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 085bb8f76..0a431d80c 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; @@ -534,6 +535,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 */ @@ -543,6 +552,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..dc5e9b9b0 --- /dev/null +++ b/app/Queries/RepositoryContributorsQuery.php @@ -0,0 +1,98 @@ +usernames(), true); + } + + /** + * @return list + */ + public function usernames(): array + { + return once(function (): array { + try { + /** @var list|null $cached */ + $cached = Cache::get(self::CacheKey); + + if (is_array($cached)) { + return $cached; + } + } catch (Throwable) { + // + } + + try { + $usernames = $this->fetch(); + + try { + Cache::put(self::CacheKey, $usernames, self::CacheSeconds); + } catch (Throwable) { + // + } + + return $usernames; + } catch (HttpClientException|RuntimeException) { + return []; + } + }); + } + + /** + * @return list + */ + private function fetch(): array + { + $usernames = []; + + $repository = config()->string('services.github.repository'); + + for ($page = 1; $page <= self::MaxPages; $page++) { + $contributors = $this->github->request()->get('repos/'.$repository.'/contributors', [ + 'per_page' => 100, + 'page' => $page, + ])->throw()->collect(); + + foreach ($contributors->where('type', 'User')->mapInto(Fluent::class) as $contributor) { + $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/resources/js/components/app-aside.tsx b/resources/js/components/app-aside.tsx index ef8080a4b..deb8c4207 100644 --- a/resources/js/components/app-aside.tsx +++ b/resources/js/components/app-aside.tsx @@ -7,6 +7,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 { BioText } from '@/components/bio-text'; +import { ContributorBadge } from '@/components/contributor-badge'; import { GitHubIcon } from '@/components/github-icon'; import { LiveFollowing } from '@/components/live-following'; import { Button } from '@/components/ui/button'; @@ -97,8 +98,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 c24f9da0b..e29f8a7e1 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -6,6 +6,7 @@ import UserFollowController from '@/actions/App/Http/Controllers/UserFollowContr import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController'; import { BioText } from '@/components/bio-text'; +import { ContributorBadge } from '@/components/contributor-badge'; import { FollowLabel, followVisitOptions, @@ -453,9 +454,10 @@ function UserPreviewContent({ - {user.name} + {user.name} + {user.contributor && }

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

-

{user.name}

+

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

-

- {user.name} +

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

diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 4b12c76df..9f05a72e0 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; show_local_time: boolean; diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 7c3e614ce..e9abd41d2 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -56,6 +56,7 @@ 'html_url' => route('user.show', $user), 'feed_url' => route('user-feed.show', $user), 'created_at' => now()->toIso8601ZuluString(), + 'contributor' => false, ], ]]); }); @@ -231,6 +232,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 d0a47dc28..8e9fc290d 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 b02115f83..dcdebde96 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -70,6 +70,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 5212ea033..ebc4aba35 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 6b37481af..57cd660db 100644 --- a/tests/Feature/Controllers/LogControllerTest.php +++ b/tests/Feature/Controllers/LogControllerTest.php @@ -14,6 +14,7 @@ use App\Services\Features; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; @@ -44,6 +45,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/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, /create()->refresh(); @@ -28,6 +29,7 @@ 'show_local_time', 'lane', 'has_github_account', + 'contributor', 'live', ]); }); @@ -211,6 +213,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/NotificationInbox.test.tsx b/tests/Unit/NotificationInbox.test.tsx index 8592334a8..375747f00 100644 --- a/tests/Unit/NotificationInbox.test.tsx +++ b/tests/Unit/NotificationInbox.test.tsx @@ -22,6 +22,7 @@ const actor: User = { timezone: null, show_local_time: false, has_github_account: true, + contributor: false, lane: 0, created_at: timestamp, updated_at: timestamp, diff --git a/tests/Unit/Queries/RepositoryContributorsQueryTest.php b/tests/Unit/Queries/RepositoryContributorsQueryTest.php new file mode 100644 index 000000000..1f889e4d4 --- /dev/null +++ b/tests/Unit/Queries/RepositoryContributorsQueryTest.php @@ -0,0 +1,85 @@ + 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*' => 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) + ->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(); +}); + +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']); +});