Add ticket branch discovery by ticket key #17

Merged
Twyco merged 3 commits from codex/0.2-gitea-integration/03-ticket-branch-discovery-by-ticket-key into main 2026-04-01 17:15:11 +00:00
12 changed files with 475 additions and 0 deletions
Showing only changes of commit 0b95477f4e - Show all commits
@@ -3,6 +3,7 @@
namespace App\Actions\Git\Contracts;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
@@ -16,4 +17,14 @@ interface GitProviderClient
* @return array<int, GitRepository>
*/
public function listRepositories(string $baseUrl, string $token): array;
/**
* @return array<int, GitBranch>
*/
public function listBranches(
string $baseUrl,
string $token,
string $owner,
string $repository,
): array;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Actions\Git\Data;
/**
* Provider-agnostic representation of a repository branch.
*/
final readonly class GitBranch
{
public function __construct(
public string $name,
) {}
}
@@ -4,6 +4,7 @@ namespace App\Actions\Git\Gitea;
use App\Actions\Git\Contracts\GitProviderClient;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use Illuminate\Http\Client\PendingRequest;
@@ -75,6 +76,43 @@ class GiteaProviderClient implements GitProviderClient
return $repositories;
}
/**
* @return array<int, GitBranch>
*/
public function listBranches(
string $baseUrl,
string $token,
string $owner,
string $repository,
): array {
$page = 1;
$branches = [];
do {
/** @var array<int, array{name: string}> $payload */
$payload = $this->request($baseUrl, $token)
->get(
'/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/branches',
[
'limit' => 100,
'page' => $page,
],
)
->throw()
->json();
foreach ($payload as $branch) {
$branches[] = new GitBranch(
name: $branch['name'],
);
}
$page++;
} while (count($payload) === 100);
return $branches;
}
private function request(string $baseUrl, string $token): PendingRequest
{
return Http::acceptJson()
@@ -0,0 +1,151 @@
<?php
namespace App\Actions\Git;
use App\Actions\Git\Data\GitBranch;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\User;
use App\Models\UserGitConnection;
use Illuminate\Support\Collection;
use Throwable;
class ResolveTicketBranchMatches
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly TicketKeyReferenceMatcher $ticketKeyReferenceMatcher,
) {}
/**
* @return array<int, array{
* branches: array<int, array{name: string}>,
* error: string|null,
* repository_connection: array{
* base_url: string,
* created_at: string|null,
* has_personal_access: bool,
* id: int,
* owner_name: string,
* project_id: int,
* provider: string,
* provider_label: string,
* provider_repository_id: string,
* repository_full_name: string,
* repository_name: string,
* repository_url: string,
* updated_at: string|null
* }
* }>
*/
public function handle(User $user, Project $project, Ticket $ticket): array
{
/** @var Collection<int, RepositoryConnection> $repositoryConnections */
$repositoryConnections = $project->repositoryConnections()
->get();
return $repositoryConnections
->map(function (RepositoryConnection $repositoryConnection) use ($ticket, $user): array {
$userGitConnection = $this->matchingUserGitConnection($user, $repositoryConnection);
$hasPersonalAccess = $userGitConnection !== null;
if ($userGitConnection === null) {
return [
'branches' => [],
'error' => null,
'repository_connection' => $this->repositoryConnectionPayload(
$repositoryConnection,
false,
),
];
}
try {
$branches = collect(
$this->gitProviderManager
->client($userGitConnection->provider)
->listBranches(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
),
)
->filter(fn (GitBranch $branch): bool => $this->ticketKeyReferenceMatcher->matches(
$ticket->ticket_key,
$branch->name,
))
->map(fn (GitBranch $branch): array => [
'name' => $branch->name,
])
->values()
->all();
$error = null;
} catch (Throwable) {
$branches = [];
$error = 'KanbanGit could not load branches from this repository right now.';
}
return [
'branches' => $branches,
'error' => $error,
'repository_connection' => $this->repositoryConnectionPayload(
$repositoryConnection,
$hasPersonalAccess,
),
];
})
->values()
->all();
}
/**
* @return array{
* base_url: string,
* created_at: string|null,
* has_personal_access: bool,
* id: int,
* owner_name: string,
* project_id: int,
* provider: string,
* provider_label: string,
* provider_repository_id: string,
* repository_full_name: string,
* repository_name: string,
* repository_url: string,
* updated_at: string|null
* }
*/
private function repositoryConnectionPayload(
RepositoryConnection $repositoryConnection,
bool $hasPersonalAccess,
): array {
return [
'base_url' => $repositoryConnection->base_url,
'created_at' => $repositoryConnection->created_at?->toISOString(),
'has_personal_access' => $hasPersonalAccess,
'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(),
];
}
private function matchingUserGitConnection(
User $user,
RepositoryConnection $repositoryConnection,
): ?UserGitConnection {
return $user->gitConnections()
->where('provider', $repositoryConnection->provider)
->where('base_url', $repositoryConnection->base_url)
->first();
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Actions\Git;
class TicketKeyReferenceMatcher
{
public function matches(string $ticketKey, string $referenceName): bool
{
return preg_match(
'/^'.preg_quote($ticketKey, '/').'(?=$|[-_\/])/',
$referenceName,
) === 1;
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\Git\ResolveTicketBranchMatches;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Http\Requests\UpdateProjectTicketRequest;
@@ -18,6 +19,7 @@ class ProjectTicketController extends Controller
{
public function __construct(
private readonly ProjectPermissionService $projectPermissionService,
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
@@ -46,6 +48,11 @@ class ProjectTicketController extends Controller
'title' => $parentTicket->title,
]),
'project' => $project,
'repositoryBranches' => $this->resolveTicketBranchMatches->handle(
$request->user(),
$project,
$ticket,
),
'status' => session('status'),
'ticket' => $ticket->load([
'fieldValues.ticketFieldDefinition',
+111
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, useForm, Head, Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
@@ -17,6 +18,7 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index as projectIndex, show } from '@/routes/projects';
@@ -25,6 +27,15 @@ import type { BreadcrumbItem, ProjectTicketPageProps } from '@/types';
const props = defineProps<ProjectTicketPageProps>();
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
@@ -427,6 +438,106 @@ const formatDateTime = (value: string): string => {
</Card>
<div class="space-y-6">
<Card class="h-fit">
<CardHeader>
<CardTitle>Branches</CardTitle>
<CardDescription>
Matching branches are discovered from linked
repositories when the branch name starts with
this ticket key.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="visibleRepositoryBranches.length === 0"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No matching branches were found across the
linked repositories you can access.
</div>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="
repositoryBranch.repository_connection.id
"
class="rounded-xl border border-border px-4 py-4"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="secondary">
{{
repositoryBranch
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch
.repository_connection
.repository_full_name
}}
</span>
</div>
<p
class="mt-2 text-sm text-muted-foreground"
>
{{
repositoryBranch
.repository_connection.base_url
}}
</p>
<div
v-if="
!repositoryBranch
.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p
class="text-sm text-muted-foreground"
>
Connect a matching Git account in
your user settings to inspect
branches for this repository.
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">
Open Git connections
</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Linked tickets</CardTitle>
@@ -0,0 +1,4 @@
export type TicketBranchModel = {
name: string;
[key: string]: unknown;
};
@@ -0,0 +1,9 @@
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketBranchModel } from '@/types/models/ticket-branch-model';
export type TicketRepositoryBranchModel = {
branches: TicketBranchModel[];
error: string | null;
repository_connection: RepositoryConnectionModel;
[key: string]: unknown;
};
+2
View File
@@ -4,6 +4,7 @@ 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 { TicketRepositoryBranchModel } from '@/types/models/ticket-repository-branch-model';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketReferenceModel } from '@/types/models/ticket-reference-model';
@@ -101,6 +102,7 @@ export type ProjectTicketPageProps = {
linkedTickets: TicketLinkModel[];
parentTicketOptions: TicketReferenceModel[];
project: ProjectModel;
repositoryBranches: TicketRepositoryBranchModel[];
status?: string | null;
ticket: TicketModel;
ticketFieldDefinitions: TicketFieldDefinitionModel[];
+37
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature\Git;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\Gitea\GiteaProviderClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -80,4 +81,40 @@ class GiteaProviderTest extends TestCase
&& $request->hasHeader('Authorization', 'token repo-token');
});
}
/**
* Ensure the provider lists branches for a specific repository.
*/
public function test_provider_can_list_repository_branches(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
[
'name' => 'CORE-1-login-form',
],
[
'name' => 'main',
],
]),
]);
$branches = app(GiteaProviderClient::class)->listBranches(
'https://git.example.test',
'branch-token',
'core-team',
'kanbangit',
);
$this->assertCount(2, $branches);
$this->assertContainsOnlyInstancesOf(GitBranch::class, $branches);
$this->assertSame('CORE-1-login-form', $branches[0]->name);
Http::assertSent(function ($request): bool {
return str_starts_with(
$request->url(),
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches',
) && $request->hasHeader('Authorization', 'token branch-token');
});
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketFieldValue;
@@ -13,7 +14,9 @@ use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketWorklogEntry;
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;
@@ -97,10 +100,85 @@ class ProjectTicketControllerTest extends TestCase
->where('ticket.worklog_entries.0.actor.name', $viewer->name)
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
->where('repositoryBranches', [])
->where('canEditTicket', false),
);
}
/**
* Ensure ticket details expose matching branches and inaccessible repository links.
*/
public function test_ticket_details_include_matching_branches_and_inaccessible_repository_links(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
['name' => 'CORE-1-login-form'],
['name' => 'CORE-1/fix-oauth'],
['name' => 'CORE-10-should-not-match'],
['name' => 'main'],
]),
]);
$viewer = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$priority = TicketPriority::factory()->for($project)->create([
'name' => 'Medium',
'position' => 1,
]);
$ticket = Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => 'CORE-1',
'ticket_priority_id' => $priority->id,
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
'title' => 'Branch detail test',
]);
RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.example.test',
'owner_name' => 'core-team',
'provider_repository_id' => '501',
'repository_full_name' => 'core-team/kanbangit',
'repository_name' => 'kanbangit',
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
RepositoryConnection::factory()->for($project)->create([
'base_url' => 'https://git.other.test',
'owner_name' => 'other-team',
'provider_repository_id' => '902',
'repository_full_name' => 'other-team/mobile',
'repository_name' => 'mobile',
'repository_url' => 'https://git.other.test/other-team/mobile',
]);
UserGitConnection::factory()->for($viewer)->create([
'base_url' => 'https://git.example.test',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('projects/Ticket')
->where('repositoryBranches.0.repository_connection.repository_full_name', 'core-team/kanbangit')
->has('repositoryBranches.0.branches', 2)
->where('repositoryBranches.0.branches.0.name', 'CORE-1-login-form')
->where('repositoryBranches.0.branches.1.name', 'CORE-1/fix-oauth')
->where('repositoryBranches.1.repository_connection.repository_full_name', 'other-team/mobile')
->where('repositoryBranches.1.repository_connection.has_personal_access', false)
->where('repositoryBranches.1.branches', []),
);
}
/**
* Ensure updating ticket details requires the project edit permission.
*/