Add project repository connection settings #16
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Git\Data\GitRepository;
|
||||
use App\Actions\Git\GitProviderManager;
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Http\Requests\StoreRepositoryConnectionRequest;
|
||||
use App\Http\Requests\UpdateRepositoryConnectionRequest;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Throwable;
|
||||
|
||||
class ProjectRepositorySettingsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitProviderManager $gitProviderManager,
|
||||
private readonly ProjectPermissionService $projectPermissionService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Project $project): Response
|
||||
{
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
return Inertia::render('projects/settings/Repositories', [
|
||||
'availableRepositorySources' => $this->availableRepositorySources($request->user()),
|
||||
'project' => $project,
|
||||
'repositoryConnections' => $project->repositoryConnections()
|
||||
->get()
|
||||
->map(fn (RepositoryConnection $repositoryConnection): array => [
|
||||
'base_url' => $repositoryConnection->base_url,
|
||||
'created_at' => $repositoryConnection->created_at?->toISOString(),
|
||||
'has_personal_access' => $this->hasPersonalAccess($request->user(), $repositoryConnection),
|
||||
'id' => $repositoryConnection->id,
|
||||
'owner_name' => $repositoryConnection->owner_name,
|
||||
'project_id' => $repositoryConnection->project_id,
|
||||
'provider' => $repositoryConnection->provider->value,
|
||||
'provider_label' => $repositoryConnection->provider->label(),
|
||||
'provider_repository_id' => $repositoryConnection->provider_repository_id,
|
||||
'repository_full_name' => $repositoryConnection->repository_full_name,
|
||||
'repository_name' => $repositoryConnection->repository_name,
|
||||
'repository_url' => $repositoryConnection->repository_url,
|
||||
'updated_at' => $repositoryConnection->updated_at?->toISOString(),
|
||||
])
|
||||
->values(),
|
||||
'status' => session('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreRepositoryConnectionRequest $request,
|
||||
Project $project,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
$repositoryAttributes = $this->repositoryAttributesForSelection(
|
||||
$request->user(),
|
||||
$request->userGitConnectionId(),
|
||||
$request->providerRepositoryId(),
|
||||
);
|
||||
|
||||
$this->ensureRepositoryConnectionIsUnique($project, $repositoryAttributes);
|
||||
|
||||
$repositoryConnection = $project->repositoryConnections()->create($repositoryAttributes);
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
->with('status', "{$repositoryConnection->repository_full_name} was linked to {$project->name}.");
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpdateRepositoryConnectionRequest $request,
|
||||
Project $project,
|
||||
RepositoryConnection $repositoryConnection,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
$repositoryAttributes = $this->repositoryAttributesForSelection(
|
||||
$request->user(),
|
||||
$request->userGitConnectionId(),
|
||||
$request->providerRepositoryId(),
|
||||
);
|
||||
|
||||
$this->ensureRepositoryConnectionIsUnique(
|
||||
$project,
|
||||
$repositoryAttributes,
|
||||
$repositoryConnection,
|
||||
);
|
||||
|
||||
$repositoryConnection->update($repositoryAttributes);
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
->with('status', "{$repositoryConnection->fresh()->repository_full_name} connection was updated.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
Request $request,
|
||||
Project $project,
|
||||
RepositoryConnection $repositoryConnection,
|
||||
): RedirectResponse {
|
||||
abort_unless(
|
||||
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
|
||||
403,
|
||||
);
|
||||
|
||||
$repositoryLabel = $repositoryConnection->repository_full_name;
|
||||
|
||||
$repositoryConnection->delete();
|
||||
|
||||
return to_route('projects.settings.repositories', $project)
|
||||
->with('status', "{$repositoryLabel} was removed from the project.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{
|
||||
* account_login: string,
|
||||
* account_name: string|null,
|
||||
* base_url: string,
|
||||
* error: string|null,
|
||||
* provider: string,
|
||||
* provider_label: string,
|
||||
* repositories: array<int, array{
|
||||
* full_name: string,
|
||||
* owner_name: string,
|
||||
* provider_repository_id: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string,
|
||||
* visibility_label: string
|
||||
* }>,
|
||||
* user_git_connection_id: int
|
||||
* }>
|
||||
*/
|
||||
private function availableRepositorySources(User $user): array
|
||||
{
|
||||
/** @var Collection<int, UserGitConnection> $gitConnections */
|
||||
$gitConnections = $user->gitConnections()
|
||||
->get();
|
||||
|
||||
return $gitConnections
|
||||
->map(function (UserGitConnection $gitConnection): array {
|
||||
try {
|
||||
$repositories = collect(
|
||||
$this->gitProviderManager
|
||||
->client($gitConnection->provider)
|
||||
->listRepositories($gitConnection->base_url, $gitConnection->api_token),
|
||||
)
|
||||
->map(fn (GitRepository $repository): array => [
|
||||
'full_name' => $repository->fullName,
|
||||
'owner_name' => $repository->owner,
|
||||
'provider_repository_id' => $repository->providerRepositoryId,
|
||||
'repository_name' => $repository->name,
|
||||
'repository_url' => $repository->htmlUrl,
|
||||
'visibility_label' => $repository->isPrivate ? 'Private' : 'Public',
|
||||
])
|
||||
->sortBy('full_name')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$error = null;
|
||||
} catch (Throwable) {
|
||||
$repositories = [];
|
||||
$error = 'KanbanGit could not load repositories from this Git connection right now.';
|
||||
}
|
||||
|
||||
return [
|
||||
'account_login' => $gitConnection->account_login,
|
||||
'account_name' => $gitConnection->account_name,
|
||||
'base_url' => $gitConnection->base_url,
|
||||
'error' => $error,
|
||||
'provider' => $gitConnection->provider->value,
|
||||
'provider_label' => $gitConnection->provider->label(),
|
||||
'repositories' => $repositories,
|
||||
'user_git_connection_id' => $gitConnection->id,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* base_url: string,
|
||||
* owner_name: string,
|
||||
* provider: string,
|
||||
* provider_repository_id: string,
|
||||
* repository_full_name: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string
|
||||
* } $repositoryAttributes
|
||||
*/
|
||||
private function ensureRepositoryConnectionIsUnique(
|
||||
Project $project,
|
||||
array $repositoryAttributes,
|
||||
?RepositoryConnection $repositoryConnection = null,
|
||||
): void {
|
||||
$query = $project->repositoryConnections()
|
||||
->where('provider', $repositoryAttributes['provider'])
|
||||
->where('base_url', $repositoryAttributes['base_url'])
|
||||
->where('provider_repository_id', $repositoryAttributes['provider_repository_id']);
|
||||
|
||||
if ($repositoryConnection !== null) {
|
||||
$query->whereKeyNot($repositoryConnection->id);
|
||||
}
|
||||
|
||||
if ($query->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'provider_repository_id' => 'This repository is already linked to the project.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasPersonalAccess(User $user, RepositoryConnection $repositoryConnection): bool
|
||||
{
|
||||
return $user->gitConnections()
|
||||
->where('provider', $repositoryConnection->provider)
|
||||
->where('base_url', $repositoryConnection->base_url)
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* base_url: string,
|
||||
* owner_name: string,
|
||||
* provider: string,
|
||||
* provider_repository_id: string,
|
||||
* repository_full_name: string,
|
||||
* repository_name: string,
|
||||
* repository_url: string
|
||||
* }
|
||||
*/
|
||||
private function repositoryAttributesForSelection(
|
||||
User $user,
|
||||
int $userGitConnectionId,
|
||||
string $providerRepositoryId,
|
||||
): array {
|
||||
$userGitConnection = $this->ownedUserGitConnection($user, $userGitConnectionId);
|
||||
|
||||
$repository = $this->repositoryFromConnection(
|
||||
$userGitConnection,
|
||||
$providerRepositoryId,
|
||||
);
|
||||
|
||||
if ($repository === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'provider_repository_id' => 'The selected repository is not available through this Git connection.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'base_url' => $userGitConnection->base_url,
|
||||
'owner_name' => $repository->owner,
|
||||
'provider' => $userGitConnection->provider,
|
||||
'provider_repository_id' => $repository->providerRepositoryId,
|
||||
'repository_full_name' => $repository->fullName,
|
||||
'repository_name' => $repository->name,
|
||||
'repository_url' => $repository->htmlUrl,
|
||||
];
|
||||
}
|
||||
|
||||
private function ownedUserGitConnection(User $user, int $userGitConnectionId): UserGitConnection
|
||||
{
|
||||
$userGitConnection = UserGitConnection::query()->findOrFail($userGitConnectionId);
|
||||
|
||||
abort_unless($userGitConnection->user_id === $user->id, 403);
|
||||
|
||||
return $userGitConnection;
|
||||
}
|
||||
|
||||
private function repositoryFromConnection(
|
||||
UserGitConnection $userGitConnection,
|
||||
string $providerRepositoryId,
|
||||
): ?GitRepository {
|
||||
return collect(
|
||||
$this->gitProviderManager
|
||||
->client($userGitConnection->provider)
|
||||
->listRepositories($userGitConnection->base_url, $userGitConnection->api_token),
|
||||
)
|
||||
->first(fn (GitRepository $repository): bool => $repository->providerRepositoryId === $providerRepositoryId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreRepositoryConnectionRequest 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 [
|
||||
'provider_repository_id' => ['required', 'string', 'max:255'],
|
||||
'user_git_connection_id' => ['required', 'integer', 'exists:user_git_connections,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function providerRepositoryId(): string
|
||||
{
|
||||
return (string) $this->validated('provider_repository_id');
|
||||
}
|
||||
|
||||
public function userGitConnectionId(): int
|
||||
{
|
||||
return (int) $this->validated('user_git_connection_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateRepositoryConnectionRequest 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 [
|
||||
'provider_repository_id' => ['required', 'string', 'max:255'],
|
||||
'user_git_connection_id' => ['required', 'integer', 'exists:user_git_connections,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function providerRepositoryId(): string
|
||||
{
|
||||
return (string) $this->validated('provider_repository_id');
|
||||
}
|
||||
|
||||
public function userGitConnectionId(): int
|
||||
{
|
||||
return (int) $this->validated('user_git_connection_id');
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,13 @@ class Project extends Model
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
public function repositoryConnections(): HasMany
|
||||
{
|
||||
return $this->hasMany(RepositoryConnection::class)
|
||||
->orderBy('provider')
|
||||
->orderBy('repository_full_name');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use Database\Factories\RepositoryConnectionFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'base_url',
|
||||
'owner_name',
|
||||
'project_id',
|
||||
'provider',
|
||||
'provider_repository_id',
|
||||
'repository_full_name',
|
||||
'repository_name',
|
||||
'repository_url',
|
||||
])]
|
||||
class RepositoryConnection extends Model
|
||||
{
|
||||
/** @use HasFactory<RepositoryConnectionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'provider' => GitProviderKey::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<RepositoryConnection>
|
||||
*/
|
||||
class RepositoryConnectionFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'base_url' => 'https://git.'.fake()->domainName(),
|
||||
'owner_name' => fake()->userName(),
|
||||
'project_id' => Project::factory(),
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
'provider_repository_id' => (string) fake()->numberBetween(1, 999_999),
|
||||
'repository_full_name' => fake()->userName().'/'.fake()->slug(),
|
||||
'repository_name' => fake()->slug(),
|
||||
'repository_url' => 'https://git.'.fake()->domainName().'/'.fake()->userName().'/'.fake()->slug(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('repository_connections', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('provider');
|
||||
$table->string('base_url');
|
||||
$table->string('provider_repository_id');
|
||||
$table->string('owner_name');
|
||||
$table->string('repository_name');
|
||||
$table->string('repository_full_name');
|
||||
$table->string('repository_url');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique([
|
||||
'project_id',
|
||||
'provider',
|
||||
'base_url',
|
||||
'provider_repository_id',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('repository_connections');
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
UserSeeder::class,
|
||||
UserGitConnectionSeeder::class,
|
||||
ProjectSeeder::class,
|
||||
RepositoryConnectionSeeder::class,
|
||||
BoardColumnSeeder::class,
|
||||
ProjectTicketMetadataSeeder::class,
|
||||
TicketSeeder::class,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class RepositoryConnectionSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$project = Project::query()->where('key', 'CORE')->first()
|
||||
?? Project::query()->orderBy('id')->first();
|
||||
|
||||
if ($project === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$project->repositoryConnections()->firstOrCreate(
|
||||
[
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
'base_url' => 'https://git.example.test',
|
||||
'provider_repository_id' => '501',
|
||||
],
|
||||
[
|
||||
'owner_name' => 'core-team',
|
||||
'repository_name' => 'kanbangit',
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
'repository_url' => 'https://git.example.test/core-team/kanbangit',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,3 +66,4 @@
|
||||
|
||||
- later:
|
||||
- if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type
|
||||
- repository_connections check base Url maybe unnessecary?
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -18,6 +18,7 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
|
||||
import {
|
||||
board as boardSettings,
|
||||
metadata as metadataSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
|
||||
|
||||
@@ -53,6 +54,12 @@ const projectAreas = [
|
||||
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
title: 'Repositories',
|
||||
description:
|
||||
'Link project repositories so tickets can later discover related branches and pull requests.',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -104,6 +111,12 @@ const projectAreas = [
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="access(project.id)">Manage access</Link>
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,10 @@ 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 { board as boardSettings } from '@/routes/projects/settings';
|
||||
import {
|
||||
board as boardSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type { BreadcrumbItem, ProjectBoardSettingsPageProps } from '@/types';
|
||||
|
||||
const props = defineProps<ProjectBoardSettingsPageProps>();
|
||||
@@ -55,9 +58,17 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -19,6 +19,7 @@ import { index, show } from '@/routes/projects';
|
||||
import {
|
||||
board as boardSettings,
|
||||
metadata as metadataSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
@@ -68,6 +69,12 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="repositorySettings(project.id)">
|
||||
Repository settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head, Link, useForm } from '@inertiajs/vue3';
|
||||
import { computed, watch } from 'vue';
|
||||
import ProjectRepositorySettingsController from '@/actions/App/Http/Controllers/ProjectRepositorySettingsController';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { 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 {
|
||||
board as boardSettings,
|
||||
metadata as metadataSettings,
|
||||
repositories as repositorySettings,
|
||||
} from '@/routes/projects/settings';
|
||||
import type { AvailableGitRepositorySourceModel } from '@/types/models/available-git-repository-source-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
ProjectRepositorySettingsPageProps,
|
||||
} from '@/types';
|
||||
|
||||
const props = defineProps<ProjectRepositorySettingsPageProps>();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Projects',
|
||||
href: index(),
|
||||
},
|
||||
{
|
||||
title: props.project.name,
|
||||
href: show(props.project.id),
|
||||
},
|
||||
{
|
||||
title: 'Repository settings',
|
||||
href: repositorySettings(props.project.id),
|
||||
},
|
||||
];
|
||||
|
||||
const selectableRepositorySources = computed(() =>
|
||||
props.availableRepositorySources.filter((source) => source.repositories.length > 0),
|
||||
);
|
||||
|
||||
const repositoryConnectionById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
props.repositoryConnections.map((repositoryConnection) => [
|
||||
repositoryConnection.id,
|
||||
repositoryConnection,
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const repositoriesForSource = (
|
||||
userGitConnectionId: number | string,
|
||||
): AvailableGitRepositorySourceModel['repositories'] => {
|
||||
const normalizedUserGitConnectionId = Number(userGitConnectionId);
|
||||
|
||||
if (Number.isNaN(normalizedUserGitConnectionId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
selectableRepositorySources.value.find(
|
||||
(source) =>
|
||||
source.user_git_connection_id === normalizedUserGitConnectionId,
|
||||
)?.repositories ?? []
|
||||
);
|
||||
};
|
||||
|
||||
const matchingSourceIdForRepositoryConnection = (
|
||||
repositoryConnection: RepositoryConnectionModel | undefined,
|
||||
): string => {
|
||||
if (repositoryConnection === undefined) {
|
||||
return String(
|
||||
selectableRepositorySources.value[0]?.user_git_connection_id ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
return String(
|
||||
selectableRepositorySources.value.find(
|
||||
(source) =>
|
||||
source.provider === repositoryConnection.provider &&
|
||||
source.base_url === repositoryConnection.base_url,
|
||||
)?.user_git_connection_id ??
|
||||
selectableRepositorySources.value[0]?.user_git_connection_id ??
|
||||
'',
|
||||
);
|
||||
};
|
||||
|
||||
const createForm = useForm<{
|
||||
provider_repository_id: string;
|
||||
user_git_connection_id: string;
|
||||
}>({
|
||||
provider_repository_id: '',
|
||||
user_git_connection_id: String(
|
||||
selectableRepositorySources.value[0]?.user_git_connection_id ?? '',
|
||||
),
|
||||
});
|
||||
|
||||
const updateForm = useForm<{
|
||||
provider_repository_id: string;
|
||||
repository_connection_id: string;
|
||||
user_git_connection_id: string;
|
||||
}>({
|
||||
provider_repository_id: '',
|
||||
repository_connection_id: String(props.repositoryConnections[0]?.id ?? ''),
|
||||
user_git_connection_id: String(
|
||||
selectableRepositorySources.value[0]?.user_git_connection_id ?? '',
|
||||
),
|
||||
});
|
||||
|
||||
const createRepositories = computed(() =>
|
||||
repositoriesForSource(createForm.user_git_connection_id),
|
||||
);
|
||||
|
||||
const updateRepositories = computed(() =>
|
||||
repositoriesForSource(updateForm.user_git_connection_id),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => createForm.user_git_connection_id,
|
||||
() => {
|
||||
const repositories = createRepositories.value;
|
||||
|
||||
if (
|
||||
repositories.some(
|
||||
(repository) =>
|
||||
repository.provider_repository_id ===
|
||||
createForm.provider_repository_id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
createForm.provider_repository_id =
|
||||
repositories[0]?.provider_repository_id ?? '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => updateForm.repository_connection_id,
|
||||
(repositoryConnectionId) => {
|
||||
const repositoryConnection = repositoryConnectionById.value.get(
|
||||
Number(repositoryConnectionId),
|
||||
);
|
||||
|
||||
updateForm.user_git_connection_id =
|
||||
matchingSourceIdForRepositoryConnection(repositoryConnection);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[
|
||||
() => updateForm.user_git_connection_id,
|
||||
() => updateForm.repository_connection_id,
|
||||
],
|
||||
() => {
|
||||
const repositories = updateRepositories.value;
|
||||
const repositoryConnection = repositoryConnectionById.value.get(
|
||||
Number(updateForm.repository_connection_id),
|
||||
);
|
||||
const matchingRepository = repositories.find(
|
||||
(repository) =>
|
||||
repository.provider_repository_id ===
|
||||
repositoryConnection?.provider_repository_id,
|
||||
);
|
||||
|
||||
updateForm.provider_repository_id =
|
||||
matchingRepository?.provider_repository_id ??
|
||||
repositories[0]?.provider_repository_id ??
|
||||
'';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const submitCreate = (): void => {
|
||||
createForm.submit(
|
||||
ProjectRepositorySettingsController.store(props.project.id),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const submitUpdate = (): void => {
|
||||
if (updateForm.repository_connection_id === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateForm.submit(
|
||||
ProjectRepositorySettingsController.update([
|
||||
props.project.id,
|
||||
updateForm.repository_connection_id,
|
||||
]),
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<Head :title="`${project.name} repository settings`" />
|
||||
|
||||
<div class="flex flex-1 flex-col gap-6 p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<Badge variant="outline" class="px-3 py-1">
|
||||
{{ project.key }}
|
||||
</Badge>
|
||||
<Heading
|
||||
:title="`${project.name} repository settings`"
|
||||
description="Link project repositories through your personal Git connections so tickets can later discover matching branches and pull requests."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="boardSettings(project.id)">
|
||||
Board settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="metadataSettings(project.id)">
|
||||
Ticket settings
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="show(project.id)">Back to project</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Git access</CardTitle>
|
||||
<CardDescription>
|
||||
Project repository links are shared across the project,
|
||||
but every user needs their own Git connection to open or
|
||||
sync private repository data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex flex-wrap items-center gap-3">
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="editGitConnections()">
|
||||
Open Git connections
|
||||
</Link>
|
||||
</Button>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Without a matching personal connection, KanbanGit only
|
||||
shows the repository name and links you to your user
|
||||
settings.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[24rem_minmax(0,1fr)]">
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Link repository</CardTitle>
|
||||
<CardDescription>
|
||||
Choose one of your personal Git connections and
|
||||
link a visible repository to this project.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div
|
||||
v-if="selectableRepositorySources.length === 0"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
Add a personal Git connection first, or fix an
|
||||
existing connection that currently cannot load
|
||||
repositories.
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
class="space-y-4"
|
||||
@submit.prevent="submitCreate"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-user-git-connection">
|
||||
Personal Git connection
|
||||
</Label>
|
||||
<select
|
||||
id="create-user-git-connection"
|
||||
v-model="createForm.user_git_connection_id"
|
||||
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"
|
||||
>
|
||||
<option
|
||||
v-for="source in selectableRepositorySources"
|
||||
:key="source.user_git_connection_id"
|
||||
:value="source.user_git_connection_id"
|
||||
>
|
||||
{{
|
||||
`${source.provider_label} · ${source.account_login} @ ${source.base_url}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
createForm.errors.user_git_connection_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="create-provider-repository-id">
|
||||
Repository
|
||||
</Label>
|
||||
<select
|
||||
id="create-provider-repository-id"
|
||||
v-model="
|
||||
createForm.provider_repository_id
|
||||
"
|
||||
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"
|
||||
>
|
||||
<option
|
||||
v-for="repository in createRepositories"
|
||||
:key="
|
||||
repository.provider_repository_id
|
||||
"
|
||||
:value="
|
||||
repository.provider_repository_id
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${repository.full_name} (${repository.visibility_label})`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
createForm.errors.provider_repository_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="createForm.processing"
|
||||
>
|
||||
Link repository
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
v-if="
|
||||
repositoryConnections.length > 0 &&
|
||||
selectableRepositorySources.length > 0
|
||||
"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Relink repository</CardTitle>
|
||||
<CardDescription>
|
||||
Update an existing project repository
|
||||
connection by choosing a different repository
|
||||
from one of your personal Git connections.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form
|
||||
class="space-y-4"
|
||||
@submit.prevent="submitUpdate"
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<Label for="update-repository-connection">
|
||||
Linked repository
|
||||
</Label>
|
||||
<select
|
||||
id="update-repository-connection"
|
||||
v-model="
|
||||
updateForm.repository_connection_id
|
||||
"
|
||||
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"
|
||||
>
|
||||
<option
|
||||
v-for="repositoryConnection in repositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
:value="repositoryConnection.id"
|
||||
>
|
||||
{{
|
||||
repositoryConnection.repository_full_name
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="update-user-git-connection">
|
||||
Personal Git connection
|
||||
</Label>
|
||||
<select
|
||||
id="update-user-git-connection"
|
||||
v-model="updateForm.user_git_connection_id"
|
||||
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"
|
||||
>
|
||||
<option
|
||||
v-for="source in selectableRepositorySources"
|
||||
:key="source.user_git_connection_id"
|
||||
:value="source.user_git_connection_id"
|
||||
>
|
||||
{{
|
||||
`${source.provider_label} · ${source.account_login} @ ${source.base_url}`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
updateForm.errors.user_git_connection_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="update-provider-repository-id">
|
||||
Repository
|
||||
</Label>
|
||||
<select
|
||||
id="update-provider-repository-id"
|
||||
v-model="
|
||||
updateForm.provider_repository_id
|
||||
"
|
||||
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"
|
||||
>
|
||||
<option
|
||||
v-for="repository in updateRepositories"
|
||||
:key="
|
||||
repository.provider_repository_id
|
||||
"
|
||||
:value="
|
||||
repository.provider_repository_id
|
||||
"
|
||||
>
|
||||
{{
|
||||
`${repository.full_name} (${repository.visibility_label})`
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
:message="
|
||||
updateForm.errors.provider_repository_id
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
:disabled="updateForm.processing"
|
||||
>
|
||||
Save repository change
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card class="gap-0 overflow-hidden">
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Linked repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Project repositories are shared, but access to
|
||||
private data depends on each viewer's personal
|
||||
Git connection.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-if="repositoryConnections.length === 0"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
No repositories are linked to this project yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="repositoryConnection in repositoryConnections"
|
||||
:key="repositoryConnection.id"
|
||||
class="rounded-xl border border-border p-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge>
|
||||
{{
|
||||
repositoryConnection.provider_label
|
||||
}}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{{ repositoryConnection.base_url }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{
|
||||
repositoryConnection.repository_full_name
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{
|
||||
repositoryConnection.repository_name
|
||||
}}
|
||||
by
|
||||
{{
|
||||
repositoryConnection.owner_name
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
v-if="
|
||||
repositoryConnection.has_personal_access
|
||||
"
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<a
|
||||
:href="
|
||||
repositoryConnection.repository_url
|
||||
"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Open repository
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
as-child
|
||||
variant="outline"
|
||||
>
|
||||
<Link :href="editGitConnections()">
|
||||
Connect Git access
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!repositoryConnection.has_personal_access"
|
||||
class="mt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
This project link is visible, but you need a
|
||||
matching Git connection in your user
|
||||
settings before KanbanGit can open or query
|
||||
this repository for you.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Form
|
||||
v-bind="
|
||||
ProjectRepositorySettingsController.destroy.form(
|
||||
[
|
||||
project.id,
|
||||
repositoryConnection.id,
|
||||
],
|
||||
)
|
||||
"
|
||||
v-slot="{ processing }"
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
:disabled="processing"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
v-if="availableRepositorySources.length > 0"
|
||||
class="gap-0 overflow-hidden"
|
||||
>
|
||||
<CardHeader class="border-b">
|
||||
<CardTitle>Detected personal sources</CardTitle>
|
||||
<CardDescription>
|
||||
KanbanGit loads repositories from your personal
|
||||
Git connections and offers them for project
|
||||
linking.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-4 p-6">
|
||||
<div
|
||||
v-for="source in availableRepositorySources"
|
||||
:key="source.user_git_connection_id"
|
||||
class="rounded-xl border border-border p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge>{{ source.provider_label }}</Badge>
|
||||
<span class="text-sm font-medium">
|
||||
{{ source.account_login }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ source.base_url }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="source.error"
|
||||
class="mt-3 text-sm text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
{{ source.error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="mt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ source.repositories.length }} repositories
|
||||
available through this connection.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GitRepositoryOptionModel } from '@/types/models/git-repository-option-model';
|
||||
|
||||
export type AvailableGitRepositorySourceModel = {
|
||||
user_git_connection_id: number;
|
||||
provider: string;
|
||||
provider_label: string;
|
||||
base_url: string;
|
||||
account_login: string;
|
||||
account_name: string | null;
|
||||
error: string | null;
|
||||
repositories: GitRepositoryOptionModel[];
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type GitRepositoryOptionModel = {
|
||||
provider_repository_id: string;
|
||||
owner_name: string;
|
||||
repository_name: string;
|
||||
full_name: string;
|
||||
repository_url: string;
|
||||
visibility_label: string;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export type RepositoryConnectionModel = {
|
||||
id: number;
|
||||
project_id: number;
|
||||
provider: string;
|
||||
provider_label: string;
|
||||
base_url: string;
|
||||
provider_repository_id: string;
|
||||
owner_name: string;
|
||||
repository_name: string;
|
||||
repository_full_name: string;
|
||||
repository_url: string;
|
||||
has_personal_access: boolean;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BoardColumnModel } from '@/types/models/board-column-model';
|
||||
import type { AvailableGitRepositorySourceModel } from '@/types/models/available-git-repository-source-model';
|
||||
import type { ProjectModel } from '@/types/models/project-model';
|
||||
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
|
||||
import type { TeamModel } from '@/types/models/team-model';
|
||||
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
|
||||
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
|
||||
@@ -75,6 +77,13 @@ export type ProjectTicketMetadataSettingsPageProps = {
|
||||
ticketTypes: TicketTypeModel[];
|
||||
};
|
||||
|
||||
export type ProjectRepositorySettingsPageProps = {
|
||||
availableRepositorySources: AvailableGitRepositorySourceModel[];
|
||||
project: ProjectModel;
|
||||
repositoryConnections: RepositoryConnectionModel[];
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectBacklogPageProps = {
|
||||
boardColumns: BoardColumnModel[];
|
||||
canCreateTickets: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\ProjectBacklogController;
|
||||
use App\Http\Controllers\ProjectBoardController;
|
||||
use App\Http\Controllers\ProjectBoardSettingsController;
|
||||
use App\Http\Controllers\ProjectController;
|
||||
use App\Http\Controllers\ProjectRepositorySettingsController;
|
||||
use App\Http\Controllers\ProjectTicketController;
|
||||
use App\Http\Controllers\ProjectTicketLinkController;
|
||||
use App\Http\Controllers\ProjectTicketMetadataSettingsController;
|
||||
@@ -57,6 +58,14 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
->name('projects.settings.board.columns.destroy');
|
||||
Route::get('projects/{project}/settings/metadata', [ProjectTicketMetadataSettingsController::class, 'show'])
|
||||
->name('projects.settings.metadata');
|
||||
Route::get('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'show'])
|
||||
->name('projects.settings.repositories');
|
||||
Route::post('projects/{project}/settings/repositories', [ProjectRepositorySettingsController::class, 'store'])
|
||||
->name('projects.settings.repositories.store');
|
||||
Route::patch('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'update'])
|
||||
->name('projects.settings.repositories.update');
|
||||
Route::delete('projects/{project}/settings/repositories/{repositoryConnection}', [ProjectRepositorySettingsController::class, 'destroy'])
|
||||
->name('projects.settings.repositories.destroy');
|
||||
Route::post('projects/{project}/settings/metadata/types', [ProjectTicketMetadataSettingsController::class, 'storeType'])
|
||||
->name('projects.settings.metadata.types.store');
|
||||
Route::patch('projects/{project}/settings/metadata/types/{ticketType}', [ProjectTicketMetadataSettingsController::class, 'updateType'])
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\Project;
|
||||
use Database\Seeders\BoardColumnSeeder;
|
||||
use Database\Seeders\ProjectSeeder;
|
||||
use Database\Seeders\ProjectTicketMetadataSeeder;
|
||||
use Database\Seeders\RepositoryConnectionSeeder;
|
||||
use Database\Seeders\TicketSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
@@ -141,4 +142,56 @@ class ProjectTest extends TestCase
|
||||
$coreProject->tickets->pluck('ticket_key')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure projects expose linked repository connections in a stable order.
|
||||
*/
|
||||
public function test_projects_expose_repository_connections_in_name_order(): void
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$project->repositoryConnections()->create([
|
||||
'base_url' => 'https://git.example.test',
|
||||
'owner_name' => 'core-team',
|
||||
'provider' => 'gitea',
|
||||
'provider_repository_id' => '502',
|
||||
'repository_full_name' => 'core-team/platform',
|
||||
'repository_name' => 'platform',
|
||||
'repository_url' => 'https://git.example.test/core-team/platform',
|
||||
]);
|
||||
$project->repositoryConnections()->create([
|
||||
'base_url' => 'https://git.example.test',
|
||||
'owner_name' => 'core-team',
|
||||
'provider' => 'gitea',
|
||||
'provider_repository_id' => '501',
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
'repository_name' => 'kanbangit',
|
||||
'repository_url' => 'https://git.example.test/core-team/kanbangit',
|
||||
]);
|
||||
|
||||
$this->assertSame(
|
||||
['core-team/kanbangit', 'core-team/platform'],
|
||||
$project->fresh()->repositoryConnections->pluck('repository_full_name')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure repository connection seeding adds starter repositories to seeded projects.
|
||||
*/
|
||||
public function test_repository_connection_seeder_adds_repositories_to_seeded_projects(): void
|
||||
{
|
||||
$this->seed([
|
||||
ProjectSeeder::class,
|
||||
RepositoryConnectionSeeder::class,
|
||||
]);
|
||||
|
||||
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
|
||||
|
||||
$this->assertDatabaseHas('repository_connections', [
|
||||
'project_id' => $coreProject->id,
|
||||
'provider' => 'gitea',
|
||||
'provider_repository_id' => '501',
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Models\RepositoryConnection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RepositoryConnectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure the repository connection factory creates a project-scoped Gitea link.
|
||||
*/
|
||||
public function test_factory_creates_a_project_repository_connection(): void
|
||||
{
|
||||
$repositoryConnection = RepositoryConnection::factory()->create();
|
||||
|
||||
$this->assertSame(GitProviderKey::Gitea, $repositoryConnection->fresh()->provider);
|
||||
$this->assertNotNull($repositoryConnection->project_id);
|
||||
$this->assertNotSame('', $repositoryConnection->repository_full_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure repository connections belong to a project.
|
||||
*/
|
||||
public function test_repository_connections_belong_to_a_project(): void
|
||||
{
|
||||
$repositoryConnection = RepositoryConnection::factory()->create();
|
||||
|
||||
$this->assertTrue($repositoryConnection->project()->exists());
|
||||
$this->assertTrue($repositoryConnection->project->is($repositoryConnection->fresh()->project));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use App\Actions\Git\GitProviderKey;
|
||||
use App\Actions\ProjectPermissions\ProjectPermissionService;
|
||||
use App\Models\Project;
|
||||
use App\Models\RepositoryConnection;
|
||||
use App\Models\User;
|
||||
use App\Models\UserGitConnection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectRepositorySettingsControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure users with edit permission can view repository settings.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_view_repository_settings(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'key' => 'CORE',
|
||||
]);
|
||||
|
||||
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']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.repositories', $project))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('projects/settings/Repositories')
|
||||
->where('project.id', $project->id)
|
||||
->where('repositoryConnections.0.repository_full_name', 'core-team/kanbangit')
|
||||
->where('repositoryConnections.0.has_personal_access', false)
|
||||
->where('availableRepositorySources', []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users without edit permission cannot manage repository settings.
|
||||
*/
|
||||
public function test_users_without_edit_permission_cannot_view_repository_settings(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('projects.settings.repositories', $project))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authorized users can link a repository through their own Git connection.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_link_project_repositories(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/user/repos*' => Http::response([
|
||||
[
|
||||
'id' => 501,
|
||||
'name' => 'kanbangit',
|
||||
'full_name' => 'core-team/kanbangit',
|
||||
'html_url' => 'https://git.example.test/core-team/kanbangit',
|
||||
'owner' => [
|
||||
'login' => 'core-team',
|
||||
],
|
||||
'private' => true,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$userGitConnection = UserGitConnection::factory()->for($user)->create([
|
||||
'base_url' => 'https://git.example.test',
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.repositories.store', $project), [
|
||||
'provider_repository_id' => '501',
|
||||
'user_git_connection_id' => $userGitConnection->id,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.repositories', $project))
|
||||
->assertSessionHas('status', 'core-team/kanbangit was linked to '.$project->name.'.');
|
||||
|
||||
$this->assertDatabaseHas('repository_connections', [
|
||||
'project_id' => $project->id,
|
||||
'provider' => GitProviderKey::Gitea->value,
|
||||
'base_url' => 'https://git.example.test',
|
||||
'provider_repository_id' => '501',
|
||||
'owner_name' => 'core-team',
|
||||
'repository_name' => 'kanbangit',
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure users cannot link repositories through another user's Git connection.
|
||||
*/
|
||||
public function test_users_cannot_link_repositories_through_foreign_git_connections(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create();
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.settings.repositories.store', $project), [
|
||||
'provider_repository_id' => '501',
|
||||
'user_git_connection_id' => $foreignGitConnection->id,
|
||||
])
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authorized users can relink an existing project repository.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_update_repository_connections(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://git.internal.test/api/v1/user/repos*' => Http::response([
|
||||
[
|
||||
'id' => 902,
|
||||
'name' => 'platform',
|
||||
'full_name' => 'infra/platform',
|
||||
'html_url' => 'https://git.internal.test/infra/platform',
|
||||
'owner' => [
|
||||
'login' => 'infra',
|
||||
],
|
||||
'private' => false,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$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 = UserGitConnection::factory()->for($user)->create([
|
||||
'base_url' => 'https://git.internal.test',
|
||||
'provider' => GitProviderKey::Gitea,
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [
|
||||
'provider_repository_id' => '902',
|
||||
'user_git_connection_id' => $userGitConnection->id,
|
||||
])
|
||||
->assertRedirect(route('projects.settings.repositories', $project))
|
||||
->assertSessionHas('status', 'infra/platform connection was updated.');
|
||||
|
||||
$this->assertDatabaseHas('repository_connections', [
|
||||
'id' => $repositoryConnection->id,
|
||||
'base_url' => 'https://git.internal.test',
|
||||
'provider_repository_id' => '902',
|
||||
'owner_name' => 'infra',
|
||||
'repository_name' => 'platform',
|
||||
'repository_full_name' => 'infra/platform',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure authorized users can remove project repository links.
|
||||
*/
|
||||
public function test_users_with_edit_permission_can_delete_repository_connections(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$repositoryConnection = RepositoryConnection::factory()->for($project)->create([
|
||||
'repository_full_name' => 'core-team/kanbangit',
|
||||
]);
|
||||
|
||||
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
|
||||
->assertRedirect(route('projects.settings.repositories', $project))
|
||||
->assertSessionHas('status', 'core-team/kanbangit was removed from the project.');
|
||||
|
||||
$this->assertDatabaseMissing('repository_connections', [
|
||||
'id' => $repositoryConnection->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user