Merge pull request 'Add ticket pull request visibility' (#18) from codex/0.2-gitea-integration/04-ticket-pull-request-visibility into main
linter / quality (push) Successful in 1m10s
tests / ci (8.4) (push) Successful in 1m17s

Reviewed-on: OpenSource/KanbanGit#18
This commit was merged in pull request #18.
This commit is contained in:
2026-04-01 17:17:58 +00:00
11 changed files with 495 additions and 3 deletions
@@ -4,6 +4,7 @@ namespace App\Actions\Git\Contracts;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\Data\GitPullRequest;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
@@ -27,4 +28,14 @@ interface GitProviderClient
string $owner,
string $repository,
): array;
/**
* @return array<int, GitPullRequest>
*/
public function listPullRequests(
string $baseUrl,
string $token,
string $owner,
string $repository,
): array;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Actions\Git\Data;
/**
* Provider-agnostic representation of a pull request.
*/
final readonly class GitPullRequest
{
public function __construct(
public string $number,
public string $title,
public string $status,
public string $sourceBranch,
public string $htmlUrl,
) {}
}
@@ -5,6 +5,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\GitPullRequest;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use Illuminate\Http\Client\PendingRequest;
@@ -113,6 +114,58 @@ class GiteaProviderClient implements GitProviderClient
return $branches;
}
/**
* @return array<int, GitPullRequest>
*/
public function listPullRequests(
string $baseUrl,
string $token,
string $owner,
string $repository,
): array {
$page = 1;
$pullRequests = [];
do {
/** @var array<int, array{
* html_url: string,
* merged?: bool,
* number: int|string,
* state: string,
* title: string,
* head: array{ref: string}
* }> $payload
*/
$payload = $this->request($baseUrl, $token)
->get(
'/repos/'.rawurlencode($owner).'/'.rawurlencode($repository).'/pulls',
[
'limit' => 100,
'page' => $page,
'state' => 'all',
],
)
->throw()
->json();
foreach ($payload as $pullRequest) {
$status = ($pullRequest['merged'] ?? false) ? 'merged' : $pullRequest['state'];
$pullRequests[] = new GitPullRequest(
number: (string) $pullRequest['number'],
title: $pullRequest['title'],
status: $status,
sourceBranch: $pullRequest['head']['ref'],
htmlUrl: $pullRequest['html_url'],
);
}
$page++;
} while (count($payload) === 100);
return $pullRequests;
}
private function request(string $baseUrl, string $token): PendingRequest
{
return Http::acceptJson()
@@ -0,0 +1,161 @@
<?php
namespace App\Actions\Git;
use App\Actions\Git\Data\GitPullRequest;
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 ResolveTicketPullRequestMatches
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly TicketKeyReferenceMatcher $ticketKeyReferenceMatcher,
) {}
/**
* @return array<int, array{
* error: string|null,
* pull_requests: array<int, array{
* html_url: string,
* number: string,
* source_branch: string,
* status: string,
* title: string
* }>,
* 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 [
'error' => null,
'pull_requests' => [],
'repository_connection' => $this->repositoryConnectionPayload(
$repositoryConnection,
false,
),
];
}
try {
$pullRequests = collect(
$this->gitProviderManager
->client($userGitConnection->provider)
->listPullRequests(
$userGitConnection->base_url,
$userGitConnection->api_token,
$repositoryConnection->owner_name,
$repositoryConnection->repository_name,
),
)
->filter(fn (GitPullRequest $pullRequest): bool => $this->ticketKeyReferenceMatcher->matches(
$ticket->ticket_key,
$pullRequest->sourceBranch,
))
->map(fn (GitPullRequest $pullRequest): array => [
'html_url' => $pullRequest->htmlUrl,
'number' => $pullRequest->number,
'source_branch' => $pullRequest->sourceBranch,
'status' => $pullRequest->status,
'title' => $pullRequest->title,
])
->values()
->all();
$error = null;
} catch (Throwable) {
$error = 'KanbanGit could not load pull requests from this repository right now.';
$pullRequests = [];
}
return [
'error' => $error,
'pull_requests' => $pullRequests,
'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();
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Actions\Git\ResolveTicketBranchMatches;
use App\Actions\Git\ResolveTicketPullRequestMatches;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Http\Requests\UpdateProjectTicketRequest;
@@ -20,6 +21,7 @@ class ProjectTicketController extends Controller
public function __construct(
private readonly ProjectPermissionService $projectPermissionService,
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
private readonly UpdateProjectTicket $updateProjectTicket,
) {}
@@ -53,6 +55,11 @@ class ProjectTicketController extends Controller
$project,
$ticket,
),
'repositoryPullRequests' => $this->resolveTicketPullRequestMatches->handle(
$request->user(),
$project,
$ticket,
),
'status' => session('status'),
'ticket' => $ticket->load([
'fieldValues.ticketFieldDefinition',
+138
View File
@@ -36,6 +36,15 @@ const visibleRepositoryBranches = computed(() =>
),
);
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
@@ -687,6 +696,135 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Pull requests</CardTitle>
<CardDescription>
Matching pull requests are detected from linked
repositories by checking whether the source
branch starts with this ticket key.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="
visibleRepositoryPullRequests.length === 0
"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No matching pull requests were found across the
linked repositories you can access.
</div>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="
repositoryPullRequest
.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">
{{
repositoryPullRequest
.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest
.repository_connection
.repository_full_name
}}
</span>
</div>
<p
class="mt-2 text-sm text-muted-foreground"
>
{{
repositoryPullRequest
.repository_connection.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest
.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 pull
requests for this repository.
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">
Open Git connections
</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.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"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="rounded-xl border border-border/70 px-4 py-3"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{
pullRequest.status
}}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="font-medium text-foreground transition hover:text-primary"
>
{{ pullRequest.title }}
</a>
</div>
<p
class="mt-2 text-sm text-muted-foreground"
>
Source branch:
{{
pullRequest.source_branch
}}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
@@ -0,0 +1,8 @@
export type TicketPullRequestModel = {
html_url: string;
number: string;
source_branch: string;
status: string;
title: string;
[key: string]: unknown;
};
@@ -0,0 +1,9 @@
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketPullRequestModel } from '@/types/models/ticket-pull-request-model';
export type TicketRepositoryPullRequestModel = {
error: string | null;
pull_requests: TicketPullRequestModel[];
repository_connection: RepositoryConnectionModel;
[key: string]: unknown;
};
+2
View File
@@ -8,6 +8,7 @@ import type { TicketRepositoryBranchModel } from '@/types/models/ticket-reposito
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';
import type { TicketRepositoryPullRequestModel } from '@/types/models/ticket-repository-pull-request-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
@@ -103,6 +104,7 @@ export type ProjectTicketPageProps = {
parentTicketOptions: TicketReferenceModel[];
project: ProjectModel;
repositoryBranches: TicketRepositoryBranchModel[];
repositoryPullRequests: TicketRepositoryPullRequestModel[];
status?: string | null;
ticket: TicketModel;
ticketFieldDefinitions: TicketFieldDefinitionModel[];
+44
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Git;
use App\Actions\Git\Data\GitAuthenticatedUser;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\Data\GitPullRequest;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\Gitea\GiteaProviderClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -117,4 +118,47 @@ class GiteaProviderTest extends TestCase
) && $request->hasHeader('Authorization', 'token branch-token');
});
}
/**
* Ensure the provider lists pull requests for a specific repository.
*/
public function test_provider_can_list_repository_pull_requests(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/pulls*' => Http::response([
[
'number' => 42,
'title' => 'Add login form',
'state' => 'open',
'merged' => false,
'html_url' => 'https://git.example.test/core-team/kanbangit/pulls/42',
'head' => [
'ref' => 'CORE-1-login-form',
],
],
]),
]);
$pullRequests = app(GiteaProviderClient::class)->listPullRequests(
'https://git.example.test',
'pull-token',
'core-team',
'kanbangit',
);
$this->assertCount(1, $pullRequests);
$this->assertContainsOnlyInstancesOf(GitPullRequest::class, $pullRequests);
$this->assertSame('42', $pullRequests[0]->number);
$this->assertSame('Add login form', $pullRequests[0]->title);
$this->assertSame('open', $pullRequests[0]->status);
$this->assertSame('CORE-1-login-form', $pullRequests[0]->sourceBranch);
Http::assertSent(function ($request): bool {
return str_starts_with(
$request->url(),
'https://git.example.test/api/v1/repos/core-team/kanbangit/pulls',
) && $request->hasHeader('Authorization', 'token pull-token');
});
}
}
@@ -101,14 +101,15 @@ class ProjectTicketControllerTest extends TestCase
->where('linkedTickets.0.linked_ticket.ticket_key', 'CORE-2')
->where('linkedTickets.0.ticket_link_type.label', 'Relates to')
->where('repositoryBranches', [])
->where('repositoryPullRequests', [])
->where('canEditTicket', false),
);
}
/**
* Ensure ticket details expose matching branches and inaccessible repository links.
* Ensure ticket details expose matching branches, pull requests, and inaccessible repository links.
*/
public function test_ticket_details_include_matching_branches_and_inaccessible_repository_links(): void
public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void
{
Http::preventStrayRequests();
Http::fake([
@@ -118,6 +119,38 @@ class ProjectTicketControllerTest extends TestCase
['name' => 'CORE-10-should-not-match'],
['name' => 'main'],
]),
'https://git.example.test/api/v1/repos/core-team/kanbangit/pulls*' => Http::response([
[
'number' => 42,
'title' => 'Add login form',
'state' => 'open',
'merged' => false,
'html_url' => 'https://git.example.test/core-team/kanbangit/pulls/42',
'head' => [
'ref' => 'CORE-1-login-form',
],
],
[
'number' => 43,
'title' => 'Close auth gap',
'state' => 'closed',
'merged' => true,
'html_url' => 'https://git.example.test/core-team/kanbangit/pulls/43',
'head' => [
'ref' => 'CORE-1/fix-oauth',
],
],
[
'number' => 99,
'title' => 'Other ticket change',
'state' => 'open',
'merged' => false,
'html_url' => 'https://git.example.test/core-team/kanbangit/pulls/99',
'head' => [
'ref' => 'CORE-10-should-not-match',
],
],
]),
]);
$viewer = User::factory()->create();
@@ -175,7 +208,16 @@ class ProjectTicketControllerTest extends TestCase
->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', []),
->where('repositoryBranches.1.branches', [])
->has('repositoryPullRequests.0.pull_requests', 2)
->where('repositoryPullRequests.0.pull_requests.0.number', '42')
->where('repositoryPullRequests.0.pull_requests.0.title', 'Add login form')
->where('repositoryPullRequests.0.pull_requests.0.status', 'open')
->where('repositoryPullRequests.0.pull_requests.1.number', '43')
->where('repositoryPullRequests.0.pull_requests.1.status', 'merged')
->where('repositoryPullRequests.1.repository_connection.repository_full_name', 'other-team/mobile')
->where('repositoryPullRequests.1.repository_connection.has_personal_access', false)
->where('repositoryPullRequests.1.pull_requests', []),
);
}