Add ticket-driven branch creation flow #19

Merged
Twyco merged 3 commits from codex/0.2-gitea-integration/05-ticket-branch-creation-from-ticket-detail into main 2026-04-01 19:37:01 +00:00
48 changed files with 1329 additions and 50 deletions
@@ -0,0 +1,28 @@
<?php
namespace App\Actions\Git;
use App\Models\Ticket;
use Illuminate\Support\Str;
class BuildSuggestedTicketBranchName
{
public function handle(Ticket $ticket): string
{
$titleSlug = Str::of($ticket->title)
->lower()
->replaceMatches('/[^a-z0-9\s-]+/', '')
->trim()
->replaceMatches('/\s+/', '-')
->replaceMatches('/-+/', '-')
->value();
$ticketKey = Str::lower($ticket->ticket_key);
if ($titleSlug === '') {
return $ticketKey;
}
return "{$ticketKey}-{$titleSlug}";
}
}
@@ -14,6 +14,13 @@ interface GitProviderClient
public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser; public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser;
public function getRepository(
string $baseUrl,
string $token,
string $owner,
string $repository,
): GitRepository;
/** /**
* @return array<int, GitRepository> * @return array<int, GitRepository>
*/ */
@@ -38,4 +45,13 @@ interface GitProviderClient
string $owner, string $owner,
string $repository, string $repository,
): array; ): array;
public function createBranch(
string $baseUrl,
string $token,
string $owner,
string $repository,
string $newBranchName,
?string $oldRefName = null,
Review

ist der oldRefName nötig?

ist der oldRefName nötig?
Review

Ja, das ist der Branch name, von dem der neue branch erstellt wird, also bswp erstelle branch von main oder dev oder features/PROJ-124-new-feat

Ja, das ist der Branch name, von dem der neue branch erstellt wird, also bswp erstelle branch von main oder dev oder features/PROJ-124-new-feat
): GitBranch;
} }
+1
View File
@@ -13,6 +13,7 @@ final readonly class GitRepository
public string $name, public string $name,
public string $fullName, public string $fullName,
public string $htmlUrl, public string $htmlUrl,
public ?string $defaultBranch,
public bool $isPrivate, public bool $isPrivate,
) {} ) {}
} }
+81 -8
View File
@@ -34,6 +34,30 @@ class GiteaProviderClient implements GitProviderClient
); );
} }
public function getRepository(
string $baseUrl,
string $token,
string $owner,
string $repository,
): GitRepository {
/** @var array{
* default_branch?: string|null,
* id: int|string,
* name: string,
* full_name: string,
* html_url: string,
* owner: array{login: string},
* private: bool
* } $payload
*/
$payload = $this->request($baseUrl, $token)
->get('/repos/'.rawurlencode($owner).'/'.rawurlencode($repository))
->throw()
->json();
return $this->mapRepository($payload);
}
/** /**
* @return array<int, GitRepository> * @return array<int, GitRepository>
*/ */
@@ -61,14 +85,7 @@ class GiteaProviderClient implements GitProviderClient
->json(); ->json();
foreach ($payload as $repository) { foreach ($payload as $repository) {
$repositories[] = new GitRepository( $repositories[] = $this->mapRepository($repository);
providerRepositoryId: (string) $repository['id'],
owner: $repository['owner']['login'],
name: $repository['name'],
fullName: $repository['full_name'],
htmlUrl: $repository['html_url'],
isPrivate: $repository['private'],
);
} }
$page++; $page++;
@@ -166,6 +183,36 @@ class GiteaProviderClient implements GitProviderClient
return $pullRequests; return $pullRequests;
} }
public function createBranch(
string $baseUrl,
string $token,
string $owner,
string $repository,
string $newBranchName,
?string $oldRefName = null,
): GitBranch {
$payload = [
'new_branch_name' => $newBranchName,
];
if ($oldRefName !== null && $oldRefName !== '') {
$payload['old_ref_name'] = $oldRefName;
}
/** @var array{name: string} $response */
$response = $this->request($baseUrl, $token)
->post(
'/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/branches',
$payload,
)
->throw()
->json();
return new GitBranch(
name: $response['name'],
);
}
private function request(string $baseUrl, string $token): PendingRequest private function request(string $baseUrl, string $token): PendingRequest
{ {
return Http::acceptJson() return Http::acceptJson()
@@ -189,4 +236,30 @@ class GiteaProviderClient implements GitProviderClient
->append('/api/v1') ->append('/api/v1')
->value(); ->value();
} }
/**
* @param array{
* default_branch?: string|null,
* id: int|string,
* name: string,
* full_name: string,
* html_url: string,
* owner: array{login: string},
* private: bool
* } $repository
*/
private function mapRepository(array $repository): GitRepository
{
return new GitRepository(
providerRepositoryId: (string) $repository['id'],
owner: $repository['owner']['login'],
name: $repository['name'],
fullName: $repository['full_name'],
htmlUrl: $repository['html_url'],
defaultBranch: blank($repository['default_branch'] ?? null)
? null
: $repository['default_branch'],
isPrivate: $repository['private'],
);
}
} }
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Actions\Git;
class ValidateGitBranchName
{
public function message(string $branchName): ?string
{
$trimmedBranchName = trim($branchName);
if ($trimmedBranchName === '') {
return 'Branch name is required.';
}
if (preg_match('/\s/', $trimmedBranchName) === 1) {
return 'Branch names must not contain whitespace.';
}
if (
str_starts_with($trimmedBranchName, '/') ||
str_ends_with($trimmedBranchName, '/') ||
str_starts_with($trimmedBranchName, '.') ||
str_ends_with($trimmedBranchName, '.')
) {
return 'Branch names must not start or end with "/" or ".".';
}
if (
str_contains($trimmedBranchName, '..') ||
str_contains($trimmedBranchName, '//') ||
str_contains($trimmedBranchName, '@{')
) {
return 'Branch names contain an unsupported ref pattern.';
}
if (preg_match('/[~^:?*\[\\\\]/', $trimmedBranchName) === 1) {
return 'Branch names contain unsupported characters.';
}
if (str_ends_with($trimmedBranchName, '.lock')) {
return 'Branch names must not end with ".lock".';
}
if (preg_match('/[\x00-\x1F\x7F]/', $trimmedBranchName) === 1) {
return 'Branch names contain unsupported control characters.';
}
return null;
}
}
@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\GitProviderManager;
use App\Actions\Git\ValidateGitBranchName;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Http\Requests\StoreProjectTicketBranchRequest;
use App\Http\Requests\ValidateProjectTicketBranchRequest;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\UserGitConnection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Throwable;
class ProjectTicketBranchController extends Controller
Twyco marked this conversation as resolved
Review

Der Controller sollte nicht so fett werden imo, das sind zuviele methods imo

Der Controller sollte nicht so fett werden imo, das sind zuviele methods imo
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly ValidateGitBranchName $validateGitBranchName,
private readonly ProjectPermissionService $projectPermissionService,
) {}
public function validate(
ValidateProjectTicketBranchRequest $request,
Project $project,
Ticket $ticket,
): JsonResponse {
abort_unless(
$this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
403,
);
$repositoryConnection = $this->projectRepositoryConnection(
$project,
$request->repositoryConnectionId(),
);
$userGitConnection = $this->matchingUserGitConnection(
$request->user()->id,
$repositoryConnection,
);
if ($userGitConnection === null) {
return response()->json([
'is_valid' => false,
'message' => 'Connect a matching Git connection in your user settings first.',
]);
}
$syntaxMessage = $this->validateGitBranchName->message($request->branchName());
if ($syntaxMessage !== null) {
return response()->json([
'is_valid' => false,
'message' => $syntaxMessage,
]);
}
try {
$branchAlreadyExists = collect(
$this->gitProviderManager
->client($userGitConnection->provider)
->listBranches(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
),
)
->contains(
fn (GitBranch $branch): bool => $branch->name === $request->branchName(),
);
} catch (Throwable) {
return response()->json([
'is_valid' => false,
'message' => 'KanbanGit could not validate this branch name right now.',
]);
}
if ($branchAlreadyExists) {
return response()->json([
'is_valid' => false,
'message' => 'This branch name already exists in the selected repository.',
]);
}
return response()->json([
'is_valid' => true,
'message' => "Branch name is available for {$ticket->ticket_key}.",
]);
}
public function store(
StoreProjectTicketBranchRequest $request,
Project $project,
Ticket $ticket,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userHasPermission($request->user(), $project, 'edit'),
403,
);
$repositoryConnection = $this->projectRepositoryConnection(
$project,
$request->repositoryConnectionId(),
);
$userGitConnection = $this->matchingUserGitConnection(
$request->user()->id,
$repositoryConnection,
);
if ($userGitConnection === null) {
throw ValidationException::withMessages([
'repository_connection_id' => 'Connect a matching Git connection in your user settings first.',
]);
}
$syntaxMessage = $this->validateGitBranchName->message($request->branchName());
if ($syntaxMessage !== null) {
throw ValidationException::withMessages([
'branch_name' => $syntaxMessage,
]);
}
$gitProvider = $this->gitProviderManager->client($userGitConnection->provider);
try {
$branchAlreadyExists = collect(
$gitProvider->listBranches(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
),
)
->contains(
fn (GitBranch $branch): bool => $branch->name === $request->branchName(),
);
} catch (Throwable) {
throw ValidationException::withMessages([
'branch_name' => 'KanbanGit could not validate this branch name right now.',
]);
}
if ($branchAlreadyExists) {
throw ValidationException::withMessages([
'branch_name' => 'This branch name already exists in the selected repository.',
]);
}
try {
$repository = $gitProvider->getRepository(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
);
$gitProvider->createBranch(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
$request->branchName(),
$repository->defaultBranch,
);
} catch (Throwable) {
throw ValidationException::withMessages([
'branch_name' => 'KanbanGit could not create this branch right now.',
]);
}
return to_route('projects.tickets.show', [$project, $ticket])
->with(
'status',
"{$request->branchName()} was created in {$repositoryConnection->repository_full_name}.",
);
}
private function projectRepositoryConnection(Project $project, int $repositoryConnectionId): RepositoryConnection
{
return $project->repositoryConnections()
->findOrFail($repositoryConnectionId);
}
private function matchingUserGitConnection(
int $userId,
RepositoryConnection $repositoryConnection,
): ?UserGitConnection {
return UserGitConnection::query()
->where('user_id', $userId)
->where('provider', $repositoryConnection->provider)
->where('base_url', $repositoryConnection->base_url)
->first();
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Git\BuildSuggestedTicketBranchName;
use App\Actions\Git\ResolveTicketBranchMatches; use App\Actions\Git\ResolveTicketBranchMatches;
use App\Actions\Git\ResolveTicketPullRequestMatches; use App\Actions\Git\ResolveTicketPullRequestMatches;
use App\Actions\ProjectPermissions\ProjectPermissionService; use App\Actions\ProjectPermissions\ProjectPermissionService;
@@ -19,6 +20,7 @@ use Inertia\Response;
class ProjectTicketController extends Controller class ProjectTicketController extends Controller
{ {
public function __construct( public function __construct(
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
private readonly ProjectPermissionService $projectPermissionService, private readonly ProjectPermissionService $projectPermissionService,
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches, private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches, private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
@@ -61,6 +63,7 @@ class ProjectTicketController extends Controller
$ticket, $ticket,
), ),
'status' => session('status'), 'status' => session('status'),
'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket),
'ticket' => $ticket->load([ 'ticket' => $ticket->load([
'fieldValues.ticketFieldDefinition', 'fieldValues.ticketFieldDefinition',
'fieldValues.userValue', 'fieldValues.userValue',
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreProjectTicketBranchRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
];
}
public function branchName(): string
{
return trim((string) $this->validated('branch_name'));
}
public function repositoryConnectionId(): int
{
return (int) $this->validated('repository_connection_id');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ValidateProjectTicketBranchRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'],
];
}
public function branchName(): string
{
return trim((string) $this->validated('branch_name'));
}
public function repositoryConnectionId(): int
{
return (int) $this->validated('repository_connection_id');
}
}
+1
View File
@@ -202,6 +202,7 @@ Git integration foundation:
- Gitea repository connections - Gitea repository connections
- Branch correlation - Branch correlation
- Pull request correlation - Pull request correlation
- Ticket-driven branch creation
### Phase 4 ### Phase 4
+6
View File
@@ -220,6 +220,12 @@ Required properties:
- Repository connection - Repository connection
- Branch name - Branch name
Behavior:
- Branch matching is based on the ticket key prefix.
- The branch list may also be the entry point for creating a new branch from the ticket detail view.
- Branch creation depends on the current user's personal Git credentials for the selected repository connection.
### PullRequestReference ### PullRequestReference
Represents a pull request associated with a ticket through a repository connection. Represents a pull request associated with a ticket through a repository connection.
+10
View File
@@ -252,11 +252,21 @@ Initial expectations:
- The first supported provider is Gitea. - The first supported provider is Gitea.
- Later providers should include GitHub and potentially GitLab. - Later providers should include GitHub and potentially GitLab.
- Tickets will later expose branch and pull request sections. - Tickets will later expose branch and pull request sections.
- The ticket branch section should eventually allow users to create a new branch directly from the ticket detail view.
Ticket-to-Git matching rule: Ticket-to-Git matching rule:
- A branch or pull request belongs to a ticket when its branch name starts with the ticket key. - A branch or pull request belongs to a ticket when its branch name starts with the ticket key.
Branch creation expectations:
- The ticket detail view may expose a branch creation action inside the branch section.
- The branch creation flow should let the user choose from all project-linked repositories.
- Repository choices remain visible even when the current user has no matching personal Git connection.
- If the user lacks access to the selected repository, branch creation fields must stay disabled and guide the user to their Git connection settings.
- The suggested branch name should start from the ticket key plus a title-derived suffix and remain editable before creation.
- The UI should validate that the proposed branch name is syntactically acceptable and not already taken before creation is submitted.
Example: Example:
- Ticket key: `AUTH-42` - Ticket key: `AUTH-42`
@@ -0,0 +1,41 @@
# 05 Ticket Branch Creation from Ticket Detail
## Goal
Allow users to create a new repository branch directly from the ticket detail view while preserving the existing project-linked repository model and per-user Git credentials.
## Requirements
- Add a branch creation action to the ticket branch section.
- Opening the action must show a modal dialog.
- The dialog must list all repositories linked to the project.
- The repository dropdown must still show repositories the current user cannot access through their own Git connection.
- If the selected repository is not accessible for the current user:
- the branch name field must be disabled
- the final create button must be disabled
- the UI must guide the user to their Git connection settings
- If the selected repository is accessible:
- prefill the branch name with the ticket key plus the ticket title
- convert the suggestion to lowercase
- replace spaces with `-`
- allow the user to edit the branch name before creation
- The dialog must show a validation state with a positive or negative indicator and a human-readable message.
- Validation must cover both:
- branch name syntax
- branch name uniqueness for the selected repository
- The create action must create the branch in Gitea.
## Acceptance Criteria
- The ticket branch section exposes a branch creation button.
- Clicking the button opens a modal with a repository dropdown.
- Selecting an inaccessible repository disables the rest of the flow and points the user to Git connection settings.
- Selecting an accessible repository prepopulates the branch name using the ticket key and title pattern.
- The validation message updates before creation and prevents creating an already existing branch.
- Submitting the dialog creates the branch in the selected repository and returns the user to the ticket detail view.
## Out of Scope
- Choosing a custom source branch inside the UI
- Branch creation for providers other than Gitea
- Automatic pull request creation after branch creation
+1 -1
View File
@@ -35,7 +35,7 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials'; import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes/index';
import type { BreadcrumbItem, NavItem } from '@/types'; import type { BreadcrumbItem, NavItem } from '@/types';
type Props = { type Props = {
+2 -2
View File
@@ -8,8 +8,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue'; import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes'; import { logout } from '@/routes/index';
import { edit } from '@/routes/profile'; import { edit } from '@/routes/profile/index';
import type { User } from '@/types'; import type { User } from '@/types';
type Props = { type Props = {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { home } from '@/routes'; import { home } from '@/routes/index';
defineProps<{ defineProps<{
title?: string; title?: string;
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { home } from '@/routes'; import { home } from '@/routes/index';
defineProps<{ defineProps<{
title?: string; title?: string;
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
import AppLogoIcon from '@/components/AppLogoIcon.vue'; import AppLogoIcon from '@/components/AppLogoIcon.vue';
import { home } from '@/routes'; import { home } from '@/routes/index';
const page = usePage(); const page = usePage();
const name = page.props.name; const name = page.props.name;
+4 -4
View File
@@ -5,10 +5,10 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance'; import { edit as editAppearance } from '@/routes/appearance/index';
import { edit as editGitConnections } from '@/routes/git-connections'; import { edit as editGitConnections } from '@/routes/git-connections/index';
import { edit as editProfile } from '@/routes/profile'; import { edit as editProfile } from '@/routes/profile/index';
import { edit as editSecurity } from '@/routes/security'; import { edit as editSecurity } from '@/routes/security/index';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [ const sidebarNavItems: NavItem[] = [
+1 -1
View File
@@ -2,7 +2,7 @@
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue'; import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { dashboard, login, register } from '@/routes'; import { dashboard, login, register } from '@/routes/index';
withDefaults( withDefaults(
defineProps<{ defineProps<{
+1 -1
View File
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue'; import AuthLayout from '@/layouts/AuthLayout.vue';
import { login } from '@/routes'; import { login } from '@/routes/index';
import { email } from '@/routes/password'; import { email } from '@/routes/password';
defineProps<{ defineProps<{
+1 -1
View File
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue'; import AuthBase from '@/layouts/AuthLayout.vue';
import { register } from '@/routes'; import { register } from '@/routes/index';
import { store } from '@/routes/login'; import { store } from '@/routes/login';
import { request } from '@/routes/password'; import { request } from '@/routes/password';
+1 -1
View File
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import AuthBase from '@/layouts/AuthLayout.vue'; import AuthBase from '@/layouts/AuthLayout.vue';
import { login } from '@/routes'; import { login } from '@/routes/index';
import { store } from '@/routes/register'; import { store } from '@/routes/register';
</script> </script>
+1 -1
View File
@@ -4,7 +4,7 @@ import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue'; import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes'; import { logout } from '@/routes/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
defineProps<{ defineProps<{
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects'; import { access, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types'; import type { BreadcrumbItem, ProjectAccessPageProps } from '@/types';
const props = defineProps<ProjectAccessPageProps>(); const props = defineProps<ProjectAccessPageProps>();
+1 -1
View File
@@ -18,7 +18,7 @@ import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import { metadata as metadataSettings } from '@/routes/projects/settings'; import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects'; import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings'; import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
+1 -1
View File
@@ -14,7 +14,7 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index } from '@/routes/projects'; import { create, index } from '@/routes/projects/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects'; import { create, index, show } from '@/routes/projects/index';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types'; import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>(); defineProps<ProjectsIndexPageProps>();
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects'; import { access, index, show } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { import {
+405 -10
View File
@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, useForm, Head, Link } from '@inertiajs/vue3'; import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import { computed } from 'vue'; import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
@@ -15,14 +17,24 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections'; import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects'; import { index as projectIndex, show } from '@/routes/projects/index';
import { show as showTicket } from '@/routes/projects/tickets'; import { show as showTicket } from '@/routes/projects/tickets';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types'; import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>(); const props = defineProps<ProjectTicketPageProps>();
@@ -45,6 +57,26 @@ const visibleRepositoryPullRequests = computed(() =>
), ),
); );
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
title: 'Projects', title: 'Projects',
@@ -87,6 +119,184 @@ const form = useForm({
title: props.ticket.title, title: props.ticket.title,
}); });
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>({
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() => selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
([isOpen, repositoryConnectionId, branchName], _previousValues, onCleanup) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message:
'Connect a matching Git connection in your user settings first.',
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: 'Branch name is required.',
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.project.id,
ticket: props.ticket.id,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message:
'KanbanGit could not validate this branch name right now.',
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchCreationForm.submit(
ProjectTicketBranchController.store({
project: props.project.id,
ticket: props.ticket.id,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
const submit = (): void => { const submit = (): void => {
if (!props.canEditTicket) { if (!props.canEditTicket) {
return; return;
@@ -448,13 +658,198 @@ const formatDateTime = (value: string): string => {
<div class="space-y-6"> <div class="space-y-6">
<Card class="h-fit"> <Card class="h-fit">
<CardHeader> <CardHeader
<CardTitle>Branches</CardTitle> class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
<CardDescription> >
Matching branches are discovered from linked <div class="space-y-1.5">
repositories when the branch name starts with <CardTitle>Branches</CardTitle>
this ticket key. <CardDescription>
</CardDescription> Matching branches are discovered from
linked repositories when the branch name
starts with this ticket key.
</CardDescription>
</div>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />
Create branch
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>
Create branch from ticket
</DialogTitle>
<DialogDescription>
Pick a linked repository, confirm
the suggested branch name, and
create it directly in Gitea.
</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<div class="grid gap-2">
<Label for="repository_connection_id">
Repository
</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="
branchCreationForm.processing
"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="
String(
repositoryConnection.id,
)
"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid gap-2">
<Label for="branch_name">
Branch name
</Label>
<Input
id="branch_name"
v-model="
branchCreationForm.branch_name
"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
/>
<InputError
:message="
branchCreationForm.errors
.branch_name
"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div
class="flex items-start gap-3"
>
<CheckCircle2
v-if="
branchNameValidation?.is_valid
"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="
branchNameValidation !==
null
"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? 'Checking whether this branch name is valid and still available...'
: branchNameValidation?.message ??
'Select a linked repository to validate the branch name.'
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !==
null
"
class="rounded-xl border border-dashed border-border px-4 py-4"
>
<p
class="text-sm text-muted-foreground"
>
You can only create branches in
repositories that match one of
your personal Git connections.
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link
:href="
editGitConnections()
"
>
Open Git connections
</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="
isCreateBranchDialogOpen =
false
"
>
Cancel
</Button>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !==
true
"
>
Create now
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
@@ -15,7 +15,7 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects/index';
import { import {
board as boardSettings, board as boardSettings,
repositories as repositorySettings, repositories as repositorySettings,
@@ -15,7 +15,7 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects/index';
import { import {
board as boardSettings, board as boardSettings,
metadata as metadataSettings, metadata as metadataSettings,
@@ -15,8 +15,8 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections'; import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index, show } from '@/routes/projects'; import { index, show } from '@/routes/projects/index';
import { import {
board as boardSettings, board as boardSettings,
metadata as metadataSettings, metadata as metadataSettings,
+1 -1
View File
@@ -4,7 +4,7 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/appearance'; import { edit } from '@/routes/appearance/index';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems: BreadcrumbItem[] = [
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit as editGitConnections } from '@/routes/git-connections'; import { edit as editGitConnections } from '@/routes/git-connections/index';
import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types'; import type { BreadcrumbItem, SettingsGitConnectionsPageProps } from '@/types';
defineProps<SettingsGitConnectionsPageProps>(); defineProps<SettingsGitConnectionsPageProps>();
+1 -1
View File
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/profile'; import { edit } from '@/routes/profile/index';
import { send } from '@/routes/verification'; import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
+1 -1
View File
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth'; import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue'; import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/security'; import { edit } from '@/routes/security/index';
import { disable, enable } from '@/routes/two-factor'; import { disable, enable } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
@@ -0,0 +1,4 @@
export type BranchNameValidationModel = {
is_valid: boolean;
message: string;
};
@@ -4,5 +4,4 @@ export type TicketPullRequestModel = {
source_branch: string; source_branch: string;
status: string; status: string;
title: string; title: string;
[key: string]: unknown;
}; };
@@ -5,5 +5,4 @@ export type TicketRepositoryPullRequestModel = {
error: string | null; error: string | null;
pull_requests: TicketPullRequestModel[]; pull_requests: TicketPullRequestModel[];
repository_connection: RepositoryConnectionModel; repository_connection: RepositoryConnectionModel;
[key: string]: unknown;
}; };
+1
View File
@@ -106,6 +106,7 @@ export type ProjectTicketPageProps = {
repositoryBranches: TicketRepositoryBranchModel[]; repositoryBranches: TicketRepositoryBranchModel[];
repositoryPullRequests: TicketRepositoryPullRequestModel[]; repositoryPullRequests: TicketRepositoryPullRequestModel[];
status?: string | null; status?: string | null;
suggestedBranchName: string;
ticket: TicketModel; ticket: TicketModel;
ticketFieldDefinitions: TicketFieldDefinitionModel[]; ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketLinkOptions: TicketReferenceModel[]; ticketLinkOptions: TicketReferenceModel[];
+5
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\ProjectBoardController;
use App\Http\Controllers\ProjectBoardSettingsController; use App\Http\Controllers\ProjectBoardSettingsController;
use App\Http\Controllers\ProjectController; use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectRepositorySettingsController; use App\Http\Controllers\ProjectRepositorySettingsController;
use App\Http\Controllers\ProjectTicketBranchController;
use App\Http\Controllers\ProjectTicketController; use App\Http\Controllers\ProjectTicketController;
use App\Http\Controllers\ProjectTicketLinkController; use App\Http\Controllers\ProjectTicketLinkController;
use App\Http\Controllers\ProjectTicketMetadataSettingsController; use App\Http\Controllers\ProjectTicketMetadataSettingsController;
@@ -41,6 +42,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('projects.tickets.show'); ->name('projects.tickets.show');
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update']) Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, 'update'])
->name('projects.tickets.update'); ->name('projects.tickets.update');
Route::get('projects/{project}/tickets/{ticket}/branches/validate', [ProjectTicketBranchController::class, 'validate'])
->name('projects.tickets.branches.validate');
Route::post('projects/{project}/tickets/{ticket}/branches', [ProjectTicketBranchController::class, 'store'])
->name('projects.tickets.branches.store');
Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store']) Route::post('projects/{project}/tickets/{ticket}/links', [ProjectTicketLinkController::class, 'store'])
->name('projects.tickets.links.store'); ->name('projects.tickets.links.store');
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy']) Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
+69
View File
@@ -58,6 +58,7 @@ class GiteaProviderTest extends TestCase
'name' => 'kanbangit', 'name' => 'kanbangit',
'full_name' => 'nico/kanbangit', 'full_name' => 'nico/kanbangit',
'html_url' => 'https://git.example.test/nico/kanbangit', 'html_url' => 'https://git.example.test/nico/kanbangit',
'default_branch' => 'main',
'owner' => [ 'owner' => [
'login' => 'nico', 'login' => 'nico',
], ],
@@ -75,6 +76,7 @@ class GiteaProviderTest extends TestCase
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories); $this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
$this->assertSame('501', $repositories[0]->providerRepositoryId); $this->assertSame('501', $repositories[0]->providerRepositoryId);
$this->assertSame('nico/kanbangit', $repositories[0]->fullName); $this->assertSame('nico/kanbangit', $repositories[0]->fullName);
$this->assertSame('main', $repositories[0]->defaultBranch);
$this->assertFalse($repositories[0]->isPrivate); $this->assertFalse($repositories[0]->isPrivate);
Http::assertSent(function ($request): bool { Http::assertSent(function ($request): bool {
@@ -83,6 +85,41 @@ class GiteaProviderTest extends TestCase
}); });
} }
/**
* Ensure the provider can fetch a single repository with its default branch.
*/
public function test_provider_can_fetch_a_single_repository(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit' => Http::response([
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'core-team/kanbangit',
'html_url' => 'https://git.example.test/core-team/kanbangit',
'default_branch' => 'main',
'owner' => [
'login' => 'core-team',
],
'private' => true,
]),
]);
$repository = app(GiteaProviderClient::class)->getRepository(
'https://git.example.test',
'repo-token',
'core-team',
'kanbangit',
);
$this->assertInstanceOf(GitRepository::class, $repository);
$this->assertSame('501', $repository->providerRepositoryId);
$this->assertSame('core-team', $repository->owner);
$this->assertSame('kanbangit', $repository->name);
$this->assertSame('main', $repository->defaultBranch);
$this->assertTrue($repository->isPrivate);
}
/** /**
* Ensure the provider lists branches for a specific repository. * Ensure the provider lists branches for a specific repository.
*/ */
@@ -119,6 +156,38 @@ class GiteaProviderTest extends TestCase
}); });
} }
/**
* Ensure the provider can create branches from a base reference.
*/
public function test_provider_can_create_a_repository_branch(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches' => Http::response([
'name' => 'core-1-login-form',
], 201),
]);
$branch = app(GiteaProviderClient::class)->createBranch(
'https://git.example.test',
'branch-token',
'core-team',
'kanbangit',
'core-1-login-form',
'main',
);
$this->assertInstanceOf(GitBranch::class, $branch);
$this->assertSame('core-1-login-form', $branch->name);
Http::assertSent(function ($request): bool {
return $request->url() === 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches'
&& $request['new_branch_name'] === 'core-1-login-form'
&& $request['old_ref_name'] === 'main'
&& $request->hasHeader('Authorization', 'token branch-token');
});
}
/** /**
* Ensure the provider lists pull requests for a specific repository. * Ensure the provider lists pull requests for a specific repository.
*/ */
@@ -0,0 +1,37 @@
<?php
namespace Tests\Feature\Git;
use App\Actions\Git\TicketKeyReferenceMatcher;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TicketKeyReferenceMatcherTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure a reference must start with the exact ticket key.
*/
public function test_matcher_accepts_exact_ticket_key_prefixes(): void
{
$matcher = app(TicketKeyReferenceMatcher::class);
$this->assertTrue($matcher->matches('CORE-1', 'CORE-1-login-form'));
$this->assertTrue($matcher->matches('CORE-1', 'CORE-1/fix-oauth'));
$this->assertTrue($matcher->matches('CORE-1', 'CORE-1_hotfix'));
$this->assertTrue($matcher->matches('CORE-1', 'CORE-1'));
}
/**
* Ensure similar ticket keys are not treated as matches.
*/
public function test_matcher_rejects_other_ticket_prefixes(): void
{
$matcher = app(TicketKeyReferenceMatcher::class);
$this->assertFalse($matcher->matches('CORE-1', 'CORE-10-login-form'));
$this->assertFalse($matcher->matches('CORE-1', 'MY-CORE-1-login-form'));
$this->assertFalse($matcher->matches('CORE-1', 'core-1-login-form'));
}
}
@@ -0,0 +1,258 @@
<?php
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketPriority;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ProjectTicketBranchControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure authorized users can validate available branch names.
*/
public function test_users_with_edit_permission_can_validate_available_branch_names(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
['name' => 'main'],
['name' => 'release/1.0'],
]),
]);
[$user, $project, $ticket] = $this->ticketContext();
RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider' => GitProviderKey::Gitea,
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
UserGitConnection::factory()->for($user)->create([
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
'project' => $project,
'ticket' => $ticket,
'branch_name' => 'core-1-login-form',
'repository_connection_id' => $project->repositoryConnections()->firstOrFail()->id,
]))
->assertOk()
->assertExactJson([
'is_valid' => true,
'message' => 'Branch name is available for CORE-1.',
]);
}
/**
* Ensure branch validation reports existing branch names.
*/
public function test_branch_validation_rejects_existing_branch_names(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
['name' => 'core-1-login-form'],
]),
]);
[$user, $project, $ticket] = $this->ticketContext();
RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider' => GitProviderKey::Gitea,
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
UserGitConnection::factory()->for($user)->create([
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
'project' => $project,
'ticket' => $ticket,
'branch_name' => 'core-1-login-form',
'repository_connection_id' => $project->repositoryConnections()->firstOrFail()->id,
]))
->assertOk()
->assertExactJson([
'is_valid' => false,
'message' => 'This branch name already exists in the selected repository.',
]);
}
/**
* Ensure inaccessible repositories remain visible but cannot be used for branch creation.
*/
public function test_branch_validation_requires_a_matching_personal_git_connection(): void
{
Http::preventStrayRequests();
Http::fake();
[$user, $project, $ticket] = $this->ticketContext();
RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider' => GitProviderKey::Gitea,
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
'project' => $project,
'ticket' => $ticket,
'branch_name' => 'core-1-login-form',
'repository_connection_id' => $project->repositoryConnections()->firstOrFail()->id,
]))
->assertOk()
->assertExactJson([
'is_valid' => false,
'message' => 'Connect a matching Git connection in your user settings first.',
]);
}
/**
* Ensure users with edit permission can create branches from the ticket detail flow.
*/
public function test_users_with_edit_permission_can_create_ticket_branches(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::sequence()
->push([
['name' => 'main'],
], 200)
->push([
'name' => 'core-1-login-form',
], 201),
'https://git.example.test/api/v1/repos/core-team/kanbangit' => Http::response([
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'core-team/kanbangit',
'html_url' => 'https://git.example.test/core-team/kanbangit',
'default_branch' => 'main',
'owner' => [
'login' => 'core-team',
],
'private' => false,
]),
]);
[$user, $project, $ticket] = $this->ticketContext();
$repositoryConnection = RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider' => GitProviderKey::Gitea,
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
UserGitConnection::factory()->for($user)->create([
'api_token' => 'branch-token',
'base_url' => 'https://git.example.test',
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.branches.store', [$project, $ticket]), [
'branch_name' => ' core-1-login-form ',
'repository_connection_id' => $repositoryConnection->id,
])
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
->assertSessionHas('status', 'core-1-login-form was created in core-team/kanbangit.');
Http::assertSent(function ($request): bool {
return $request->url() === 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches'
&& $request['new_branch_name'] === 'core-1-login-form'
&& $request['old_ref_name'] === 'main'
&& $request->hasHeader('Authorization', 'token branch-token');
});
}
/**
* Ensure project edit permission is required for branch creation.
*/
public function test_branch_creation_requires_the_project_edit_permission(): void
{
Http::preventStrayRequests();
Http::fake();
[$user, $project, $ticket] = $this->ticketContext();
$repositoryConnection = RepositoryConnection::factory()->for($project)->create();
$this->actingAs($user)
->post(route('projects.tickets.branches.store', [$project, $ticket]), [
'branch_name' => 'core-1-login-form',
'repository_connection_id' => $repositoryConnection->id,
])
->assertForbidden();
}
/**
* @return array{0: User, 1: Project, 2: Ticket}
*/
private function ticketContext(): array
{
$user = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$taskType = $project->ticketTypes()->where('system_key', 'task')->firstOrFail();
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $taskType->id,
'title' => 'Login form',
]);
return [$user, $project, $ticket];
}
}
@@ -95,6 +95,7 @@ class ProjectTicketControllerTest extends TestCase
->component('projects/Ticket') ->component('projects/Ticket')
->where('project.id', $project->id) ->where('project.id', $project->id)
->where('ticket.ticket_key', 'CORE-1') ->where('ticket.ticket_key', 'CORE-1')
->where('suggestedBranchName', 'core-1-ticket-detail-test')
->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points') ->where('ticket.field_values.0.ticket_field_definition.label', 'Story Points')
->where('ticket.field_values.0.value_number', 5) ->where('ticket.field_values.0.value_number', 5)
->where('ticket.worklog_entries.0.actor.name', $viewer->name) ->where('ticket.worklog_entries.0.actor.name', $viewer->name)
@@ -202,6 +203,7 @@ class ProjectTicketControllerTest extends TestCase
->assertOk() ->assertOk()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('projects/Ticket') ->component('projects/Ticket')
->where('suggestedBranchName', 'core-1-branch-detail-test')
->where('repositoryBranches.0.repository_connection.repository_full_name', 'core-team/kanbangit') ->where('repositoryBranches.0.repository_connection.repository_full_name', 'core-team/kanbangit')
->has('repositoryBranches.0.branches', 2) ->has('repositoryBranches.0.branches', 2)
->where('repositoryBranches.0.branches.0.name', 'CORE-1-login-form') ->where('repositoryBranches.0.branches.0.name', 'CORE-1-login-form')