diff --git a/app/Http/Controllers/ProjectRepositorySettingsController.php b/app/Http/Controllers/ProjectRepositorySettingsController.php new file mode 100644 index 0000000..49c009c --- /dev/null +++ b/app/Http/Controllers/ProjectRepositorySettingsController.php @@ -0,0 +1,296 @@ +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, + * user_git_connection_id: int + * }> + */ + private function availableRepositorySources(User $user): array + { + /** @var Collection $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); + } +} diff --git a/app/Http/Requests/StoreRepositoryConnectionRequest.php b/app/Http/Requests/StoreRepositoryConnectionRequest.php new file mode 100644 index 0000000..f1e7357 --- /dev/null +++ b/app/Http/Requests/StoreRepositoryConnectionRequest.php @@ -0,0 +1,40 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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'); + } +} diff --git a/app/Http/Requests/UpdateRepositoryConnectionRequest.php b/app/Http/Requests/UpdateRepositoryConnectionRequest.php new file mode 100644 index 0000000..e3d7389 --- /dev/null +++ b/app/Http/Requests/UpdateRepositoryConnectionRequest.php @@ -0,0 +1,40 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|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'); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 61ebcd6..780748b 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -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) diff --git a/app/Models/RepositoryConnection.php b/app/Models/RepositoryConnection.php new file mode 100644 index 0000000..752ea70 --- /dev/null +++ b/app/Models/RepositoryConnection.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'provider' => GitProviderKey::class, + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } +} diff --git a/database/factories/RepositoryConnectionFactory.php b/database/factories/RepositoryConnectionFactory.php new file mode 100644 index 0000000..060c2ce --- /dev/null +++ b/database/factories/RepositoryConnectionFactory.php @@ -0,0 +1,33 @@ + + */ +class RepositoryConnectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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(), + ]; + } +} diff --git a/database/migrations/2026_03_26_075849_create_repository_connections_table.php b/database/migrations/2026_03_26_075849_create_repository_connections_table.php new file mode 100644 index 0000000..0de4858 --- /dev/null +++ b/database/migrations/2026_03_26_075849_create_repository_connections_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3a5b85e..9a356b1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder UserSeeder::class, UserGitConnectionSeeder::class, ProjectSeeder::class, + RepositoryConnectionSeeder::class, BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, TicketSeeder::class, diff --git a/database/seeders/RepositoryConnectionSeeder.php b/database/seeders/RepositoryConnectionSeeder.php new file mode 100644 index 0000000..c18c2d4 --- /dev/null +++ b/database/seeders/RepositoryConnectionSeeder.php @@ -0,0 +1,37 @@ +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', + ], + ); + } +} diff --git a/docs/missing-features.md b/docs/missing-features.md index b7a01b7..ab39a07 100644 --- a/docs/missing-features.md +++ b/docs/missing-features.md @@ -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? \ No newline at end of file diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index 15452a1..5c6a7c1 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -1,6 +1,6 @@ @@ -104,6 +111,12 @@ const projectAreas = [ + + diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 547e668..d625555 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -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(); @@ -55,9 +58,17 @@ const breadcrumbs: BreadcrumbItem[] = [ /> - +
+ + + +
+ + diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue new file mode 100644 index 0000000..3542493 --- /dev/null +++ b/resources/js/pages/projects/settings/Repositories.vue @@ -0,0 +1,661 @@ + + + diff --git a/resources/js/types/models/available-git-repository-source-model.ts b/resources/js/types/models/available-git-repository-source-model.ts new file mode 100644 index 0000000..812cad7 --- /dev/null +++ b/resources/js/types/models/available-git-repository-source-model.ts @@ -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[]; +}; diff --git a/resources/js/types/models/git-repository-option-model.ts b/resources/js/types/models/git-repository-option-model.ts new file mode 100644 index 0000000..e6cc4e7 --- /dev/null +++ b/resources/js/types/models/git-repository-option-model.ts @@ -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; +}; diff --git a/resources/js/types/models/repository-connection-model.ts b/resources/js/types/models/repository-connection-model.ts new file mode 100644 index 0000000..5efe5c1 --- /dev/null +++ b/resources/js/types/models/repository-connection-model.ts @@ -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; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index b4eb225..74fd467 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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; diff --git a/routes/web.php b/routes/web.php index 308be8f..70e3589 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']) diff --git a/tests/Feature/Models/ProjectTest.php b/tests/Feature/Models/ProjectTest.php index 2fd3530..edbf269 100644 --- a/tests/Feature/Models/ProjectTest.php +++ b/tests/Feature/Models/ProjectTest.php @@ -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', + ]); + } } diff --git a/tests/Feature/Models/RepositoryConnectionTest.php b/tests/Feature/Models/RepositoryConnectionTest.php new file mode 100644 index 0000000..002b88f --- /dev/null +++ b/tests/Feature/Models/RepositoryConnectionTest.php @@ -0,0 +1,36 @@ +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)); + } +} diff --git a/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php new file mode 100644 index 0000000..c7b534c --- /dev/null +++ b/tests/Feature/Projects/ProjectRepositorySettingsControllerTest.php @@ -0,0 +1,218 @@ +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, + ]); + } +}