From 5877391102f651700dd6eff6a5e74426cfeff924 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Mon, 26 Oct 2026 23:58:40 +0100 Subject: [PATCH 1/2] feat: highlight contributors Profiles of people GitHub lists as contributors to the repository show a /dev/null contributor label beside their level, which opens their merged pull requests. It reads the contributor list the About page already caches, as a deferred prop in its own group, so a slow GitHub neither holds up the profile nor the who-to-follow suggestions. Co-Authored-By: Claude Opus 5.5 --- KNOWLEDGE_BASE.md | 6 +++-- app/Http/Controllers/UserController.php | 5 +++- app/Queries/ContributorsQuery.php | 8 +++++++ resources/js/pages/user/show.tsx | 22 +++++++++++++++-- .../Feature/ContextualSuggestedUsersTest.php | 2 +- .../Api/SuggestedUserControllerTest.php | 2 +- .../Controllers/UserControllerTest.php | 24 +++++++++++++++++++ 7 files changed, 62 insertions(+), 7 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2bfc962a5..fb056ff6f 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,13 +98,15 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes ### Profiles and GitHub sync -Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. +Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, a **/dev/null contributor** label beside their level when GitHub lists them as a contributor to the /dev/null repository, which opens their merged pull requests on GitHub, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged. +Contributors are matched by GitHub account ID against the same hourly cached list as the About page, so a first merge can take an hour, plus however long GitHub takes to update its counts, to show; when GitHub cannot be reached, the label is not shown. + Profiles also show [achievement badges and an XP-based Unix rank](#achievement-badges-and-xp). Click a badge or rank to inspect unlock requirements and progress. -Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php). +Sources: [profile page](resources/js/pages/user/show.tsx), [contributors](app/Queries/ContributorsQuery.php), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php). ### Going live diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 348ec66ae..368008afc 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -10,6 +10,7 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\ContributorsQuery; use App\Queries\FollowersYouKnowQuery; use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; @@ -32,7 +33,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, ContributorsQuery $contributors, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -59,6 +60,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false, 'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser), 'isMuting' => fn (): bool => $currentUser?->isMuting($user) ?? false, + // GitHub can be slow to list the contributors when they aren't cached, so they load on their own + 'isContributor' => Inertia::defer(fn (): bool => $contributors->includes($user), 'contributors'), 'followersYouKnow' => fn (): ?array => $currentUser instanceof User && $currentUser->isNot($user) ? $followersYouKnow->preview($user, $currentUser) : null, diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 5bbaba6d4..11c43b7c3 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -60,6 +60,14 @@ public function get(): ?array ], $accounts); } + /** + * Whether the user's GitHub account is among the repository contributors. When GitHub doesn't answer, they aren't. + */ + public function includes(User $user): bool + { + return in_array($user->github_id, array_column($this->accounts() ?? [], 'github_id'), true); + } + /** * The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every * visitor to GitHub. diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 267f3e724..6235b75a9 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -2,6 +2,7 @@ import { Head, Link, router, usePage } from '@inertiajs/react'; import { Building2, CalendarDays, + GitPullRequest, Heart, Link2, MapPin, @@ -66,6 +67,7 @@ export default function Show({ isFollowing, followsYou, isMuting, + isContributor, followersYouKnow, canPush, moderation, @@ -82,6 +84,10 @@ export default function Show({ isFollowing: boolean; followsYou: boolean; isMuting: boolean; + /** + * Deferred, so it is only set once GitHub's contributors are read. + */ + isContributor?: boolean; followersYouKnow: FollowersYouKnowPreview | null; canPush: boolean; moderation: UserModeration | null; @@ -93,7 +99,7 @@ export default function Show({ pinnedCommit: Commit | null; readme: string | false | null; }) { - const { auth } = usePage().props; + const { auth, repository } = usePage().props; const isCurrentUser = auth.user?.id === user.id; const lane = laneColor(user.lane); const [highlightedPeriod, setHighlightedPeriod] = useState( @@ -225,7 +231,7 @@ export default function Show({

{user.name}

-
+

+ {isContributor && ( + + + /dev/null contributor + + )}

{moderation && } diff --git a/tests/Feature/ContextualSuggestedUsersTest.php b/tests/Feature/ContextualSuggestedUsersTest.php index 27b54813e..fb9b8ba2d 100644 --- a/tests/Feature/ContextualSuggestedUsersTest.php +++ b/tests/Feature/ContextualSuggestedUsersTest.php @@ -19,7 +19,7 @@ ->assertInertia(fn ($page) => $page ->component('user/show') ->missing('suggestedUsers') - ->loadDeferredProps(fn ($reload) => $reload + ->loadDeferredProps('default', fn ($reload) => $reload ->has('suggestedUsers', 1) ->where('suggestedUsers.0.id', $related->id))); diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index a513ec1d7..e773c2c57 100644 --- a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -151,7 +151,7 @@ $this->actingAs($this->user)->get($webRoute) ->assertOk() ->assertInertia(function ($inertia) use (&$web) { - return $inertia->loadDeferredProps(function ($reload) use (&$web) { + return $inertia->loadDeferredProps('default', function ($reload) use (&$web) { return $reload->where('suggestedUsers', function ($users) use (&$web): bool { $web = collect($users)->pluck('id')->all(); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 3bca2cec5..01238e49c 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -75,6 +75,30 @@ ->where('user.has_sponsors_listing', true)); }); +it('defers whether the user contributes to /dev/null, matching their GitHub account', function (): void { + $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven')]); + $other = User::factory()->create(); + fakeGitHubContributors([gitHubContributor('xHeaven', 187)]); + + $this->get(route('user.show', $contributor)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->missing('isContributor') + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', true))); + + $this->get(route('user.show', $other)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', false))); +}); + +it('does not show anyone as a contributor when GitHub does not answer', function (): void { + $user = User::factory()->create(); + Http::fake(['api.github.com/*' => Http::response(status: 500)]); + + $this->get(route('user.show', $user)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', false))); +}); + it('does not count the user relations or activity when reloading only commits', function (string $tab): void { $user = User::factory()->has(Commit::factory())->create(['handle' => 'octocat']); Commit::factory()->for($user)->for(Commit::factory(), 'parent')->create(); From 5dc2c10ba6a4dcd0e446b32a9f6034e7cdde2b90 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Tue, 27 Oct 2026 00:09:46 +0100 Subject: [PATCH 2/2] fix: search the merged pull requests by the contributor's current GitHub login The contributor list matches the account by GitHub ID, so it still finds someone who renamed on GitHub before their profile synced, but the link searched by the stale handle. The deferred prop now carries the login GitHub reports, and the link searches by it. Co-Authored-By: Claude Opus 5.5 --- app/Http/Controllers/UserController.php | 2 +- app/Queries/ContributorsQuery.php | 7 ++++--- resources/js/pages/user/show.tsx | 13 +++++++------ tests/Feature/Controllers/UserControllerTest.php | 12 ++++++------ 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 368008afc..57aa667e4 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -61,7 +61,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser), 'isMuting' => fn (): bool => $currentUser?->isMuting($user) ?? false, // GitHub can be slow to list the contributors when they aren't cached, so they load on their own - 'isContributor' => Inertia::defer(fn (): bool => $contributors->includes($user), 'contributors'), + 'contributorLogin' => Inertia::defer(fn (): ?string => $contributors->login($user), 'contributors'), 'followersYouKnow' => fn (): ?array => $currentUser instanceof User && $currentUser->isNot($user) ? $followersYouKnow->preview($user, $currentUser) : null, diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 11c43b7c3..78369f311 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -61,11 +61,12 @@ public function get(): ?array } /** - * Whether the user's GitHub account is among the repository contributors. When GitHub doesn't answer, they aren't. + * The GitHub login of the user's account when it is among the repository contributors, as GitHub reports it now, + * which a rename the user hasn't synced yet leaves ahead of their handle. Null when it isn't, or GitHub didn't answer. */ - public function includes(User $user): bool + public function login(User $user): ?string { - return in_array($user->github_id, array_column($this->accounts() ?? [], 'github_id'), true); + return array_column($this->accounts() ?? [], 'login', 'github_id')[$user->github_id] ?? null; } /** diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 6235b75a9..d9b762ab1 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -67,7 +67,7 @@ export default function Show({ isFollowing, followsYou, isMuting, - isContributor, + contributorLogin, followersYouKnow, canPush, moderation, @@ -85,9 +85,10 @@ export default function Show({ followsYou: boolean; isMuting: boolean; /** - * Deferred, so it is only set once GitHub's contributors are read. + * The GitHub login of a contributor to /dev/null, null for everyone else. Deferred, so it is only set once GitHub's + * contributors are read. */ - isContributor?: boolean; + contributorLogin?: string | null; followersYouKnow: FollowersYouKnowPreview | null; canPush: boolean; moderation: UserModeration | null; @@ -265,13 +266,13 @@ export default function Show({ Lv. {achievements.level} · {achievements.rank} - {isContributor && ( + {contributorLogin && ( /dev/null contributor diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 01238e49c..7dd3a5dad 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -75,19 +75,19 @@ ->where('user.has_sponsors_listing', true)); }); -it('defers whether the user contributes to /dev/null, matching their GitHub account', function (): void { - $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven')]); +it('defers the GitHub login of contributors to /dev/null, matching their GitHub account', function (): void { + $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven'), 'handle' => 'before-rename']); $other = User::factory()->create(); fakeGitHubContributors([gitHubContributor('xHeaven', 187)]); $this->get(route('user.show', $contributor)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page - ->missing('isContributor') - ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', true))); + ->missing('contributorLogin') + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', 'xHeaven'))); $this->get(route('user.show', $other)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page - ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', false))); + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', null))); }); it('does not show anyone as a contributor when GitHub does not answer', function (): void { @@ -96,7 +96,7 @@ $this->get(route('user.show', $user)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page - ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('isContributor', false))); + ->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', null))); }); it('does not count the user relations or activity when reloading only commits', function (string $tab): void {