From 789675f83f87a93b7bca4b4c6f07004e4c2c91bb Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 26 Mar 2026 09:19:56 +0100 Subject: [PATCH] Add ticket pull request visibility --- .../Git/Contracts/GitProviderClient.php | 11 ++ app/Actions/Git/Data/GitPullRequest.php | 17 ++ app/Actions/Git/Gitea/GiteaProviderClient.php | 53 ++++++ .../Git/ResolveTicketPullRequestMatches.php | 161 ++++++++++++++++++ .../Controllers/ProjectTicketController.php | 7 + resources/js/pages/projects/Ticket.vue | 138 +++++++++++++++ .../types/models/ticket-pull-request-model.ts | 8 + .../ticket-repository-pull-request-model.ts | 9 + resources/js/types/page-props.ts | 2 + tests/Feature/Git/GiteaProviderTest.php | 44 +++++ .../Projects/ProjectTicketControllerTest.php | 48 +++++- 11 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 app/Actions/Git/Data/GitPullRequest.php create mode 100644 app/Actions/Git/ResolveTicketPullRequestMatches.php create mode 100644 resources/js/types/models/ticket-pull-request-model.ts create mode 100644 resources/js/types/models/ticket-repository-pull-request-model.ts diff --git a/app/Actions/Git/Contracts/GitProviderClient.php b/app/Actions/Git/Contracts/GitProviderClient.php index 947c510..df6caed 100644 --- a/app/Actions/Git/Contracts/GitProviderClient.php +++ b/app/Actions/Git/Contracts/GitProviderClient.php @@ -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 + */ + public function listPullRequests( + string $baseUrl, + string $token, + string $owner, + string $repository, + ): array; } diff --git a/app/Actions/Git/Data/GitPullRequest.php b/app/Actions/Git/Data/GitPullRequest.php new file mode 100644 index 0000000..9dcb369 --- /dev/null +++ b/app/Actions/Git/Data/GitPullRequest.php @@ -0,0 +1,17 @@ + + */ + public function listPullRequests( + string $baseUrl, + string $token, + string $owner, + string $repository, + ): array { + $page = 1; + $pullRequests = []; + + do { + /** @var array $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() diff --git a/app/Actions/Git/ResolveTicketPullRequestMatches.php b/app/Actions/Git/ResolveTicketPullRequestMatches.php new file mode 100644 index 0000000..e009d8a --- /dev/null +++ b/app/Actions/Git/ResolveTicketPullRequestMatches.php @@ -0,0 +1,161 @@ +, + * 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 $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(); + } +} diff --git a/app/Http/Controllers/ProjectTicketController.php b/app/Http/Controllers/ProjectTicketController.php index 7bd37b4..dccfffd 100644 --- a/app/Http/Controllers/ProjectTicketController.php +++ b/app/Http/Controllers/ProjectTicketController.php @@ -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', diff --git a/resources/js/pages/projects/Ticket.vue b/resources/js/pages/projects/Ticket.vue index af4faf0..735f6e5 100644 --- a/resources/js/pages/projects/Ticket.vue +++ b/resources/js/pages/projects/Ticket.vue @@ -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 => { + + + Pull requests + + Matching pull requests are detected from linked + repositories by checking whether the source + branch starts with this ticket key. + + + + +
+ No matching pull requests were found across the + linked repositories you can access. +
+ +
+
+
+ + {{ + repositoryPullRequest + .repository_connection + .provider_label + }} + + + {{ + repositoryPullRequest + .repository_connection + .repository_full_name + }} + +
+ +

+ {{ + repositoryPullRequest + .repository_connection.base_url + }} +

+ +
+

+ Connect a matching Git account in + your user settings to inspect pull + requests for this repository. +

+ + +
+ +
+ {{ repositoryPullRequest.error }} +
+ +
+
+
+ + #{{ pullRequest.number }} + + + {{ + pullRequest.status + }} + + + {{ pullRequest.title }} + +
+

+ Source branch: + {{ + pullRequest.source_branch + }} +

+
+
+
+
+
+
+ Child tickets diff --git a/resources/js/types/models/ticket-pull-request-model.ts b/resources/js/types/models/ticket-pull-request-model.ts new file mode 100644 index 0000000..f1c03b1 --- /dev/null +++ b/resources/js/types/models/ticket-pull-request-model.ts @@ -0,0 +1,8 @@ +export type TicketPullRequestModel = { + html_url: string; + number: string; + source_branch: string; + status: string; + title: string; + [key: string]: unknown; +}; diff --git a/resources/js/types/models/ticket-repository-pull-request-model.ts b/resources/js/types/models/ticket-repository-pull-request-model.ts new file mode 100644 index 0000000..1e73d02 --- /dev/null +++ b/resources/js/types/models/ticket-repository-pull-request-model.ts @@ -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; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index e43a718..1c5db93 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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[]; diff --git a/tests/Feature/Git/GiteaProviderTest.php b/tests/Feature/Git/GiteaProviderTest.php index 9e3bf55..2222592 100644 --- a/tests/Feature/Git/GiteaProviderTest.php +++ b/tests/Feature/Git/GiteaProviderTest.php @@ -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'); + }); + } } diff --git a/tests/Feature/Projects/ProjectTicketControllerTest.php b/tests/Feature/Projects/ProjectTicketControllerTest.php index 880cb48..d2e54bb 100644 --- a/tests/Feature/Projects/ProjectTicketControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketControllerTest.php @@ -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', []), ); }