Add ticket-driven branch creation flow

This commit is contained in:
2026-03-26 09:39:18 +01:00
parent 789675f83f
commit 92dfe3840c
47 changed files with 1332 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 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;
}
+1
View File
@@ -13,6 +13,7 @@ final readonly class GitRepository
public string $name,
public string $fullName,
public string $htmlUrl,
public ?string $defaultBranch,
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>
*/
@@ -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'],
);
}
}
+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
{
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');
}
}