Add ticket pull request visibility #18

Merged
Twyco merged 1 commits from codex/0.2-gitea-integration/04-ticket-pull-request-visibility into main 2026-04-01 17:17:58 +00:00
11 changed files with 495 additions and 3 deletions
Showing only changes of commit 789675f83f - Show all commits
@@ -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) {
Twyco marked this conversation as resolved
Review

Wie siehts aus mit Loggin Strategie intern? Also ka das wir sachen besser verstehen können falls was schief läuft? Jetzt wo ich die error message so sehe

Wie siehts aus mit Loggin Strategie intern? Also ka das wir sachen besser verstehen können falls was schief läuft? Jetzt wo ich die error message so sehe
$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', []),
);
}