Merge pull request 'Add ticket-driven branch creation flow' (#19) from codex/0.2-gitea-integration/05-ticket-branch-creation-from-ticket-detail into main
Reviewed-on: OpenSource/KanbanGit#19
This commit was merged in pull request #19.
This commit is contained in:
@@ -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 getRepository(
|
||||
string $baseUrl,
|
||||
string $token,
|
||||
string $owner,
|
||||
string $repository,
|
||||
): GitRepository;
|
||||
|
||||
/**
|
||||
* @return array<int, GitRepository>
|
||||
*/
|
||||
@@ -38,4 +45,13 @@ interface GitProviderClient
|
||||
string $owner,
|
||||
string $repository,
|
||||
): array;
|
||||
|
||||
public function createBranch(
|
||||
string $baseUrl,
|
||||
string $token,
|
||||
string $owner,
|
||||
string $repository,
|
||||
string $newBranchName,
|
||||
?string $oldRefName = null,
|
||||
): GitBranch;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ final readonly class GitRepository
|
||||
public string $name,
|
||||
public string $fullName,
|
||||
public string $htmlUrl,
|
||||
public ?string $defaultBranch,
|
||||
public bool $isPrivate,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
@@ -61,14 +85,7 @@ class GiteaProviderClient implements GitProviderClient
|
||||
->json();
|
||||
|
||||
foreach ($payload as $repository) {
|
||||
$repositories[] = new GitRepository(
|
||||
providerRepositoryId: (string) $repository['id'],
|
||||
owner: $repository['owner']['login'],
|
||||
name: $repository['name'],
|
||||
fullName: $repository['full_name'],
|
||||
htmlUrl: $repository['html_url'],
|
||||
isPrivate: $repository['private'],
|
||||
);
|
||||
$repositories[] = $this->mapRepository($repository);
|
||||
}
|
||||
|
||||
$page++;
|
||||
@@ -166,6 +183,36 @@ class GiteaProviderClient implements GitProviderClient
|
||||
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
|
||||
{
|
||||
return Http::acceptJson()
|
||||
@@ -189,4 +236,30 @@ class GiteaProviderClient implements GitProviderClient
|
||||
->append('/api/v1')
|
||||
->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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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;
|
||||
|
||||
use App\Actions\Git\BuildSuggestedTicketBranchName;
|
||||
use App\Actions\Git\ResolveTicketBranchMatches;
|
||||
use App\Actions\Git\ResolveTicketPullRequestMatches;
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
@@ -19,6 +20,7 @@ use Inertia\Response;
|
||||
class ProjectTicketController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
|
||||
private readonly ProjectPermissionService $projectPermissionService,
|
||||
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
|
||||
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
|
||||
@@ -61,6 +63,7 @@ class ProjectTicketController extends Controller
|
||||
$ticket,
|
||||
),
|
||||
'status' => session('status'),
|
||||
'suggestedBranchName' => $this->buildSuggestedTicketBranchName->handle($ticket),
|
||||
'ticket' => $ticket->load([
|
||||
'fieldValues.ticketFieldDefinition',
|
||||
'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');
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ Git integration foundation:
|
||||
- Gitea repository connections
|
||||
- Branch correlation
|
||||
- Pull request correlation
|
||||
- Ticket-driven branch creation
|
||||
|
||||
### Phase 4
|
||||
|
||||
|
||||
@@ -220,6 +220,12 @@ Required properties:
|
||||
- Repository connection
|
||||
- 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
|
||||
|
||||
Represents a pull request associated with a ticket through a repository connection.
|
||||
|
||||
@@ -252,11 +252,21 @@ Initial expectations:
|
||||
- The first supported provider is Gitea.
|
||||
- Later providers should include GitHub and potentially GitLab.
|
||||
- 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:
|
||||
|
||||
- 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:
|
||||
|
||||
- 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
|
||||
@@ -35,7 +35,7 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { getInitials } from '@/composables/useInitials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { logout } from '@/routes/index';
|
||||
import { edit } from '@/routes/profile/index';
|
||||
import type { User } from '@/types';
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { home } from '@/routes';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { home } from '@/routes';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{
|
||||
title?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
||||
import { home } from '@/routes';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
const page = usePage();
|
||||
const name = page.props.name;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import { edit as editProfile } from '@/routes/profile';
|
||||
import { edit as editSecurity } from '@/routes/security';
|
||||
import { edit as editAppearance } from '@/routes/appearance/index';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
||||
import { edit as editProfile } from '@/routes/profile/index';
|
||||
import { edit as editSecurity } from '@/routes/security/index';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
const sidebarNavItems: NavItem[] = [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { dashboard, login, register } from '@/routes';
|
||||
import { dashboard, login, register } from '@/routes/index';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { login } from '@/routes';
|
||||
import { login } from '@/routes/index';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { register } from '@/routes';
|
||||
import { register } from '@/routes/index';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase from '@/layouts/AuthLayout.vue';
|
||||
import { login } from '@/routes';
|
||||
import { login } from '@/routes/index';
|
||||
import { store } from '@/routes/register';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import TextLink from '@/components/TextLink.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes';
|
||||
import { logout } from '@/routes/index';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
const props = defineProps<ProjectAccessPageProps>();
|
||||
|
||||
@@ -18,7 +18,7 @@ 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';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
import { metadata as metadataSettings } from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
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 { index as boardIndex } from '@/routes/projects/board';
|
||||
import { show as showTicket } from '@/routes/projects/tickets';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { create, index } from '@/routes/projects';
|
||||
import { create, index } from '@/routes/projects/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
defineProps<ProjectsIndexPageProps>();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
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 backlogIndex } from '@/routes/projects/backlog';
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
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 ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
@@ -15,14 +17,24 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 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 type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
|
||||
|
||||
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[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
@@ -87,6 +119,184 @@ const form = useForm({
|
||||
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 => {
|
||||
if (!props.canEditTicket) {
|
||||
return;
|
||||
@@ -448,13 +658,198 @@ const formatDateTime = (value: string): string => {
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card class="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle>Branches</CardTitle>
|
||||
<CardDescription>
|
||||
Matching branches are discovered from linked
|
||||
repositories when the branch name starts with
|
||||
this ticket key.
|
||||
</CardDescription>
|
||||
<CardHeader
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>Branches</CardTitle>
|
||||
<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>
|
||||
|
||||
<CardContent class="space-y-4">
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
board as boardSettings,
|
||||
repositories as repositorySettings,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
board as boardSettings,
|
||||
metadata as metadataSettings,
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections';
|
||||
import { index, show } from '@/routes/projects';
|
||||
import { edit as editGitConnections } from '@/routes/git-connections/index';
|
||||
import { index, show } from '@/routes/projects/index';
|
||||
import {
|
||||
board as boardSettings,
|
||||
metadata as metadataSettings,
|
||||
|
||||
@@ -4,7 +4,7 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.vue';
|
||||
import { edit } from '@/routes/appearance';
|
||||
import { edit } from '@/routes/appearance/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbItems: BreadcrumbItem[] = [
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.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';
|
||||
|
||||
defineProps<SettingsGitConnectionsPageProps>();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import AppLayout from '@/layouts/AppLayout.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 type { BreadcrumbItem } from '@/types';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
||||
import AppLayout from '@/layouts/AppLayout.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 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;
|
||||
status: string;
|
||||
title: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -5,5 +5,4 @@ export type TicketRepositoryPullRequestModel = {
|
||||
error: string | null;
|
||||
pull_requests: TicketPullRequestModel[];
|
||||
repository_connection: RepositoryConnectionModel;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ export type ProjectTicketPageProps = {
|
||||
repositoryBranches: TicketRepositoryBranchModel[];
|
||||
repositoryPullRequests: TicketRepositoryPullRequestModel[];
|
||||
status?: string | null;
|
||||
suggestedBranchName: string;
|
||||
ticket: TicketModel;
|
||||
ticketFieldDefinitions: TicketFieldDefinitionModel[];
|
||||
ticketLinkOptions: TicketReferenceModel[];
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\ProjectBoardController;
|
||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectRepositorySettingsController;
|
||||
use App\Http\Controllers\ProjectTicketBranchController;
|
||||
use App\Http\Controllers\ProjectTicketController;
|
||||
use App\Http\Controllers\ProjectTicketLinkController;
|
||||
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
||||
@@ -41,6 +42,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
->name('projects.tickets.show');
|
||||
Route::patch('projects/{project}/tickets/{ticket}', [ProjectTicketController::class, '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'])
|
||||
->name('projects.tickets.links.store');
|
||||
Route::delete('projects/{project}/tickets/{ticket}/links/{ticketLink}', [ProjectTicketLinkController::class, 'destroy'])
|
||||
|
||||
@@ -58,6 +58,7 @@ class GiteaProviderTest extends TestCase
|
||||
'name' => 'kanbangit',
|
||||
'full_name' => 'nico/kanbangit',
|
||||
'html_url' => 'https://git.example.test/nico/kanbangit',
|
||||
'default_branch' => 'main',
|
||||
'owner' => [
|
||||
'login' => 'nico',
|
||||
],
|
||||
@@ -75,6 +76,7 @@ class GiteaProviderTest extends TestCase
|
||||
$this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories);
|
||||
$this->assertSame('501', $repositories[0]->providerRepositoryId);
|
||||
$this->assertSame('nico/kanbangit', $repositories[0]->fullName);
|
||||
$this->assertSame('main', $repositories[0]->defaultBranch);
|
||||
$this->assertFalse($repositories[0]->isPrivate);
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
->where('project.id', $project->id)
|
||||
->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.value_number', 5)
|
||||
->where('ticket.worklog_entries.0.actor.name', $viewer->name)
|
||||
@@ -202,6 +203,7 @@ class ProjectTicketControllerTest extends TestCase
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/Ticket')
|
||||
->where('suggestedBranchName', 'core-1-branch-detail-test')
|
||||
->where('repositoryBranches.0.repository_connection.repository_full_name', 'core-team/kanbangit')
|
||||
->has('repositoryBranches.0.branches', 2)
|
||||
->where('repositoryBranches.0.branches.0.name', 'CORE-1-login-form')
|
||||
|
||||
Reference in New Issue
Block a user