From 540fa9701b48bd30df67689bc34e154823e22a88 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 02:09:24 +0200 Subject: [PATCH 1/5] feat: mark repository contributors from the GitHub contributors list Cache GitHub contributor usernames and expose a contributor flag on serialized users so the UI can show who helped build the project. Co-authored-by: Cursor --- app/Http/Resources/UserResource.php | 1 + app/Models/User.php | 10 ++ app/Queries/RepositoryContributorsQuery.php | 92 +++++++++++++++++++ .../Api/BookmarkControllerTest.php | 1 + .../Controllers/Api/CommitControllerTest.php | 2 + .../Api/CurrentUserControllerTest.php | 1 + .../Controllers/Api/LogControllerTest.php | 1 + .../Api/NotificationControllerTest.php | 1 + .../Controllers/Api/UserControllerTest.php | 2 + .../Feature/Controllers/LogControllerTest.php | 23 +++++ tests/Unit/Models/UserTest.php | 14 +++ .../RepositoryContributorsQueryTest.php | 61 ++++++++++++ 12 files changed, 209 insertions(+) create mode 100644 app/Queries/RepositoryContributorsQuery.php create mode 100644 tests/Unit/Queries/RepositoryContributorsQueryTest.php diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 312984023..9559528ed 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -37,6 +37,7 @@ public function toArray(Request $request): array 'commits_count' => $this->whenCounted('commits'), 'followers_count' => $this->whenCounted('followers'), 'following_count' => $this->whenCounted('following'), + 'contributor' => $this->isRepositoryContributor(), 'lane' => $this->when($request->user() instanceof User, fn (): int|string => $this->lane()), 'is_followed' => $this->whenHas('is_followed'), 'follows_you' => $this->whenHas('follows_you'), diff --git a/app/Models/User.php b/app/Models/User.php index 1fc1c16b7..029bbc899 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use App\Enums\LivePlatform; use App\Enums\Log; use App\Enums\RemotesFrom; +use App\Queries\RepositoryContributorsQuery; use Carbon\CarbonInterface; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Hidden; @@ -477,6 +478,14 @@ public function automaticLane(): int return $hash % 6; } + /** + * Whether this GitHub username has commits on the /dev/null repository. + */ + public function isRepositoryContributor(): bool + { + return resolve(RepositoryContributorsQuery::class)->contains($this->username); + } + /** * @return array */ @@ -486,6 +495,7 @@ public function toArray(): array ...parent::toArray(), 'lane' => $this->lane(), 'has_github_account' => $this->hasGitHubAccount(), + 'contributor' => $this->isRepositoryContributor(), 'live' => $this->liveStream()?->toArray(), ]; } diff --git a/app/Queries/RepositoryContributorsQuery.php b/app/Queries/RepositoryContributorsQuery.php new file mode 100644 index 000000000..36950c0dd --- /dev/null +++ b/app/Queries/RepositoryContributorsQuery.php @@ -0,0 +1,92 @@ +usernames(), true); + } + + /** + * @return list + */ + public function usernames(): array + { + return once(function (): array { + /** @var list|null $cached */ + $cached = Cache::get(self::CacheKey); + + if (is_array($cached)) { + return $cached; + } + + try { + $usernames = $this->fetch(); + Cache::put(self::CacheKey, $usernames, self::CacheSeconds); + + return $usernames; + } catch (HttpClientException|RuntimeException) { + return []; + } + }); + } + + /** + * @return list + */ + private function fetch(): array + { + $usernames = []; + + for ($page = 1; $page <= self::MaxPages; $page++) { + $contributors = $this->github->request()->get('repos/'.ChangelogQuery::Repository.'/contributors', [ + 'per_page' => 100, + 'page' => $page, + ])->throw()->collect(); + + foreach ($contributors as $contributor) { + $contributor = new Fluent($contributor); + + if ($contributor->string('type')->value() !== 'User') { + continue; + } + + $login = mb_strtolower($contributor->string('login')->value()); + + if ($login !== '') { + $usernames[] = $login; + } + } + + if ($contributors->count() < 100) { + break; + } + } + + return array_values(array_unique($usernames)); + } +} diff --git a/tests/Feature/Controllers/Api/BookmarkControllerTest.php b/tests/Feature/Controllers/Api/BookmarkControllerTest.php index 8bec8e66b..e340abfc6 100644 --- a/tests/Feature/Controllers/Api/BookmarkControllerTest.php +++ b/tests/Feature/Controllers/Api/BookmarkControllerTest.php @@ -197,6 +197,7 @@ 'html_url' => route('user.show', $author), 'feed_url' => route('user-feed.show', $author), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, 'lane' => $author->lane(), ], 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 406f88ccb..9fb0dd760 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -53,6 +53,7 @@ 'html_url' => route('user.show', $user), 'feed_url' => route('user-feed.show', $user), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, ], ]]); }); @@ -229,6 +230,7 @@ 'html_url' => route('user.show', $author), 'feed_url' => route('user-feed.show', $author), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, 'lane' => $author->lane(), ]; diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php index b7c41ba49..1d9cce5d6 100644 --- a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -51,6 +51,7 @@ 'commits_count' => 1, 'followers_count' => 3, 'following_count' => 2, + 'contributor' => false, 'lane' => $user->lane(), 'pasteis_mode' => true, 'pasteis_remaining' => 7, diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index 6f471ec29..13cf4a84a 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -69,6 +69,7 @@ 'html_url' => route('user.show', $author), 'feed_url' => route('user-feed.show', $author), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, 'lane' => $author->lane(), ], 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index f1b015867..eca38b168 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -38,6 +38,7 @@ 'html_url' => route('user.show', $user), 'feed_url' => route('user-feed.show', $user), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, 'lane' => $user->lane(), ]; diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index d66b9aeef..4d3233b6e 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -46,6 +46,7 @@ 'commits_count' => 2, 'followers_count' => 3, 'following_count' => 1, + 'contributor' => false, ]]); }); @@ -115,6 +116,7 @@ 'commits_count' => 3, 'followers_count' => 1, 'following_count' => 2, + 'contributor' => false, 'lane' => $user->lane(), ], 'is_following' => true, diff --git a/tests/Feature/Controllers/LogControllerTest.php b/tests/Feature/Controllers/LogControllerTest.php index 704808b9a..6da4c729d 100644 --- a/tests/Feature/Controllers/LogControllerTest.php +++ b/tests/Feature/Controllers/LogControllerTest.php @@ -11,6 +11,7 @@ use App\Models\LinkPreview; use App\Models\User; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; it('renders own commits and commits of followed users newest first', function (): void { $user = User::factory()->create(); @@ -39,6 +40,28 @@ ->where('commits.data.1.body', 'Mine')); }); +it('marks repository contributors on commit authors', function (): void { + Http::fake(['api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([ + ['type' => 'User', 'login' => 'octocat', 'contributions' => 3], + ])]); + + $viewer = User::factory()->create(); + $contributor = User::factory()->create(['username' => 'octocat']); + $other = User::factory()->create(['username' => 'stranger']); + + Commit::factory()->for($contributor)->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($other)->create(); + + $this->actingAs($viewer) + ->get('/log/latest') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('commits.data.0.user.username', 'stranger') + ->where('commits.data.0.user.contributor', false) + ->where('commits.data.1.user.username', 'octocat') + ->where('commits.data.1.user.contributor', true)); +}); + it('renders each author with their effective lane', function (): void { $user = User::factory()->create(); $chosen = User::factory()->create(['preferred_lane' => 5]); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 507ecf807..9b89bbca1 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -7,6 +7,7 @@ use App\Models\SshKey; use App\Models\User; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; test('to array', function (): void { $user = User::factory()->create()->refresh(); @@ -27,6 +28,7 @@ 'timezone', 'lane', 'has_github_account', + 'contributor', 'live', ]); }); @@ -210,6 +212,18 @@ 'custom' => [null, '#1a2b3c', '#1a2b3c'], ]); +it('serializes whether it contributed to the repository', function (): void { + Http::fake(['api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([ + ['type' => 'User', 'login' => 'octocat', 'contributions' => 1], + ])]); + + $contributor = User::factory()->create(['username' => 'octocat']); + $other = User::factory()->create(['username' => 'stranger']); + + expect($contributor->toArray()['contributor'])->toBeTrue() + ->and($other->toArray()['contributor'])->toBeFalse(); +}); + it('knows who it mutes and who mutes it', function (): void { $user = User::factory()->create(); $other = User::factory()->create(); diff --git a/tests/Unit/Queries/RepositoryContributorsQueryTest.php b/tests/Unit/Queries/RepositoryContributorsQueryTest.php new file mode 100644 index 000000000..738f16de2 --- /dev/null +++ b/tests/Unit/Queries/RepositoryContributorsQueryTest.php @@ -0,0 +1,61 @@ + Http::response([ + ['type' => 'Bot', 'login' => 'dependabot[bot]', 'contributions' => 50], + ['type' => 'User', 'login' => 'NunoMaduro', 'contributions' => 10], + ['type' => 'User', 'login' => 'xHeaven', 'contributions' => 20], + ])]); + + $query = resolve(RepositoryContributorsQuery::class); + + expect($query->usernames())->toBe(['nunomaduro', 'xheaven']) + ->and($query->contains('nunomaduro'))->toBeTrue() + ->and($query->contains('NunoMaduro'))->toBeTrue() + ->and($query->contains('octocat'))->toBeFalse(); + + Http::assertSent(fn (Request $request): bool => $request['per_page'] === 100 && $request['page'] === 1); +}); + +it('pages through contributors until a short page', function (): void { + $page = fn (int $number): array => collect(range(1, 100))->map(fn (int $n): array => [ + 'type' => 'User', + 'login' => "dev{$number}-{$n}", + 'contributions' => 1, + ])->all(); + + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => function (Request $request) use ($page) { + return Http::response(match ((int) $request['page']) { + 1 => $page(1), + 2 => array_slice($page(2), 0, 3), + default => [], + }); + }, + ]); + + expect(resolve(RepositoryContributorsQuery::class)->usernames())->toHaveCount(103) + ->and(resolve(RepositoryContributorsQuery::class)->contains('dev2-3'))->toBeTrue(); +}); + +it('soft-fails to nobody when GitHub errors without caching the miss', function (): void { + Http::fake(['api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([], 503)]); + + expect(resolve(RepositoryContributorsQuery::class)->usernames())->toBe([]) + ->and(Cache::has('repository-contributors:usernames'))->toBeFalse(); +}); + +it('reads a warm cache without calling GitHub', function (): void { + Cache::put('repository-contributors:usernames', ['cached'], 900); + + expect(resolve(RepositoryContributorsQuery::class)->contains('cached'))->toBeTrue(); + + Http::assertNothingSent(); +}); From 948ba6356290624d5362a8075f93ef6f490cb4be Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 02:09:28 +0200 Subject: [PATCH 2/5] feat: show the contributor mark beside display names Render the /dev/null logo after the name on commits, profiles, the aside, suggestions, and hover previews when the user contributed. Co-authored-by: Cursor --- resources/js/components/app-aside.tsx | 8 +++-- resources/js/components/commit-card.tsx | 4 +++ resources/js/components/commit-thread.tsx | 2 ++ resources/js/components/contributor-badge.tsx | 30 +++++++++++++++++++ resources/js/components/quoted-commit.tsx | 2 ++ resources/js/components/user-preview.tsx | 6 ++-- resources/js/components/who-to-follow.tsx | 8 +++-- resources/js/pages/user/show.tsx | 11 +++++-- resources/js/types/auth.ts | 4 +++ tests/Unit/CommitImages.test.tsx | 1 + tests/Unit/ContributorBadge.test.tsx | 19 ++++++++++++ tests/Unit/NotificationInbox.test.tsx | 1 + 12 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 resources/js/components/contributor-badge.tsx create mode 100644 tests/Unit/ContributorBadge.test.tsx diff --git a/resources/js/components/app-aside.tsx b/resources/js/components/app-aside.tsx index eccdddaf8..587aa2853 100644 --- a/resources/js/components/app-aside.tsx +++ b/resources/js/components/app-aside.tsx @@ -6,6 +6,7 @@ import TermsController from '@/actions/App/Http/Controllers/TermsController'; import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController'; import { ChangelogPopover } from '@/components/changelog-popover'; +import { ContributorBadge } from '@/components/contributor-badge'; import { GitHubIcon } from '@/components/github-icon'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; @@ -95,8 +96,11 @@ export function AppAside({ > - - {user.name} + + + {user.name} + + {user.contributor && } {commit.user.name} + {commit.user.contributor && ( + + )} {reply.user.name} + {reply.user.contributor && } + + + + Contributor + + + Contributor + + ); +} diff --git a/resources/js/components/quoted-commit.tsx b/resources/js/components/quoted-commit.tsx index 8c7e090da..aff30f9de 100644 --- a/resources/js/components/quoted-commit.tsx +++ b/resources/js/components/quoted-commit.tsx @@ -5,6 +5,7 @@ import { CommitBodyExcerpt } from '@/components/commit-body'; import { CommitImages } from '@/components/commit-images'; import { CommitPoll } from '@/components/commit-poll'; import { CommitStatusBadge } from '@/components/commit-status-badge'; +import { ContributorBadge } from '@/components/contributor-badge'; import { LinkPreview } from '@/components/link-preview'; import { UserAvatar } from '@/components/user-avatar'; import { useNow } from '@/hooks/use-now'; @@ -50,6 +51,7 @@ export function QuotedCommit({ {commit.user.name} + {commit.user.contributor && } @{commit.user.username} diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index 95ec85e5d..afc9c834b 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -11,6 +11,7 @@ import { useFollowHover, useIsFollowing, } from '@/components/follow-button'; +import { ContributorBadge } from '@/components/contributor-badge'; import { LiveAvatarMarker, LiveStreamCard, @@ -444,9 +445,10 @@ function UserPreviewContent({ - {user.name} + {user.name} + {user.contributor && }

- - {user.name} + + + {user.name} + + {user.contributor && }

-

{user.name}

+

+ {user.name} + {user.contributor && } +

{user.commits_count}{' '} {pluralize(user.commits_count, 'commit', 'commits')} @@ -205,8 +209,9 @@ export default function Show({

-

- {user.name} +

+ {user.name} + {user.contributor && }

diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 43a68a065..d19090ae3 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -22,6 +22,10 @@ export type User = { company: string | null; has_sponsors_listing: boolean; has_github_account: boolean; + /** + * Whether this person has commits on the /dev/null GitHub repository. + */ + contributor: boolean; lane: Lane; timezone: string | null; /** diff --git a/tests/Unit/CommitImages.test.tsx b/tests/Unit/CommitImages.test.tsx index f2a91b252..1532d8d40 100644 --- a/tests/Unit/CommitImages.test.tsx +++ b/tests/Unit/CommitImages.test.tsx @@ -37,6 +37,7 @@ const commit: Commit = { company: null, has_sponsors_listing: false, has_github_account: true, + contributor: false, lane: 0, created_at: '2026-10-16T10:00:00Z', updated_at: '2026-10-16T10:00:00Z', diff --git a/tests/Unit/ContributorBadge.test.tsx b/tests/Unit/ContributorBadge.test.tsx new file mode 100644 index 000000000..be45b4e15 --- /dev/null +++ b/tests/Unit/ContributorBadge.test.tsx @@ -0,0 +1,19 @@ +/// + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ContributorBadge } from '../../resources/js/components/contributor-badge'; +import { TooltipProvider } from '../../resources/js/components/ui/tooltip'; + +void test('renders the repository mark with a contributor label', () => { + const html = renderToStaticMarkup( + + + , + ); + + assert.match(html, /data-test="contributor-badge"/); + assert.match(html, /Contributor/); + assert.match(html, / Date: Tue, 20 Oct 2026 02:18:24 +0200 Subject: [PATCH 3/5] fix: keep contributor lookup from breaking cache mocks and CI Soft-fail when the cache facade is unavailable, make the query readonly, and fix Fluent typing so lint, types, and org typing tests pass. Co-authored-by: Cursor --- app/Queries/RepositoryContributorsQuery.php | 30 +++++++++++-------- resources/js/components/commit-thread.tsx | 2 +- resources/js/components/user-preview.tsx | 2 +- .../RepositoryContributorsQueryTest.php | 12 ++++---- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/app/Queries/RepositoryContributorsQuery.php b/app/Queries/RepositoryContributorsQuery.php index 36950c0dd..d2c0a5bd2 100644 --- a/app/Queries/RepositoryContributorsQuery.php +++ b/app/Queries/RepositoryContributorsQuery.php @@ -9,11 +9,12 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Fluent; use RuntimeException; +use Throwable; /** * GitHub accounts that have commits on the repository's default branch. Cached; soft-fails to nobody when GitHub is down. */ -final class RepositoryContributorsQuery +final readonly class RepositoryContributorsQuery { private const string CacheKey = 'repository-contributors:usernames'; @@ -37,16 +38,25 @@ public function contains(string $username): bool public function usernames(): array { return once(function (): array { - /** @var list|null $cached */ - $cached = Cache::get(self::CacheKey); + try { + /** @var list|null $cached */ + $cached = Cache::get(self::CacheKey); - if (is_array($cached)) { - return $cached; + if (is_array($cached)) { + return $cached; + } + } catch (Throwable) { + // } try { $usernames = $this->fetch(); - Cache::put(self::CacheKey, $usernames, self::CacheSeconds); + + try { + Cache::put(self::CacheKey, $usernames, self::CacheSeconds); + } catch (Throwable) { + // + } return $usernames; } catch (HttpClientException|RuntimeException) { @@ -68,13 +78,7 @@ private function fetch(): array 'page' => $page, ])->throw()->collect(); - foreach ($contributors as $contributor) { - $contributor = new Fluent($contributor); - - if ($contributor->string('type')->value() !== 'User') { - continue; - } - + foreach ($contributors->where('type', 'User')->mapInto(Fluent::class) as $contributor) { $login = mb_strtolower($contributor->string('login')->value()); if ($login !== '') { diff --git a/resources/js/components/commit-thread.tsx b/resources/js/components/commit-thread.tsx index be41966df..814d3fedb 100644 --- a/resources/js/components/commit-thread.tsx +++ b/resources/js/components/commit-thread.tsx @@ -27,12 +27,12 @@ import { CommitSecondaryActions } from '@/components/commit-secondary-actions'; import { CommitStarButton } from '@/components/commit-star-button'; import { CommitStatusBadge } from '@/components/commit-status-badge'; import { useCommitTranslation } from '@/components/commit-translation'; -import { ContributorBadge } from '@/components/contributor-badge'; import { ComposerPreviewBody, PreviewToggle, useComposerPreview, } from '@/components/composer-preview'; +import { ContributorBadge } from '@/components/contributor-badge'; import { RailDot } from '@/components/graph-rail'; import InputError from '@/components/input-error'; import { LinkCardPicker } from '@/components/link-card-picker'; diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index afc9c834b..5683a1ad1 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -5,13 +5,13 @@ import { createPortal } from 'react-dom'; import UserFollowController from '@/actions/App/Http/Controllers/UserFollowController'; import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController'; +import { ContributorBadge } from '@/components/contributor-badge'; import { FollowLabel, onFollowHttpException, useFollowHover, useIsFollowing, } from '@/components/follow-button'; -import { ContributorBadge } from '@/components/contributor-badge'; import { LiveAvatarMarker, LiveStreamCard, diff --git a/tests/Unit/Queries/RepositoryContributorsQueryTest.php b/tests/Unit/Queries/RepositoryContributorsQueryTest.php index 738f16de2..d224d62f1 100644 --- a/tests/Unit/Queries/RepositoryContributorsQueryTest.php +++ b/tests/Unit/Queries/RepositoryContributorsQueryTest.php @@ -32,13 +32,11 @@ ])->all(); Http::fake([ - 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => function (Request $request) use ($page) { - return Http::response(match ((int) $request['page']) { - 1 => $page(1), - 2 => array_slice($page(2), 0, 3), - default => [], - }); - }, + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => fn (Request $request) => Http::response(match ((int) $request['page']) { + 1 => $page(1), + 2 => array_slice($page(2), 0, 3), + default => [], + }), ]); expect(resolve(RepositoryContributorsQuery::class)->usernames())->toHaveCount(103) From cc80527958cab09f2b2a0ed383ff12aeae246cf1 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 02:47:18 +0200 Subject: [PATCH 4/5] fix: cover contributor cache soft-fails and restore import order Co-authored-by: Cursor --- resources/js/components/user-preview.tsx | 2 +- .../RepositoryContributorsQueryTest.php | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index 07e46c41c..4f37b7371 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -5,8 +5,8 @@ import { createPortal } from 'react-dom'; import UserFollowController from '@/actions/App/Http/Controllers/UserFollowController'; import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController'; -import { ContributorBadge } from '@/components/contributor-badge'; import { BioText } from '@/components/bio-text'; +import { ContributorBadge } from '@/components/contributor-badge'; import { FollowLabel, onFollowHttpException, diff --git a/tests/Unit/Queries/RepositoryContributorsQueryTest.php b/tests/Unit/Queries/RepositoryContributorsQueryTest.php index d224d62f1..1f889e4d4 100644 --- a/tests/Unit/Queries/RepositoryContributorsQueryTest.php +++ b/tests/Unit/Queries/RepositoryContributorsQueryTest.php @@ -57,3 +57,29 @@ Http::assertNothingSent(); }); + +it('fetches from GitHub when the cache cannot be read', function (): void { + Http::fake(['api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([ + ['type' => 'User', 'login' => 'octocat', 'contributions' => 1], + ])]); + + Cache::partialMock() + ->shouldReceive('get') + ->once() + ->andThrow(new RuntimeException('Cache read failed')); + + expect(resolve(RepositoryContributorsQuery::class)->usernames())->toBe(['octocat']); +}); + +it('returns usernames when the cache cannot be written', function (): void { + Http::fake(['api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([ + ['type' => 'User', 'login' => 'octocat', 'contributions' => 1], + ])]); + + Cache::partialMock() + ->shouldReceive('put') + ->once() + ->andThrow(new RuntimeException('Cache write failed')); + + expect(resolve(RepositoryContributorsQuery::class)->usernames())->toBe(['octocat']); +}); From 9a5ced70eedde3fcef57ca302bb2620704e151a2 Mon Sep 17 00:00:00 2001 From: Fernando Andrade Date: Tue, 20 Oct 2026 15:14:41 +0200 Subject: [PATCH 5/5] fix: read the GitHub repository from config for contributor lookups ChangelogQuery: :Repository is gone; match the other repository queries. Co-authored-by: Cursor --- app/Queries/RepositoryContributorsQuery.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Queries/RepositoryContributorsQuery.php b/app/Queries/RepositoryContributorsQuery.php index d2c0a5bd2..dc5e9b9b0 100644 --- a/app/Queries/RepositoryContributorsQuery.php +++ b/app/Queries/RepositoryContributorsQuery.php @@ -72,8 +72,10 @@ private function fetch(): array { $usernames = []; + $repository = config()->string('services.github.repository'); + for ($page = 1; $page <= self::MaxPages; $page++) { - $contributors = $this->github->request()->get('repos/'.ChangelogQuery::Repository.'/contributors', [ + $contributors = $this->github->request()->get('repos/'.$repository.'/contributors', [ 'per_page' => 100, 'page' => $page, ])->throw()->collect();