From 50d1187e10ca1f3591abfcb4cd8eb2c9f2daa629 Mon Sep 17 00:00:00 2001 From: Rohit Date: Mon, 2 Nov 2026 19:08:57 +0530 Subject: [PATCH] feat: list an organization's own commits in the API, as its page's Commits tab The app's organization page needs the Commits tab the web shows, but the API only had the organization itself and its mentions. GET api/v1/organizations/{handle}/commits now lists the commits the organization posted, newest first, without its replies. A signed request gets them as the web shows them to its user, through OrganizationCommitsQuery with reply previews, so a member also sees the organization's pending commits. A guest gets the approved ones with their public preview, like the mentions endpoint. A handle that is no organization is not found. --- .../Api/OrganizationCommitController.php | 44 ++++++++++++++ .../ListApiOrganizationCommitsRequest.php | 40 +++++++++++++ routes/api.php | 3 + .../Api/OrganizationCommitControllerTest.php | 57 +++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 app/Http/Controllers/Api/OrganizationCommitController.php create mode 100644 app/Http/Requests/ListApiOrganizationCommitsRequest.php create mode 100644 tests/Feature/Controllers/Api/OrganizationCommitControllerTest.php diff --git a/app/Http/Controllers/Api/OrganizationCommitController.php b/app/Http/Controllers/Api/OrganizationCommitController.php new file mode 100644 index 000000000..fa77be0f5 --- /dev/null +++ b/app/Http/Controllers/Api/OrganizationCommitController.php @@ -0,0 +1,44 @@ +organization(); + assert($organization instanceof Organization); + + if ($currentUser instanceof User) { + $signedCommits = $commits->builder($organization, $currentUser)->cursorPaginate(20); + + $replyPreviews->load($signedCommits->items(), $currentUser); + + return CommitResource::collection($signedCommits); + } + + return CommitResource::collection(Commit::query() + ->where('author_id', $organization->id) + ->approved() + ->topLevel() + ->withPublicPreview() + ->latest() + ->latest('id') + ->cursorPaginate(20)); + } +} diff --git a/app/Http/Requests/ListApiOrganizationCommitsRequest.php b/app/Http/Requests/ListApiOrganizationCommitsRequest.php new file mode 100644 index 000000000..bb4f6aa1e --- /dev/null +++ b/app/Http/Requests/ListApiOrganizationCommitsRequest.php @@ -0,0 +1,40 @@ +organization() instanceof Organization ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } + + /** + * Only organizations that already have a page, so looking one up never asks GitHub. + */ + public function organization(): ?Organization + { + $handle = $this->route('handle'); + assert(is_string($handle)); + + return $this->organization ??= Organization::query() + ->whereRelation('profile', 'handle', $handle) + ->first(); + } +} diff --git a/routes/api.php b/routes/api.php index 824068436..42aa3bfbe 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,6 +42,7 @@ use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\NotificationGroupController; use App\Http\Controllers\Api\OrganizationAdminController; +use App\Http\Controllers\Api\OrganizationCommitController; use App\Http\Controllers\Api\OrganizationController; use App\Http\Controllers\Api\OrganizationFollowController; use App\Http\Controllers\Api\OrganizationMentionController; @@ -116,6 +117,8 @@ ->name('api-related-commit.index'); Route::get('organizations/{handle}', [OrganizationController::class, 'show']) ->name('api-organization.show'); + Route::get('organizations/{handle}/commits', [OrganizationCommitController::class, 'index']) + ->name('api-organization-commit.index'); Route::get('organizations/{handle}/mentions', [OrganizationMentionController::class, 'index']) ->name('api-organization-mention.index'); }); diff --git a/tests/Feature/Controllers/Api/OrganizationCommitControllerTest.php b/tests/Feature/Controllers/Api/OrganizationCommitControllerTest.php new file mode 100644 index 000000000..2802f90d6 --- /dev/null +++ b/tests/Feature/Controllers/Api/OrganizationCommitControllerTest.php @@ -0,0 +1,57 @@ +create(['handle' => 'laravel']); + $poster = User::factory()->create(); + + $older = Commit::factory()->for($poster)->create(['author_id' => $laravel->id, 'created_at' => now()->subHour()]); + $newer = Commit::factory()->for($poster)->create(['author_id' => $laravel->id]); + Commit::factory()->for($poster)->for($newer, 'parent')->create(['author_id' => $laravel->id]); + Commit::factory()->for($poster)->pending()->create(['author_id' => $laravel->id]); + Commit::factory()->for($poster)->create(['author_id' => $laravel->id])->delete(); + Commit::factory()->for($poster)->create(); + Commit::factory()->for($poster)->create(['author_id' => Organization::factory()->create()->id]); + + $this->getJson('/api/v1/organizations/laravel/commits') + ->assertOk() + ->assertJsonPath('data.*.id', [$newer->id, $older->id]) + ->assertJsonPath('data.0.author.type', 'organization') + ->assertJsonPath('data.0.author.handle', 'laravel') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists for a signed request the commits of an organization as the web shows them to its user, its pending ones to its members only', function (): void { + $admin = User::factory()->create(); + $teammate = User::factory()->create(); + $stranger = User::factory()->create(); + $laravel = Organization::factory()->create(['handle' => 'laravel']); + $laravel->admins()->attach([$admin->id, $teammate->id]); + + $pending = Commit::factory()->for($admin)->pending()->create(['author_id' => $laravel->id]); + $approved = Commit::factory()->for($admin)->create(['author_id' => $laravel->id, 'created_at' => now()->subHour()]); + + signedApiRequest(apiKeyPairFor($admin), 'GET', route('api-organization-commit.index', 'laravel')) + ->assertOk() + ->assertJsonPath('data.*.id', [$pending->id, $approved->id]) + ->assertJsonPath('data.0.status', 'pending'); + + signedApiRequest(apiKeyPairFor($teammate), 'GET', route('api-organization-commit.index', 'laravel')) + ->assertOk() + ->assertJsonPath('data.*.id', [$pending->id, $approved->id]); + + signedApiRequest(apiKeyPairFor($stranger), 'GET', route('api-organization-commit.index', 'laravel')) + ->assertOk() + ->assertJsonPath('data.*.id', [$approved->id]); +}); + +it('does not find the commits of a handle that is no organization', function (): void { + User::factory()->create(['handle' => 'octocat']); + + $this->getJson('/api/v1/organizations/octocat/commits')->assertNotFound(); +});