From 685a11aa14ab739f7458c85ea0d89ff442ae680d Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 09:33:51 +0100 Subject: [PATCH 1/6] feat: award a contributor badge to repository contributors Anyone among the contributors of the repository, as GitHub lists them on the About page, earns the contributor badge and 25 XP. They are matched by their GitHub account, and the badge reads the contributor list that is already cached for an hour, so profiles add no GitHub calls of their own. --- KNOWLEDGE_BASE.md | 7 ++--- app/Enums/Achievement.php | 5 ++++ app/Queries/ContributorsQuery.php | 8 ++++++ app/Queries/UserAchievementsQuery.php | 14 +++++++--- .../js/components/achievements-dialog.tsx | 6 +++-- .../js/components/achievements-shelf.tsx | 2 ++ resources/js/types/achievement.ts | 1 + .../Controllers/SessionControllerTest.php | 1 + .../Controllers/UserControllerTest.php | 3 +++ tests/Unit/Queries/ContributorsQueryTest.php | 21 +++++++++++++++ .../Queries/UserAchievementsQueryTest.php | 26 ++++++++++++++++++- 11 files changed, 85 insertions(+), 9 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c8811..b2378f290c 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -132,7 +132,7 @@ Sources: [go-live button](resources/js/components/profile-live-button.tsx), [liv ### Achievement badges and XP -Profiles have **15 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog. +Profiles have **16 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog. | Badge | When it unlocks | | --- | --- | @@ -151,14 +151,15 @@ Profiles have **15 achievement badges**. Earned badges appear in a single row, w | **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. | | **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. | | **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” | +| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. The list is refreshed hourly, so the badge can take up to an hour to appear. | Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone. -Badges are calculated from current account data, not permanently recorded awards. Removing qualifying activity can reduce progress or lock a badge again; for example, unstarring a commit can remove **stargazer**. **Shell shocked** depends only on when the account was created, so deleting activity does not remove it. When a commit posted on the web unlocks a badge, a toast names it and confetti drops; clicking an unlocked badge drops confetti again. Unlocking a badge does not normally give bonus XP; **shell shocked** contributes **100 XP** once. XP otherwise comes from **10 per commit**, **15 per day in the longest streak**, **5 per star received**, **5 per reply to another developer**, and **2 per star given to another developer**. The **luddite** penalty applies once while the badge is unlocked, not once per unfollow, and total XP cannot fall below zero. A qualifying reply contributes both commit XP and reply XP; pastéis do not add XP. +Badges are calculated from current account data, not permanently recorded awards. Removing qualifying activity can reduce progress or lock a badge again; for example, unstarring a commit can remove **stargazer**. **Shell shocked** depends only on when the account was created, and **contributor** only on the repository, so deleting activity removes neither. When a commit posted on the web unlocks a badge, a toast names it and confetti drops; clicking an unlocked badge drops confetti again. Unlocking a badge does not normally give bonus XP; **shell shocked** contributes **100 XP** once and **contributor** **25 XP** once. XP otherwise comes from **10 per commit**, **15 per day in the longest streak**, **5 per star received**, **5 per reply to another developer**, and **2 per star given to another developer**. The **luddite** penalty applies once while the badge is unlocked, not once per unfollow, and total XP cannot fall below zero. A qualifying reply contributes both commit XP and reply XP; pastéis do not add XP. XP also determines your Unix rank: **user** at 0 XP, **daemon** at 50, **operator** at 150, **maintainer** at 400, **wheel** at 1,000, and **root** at 2,500. The achievements dialog's **Ranks** tab shows the levels and progress. Level-up notifications use your public progress, so pending or rejected commits do not trigger them. Each level above the starting rank can notify you only once, even if you lose and regain the XP; jumping ahead does not send a notification for every skipped level. -Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and XP calculation](app/Queries/UserAchievementsQuery.php), [streak calculation](app/Queries/UserStreakQuery.php), [Unix ranks](app/Enums/UnixRank.php), [level-up notifications](app/Actions/CreateLevelNotification.php), [achievements dialog](resources/js/components/achievements-dialog.tsx). +Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and XP calculation](app/Queries/UserAchievementsQuery.php), [repository contributors](app/Queries/ContributorsQuery.php), [streak calculation](app/Queries/UserStreakQuery.php), [Unix ranks](app/Enums/UnixRank.php), [level-up notifications](app/Actions/CreateLevelNotification.php), [achievements dialog](resources/js/components/achievements-dialog.tsx). ### Following developers and suggestions diff --git a/app/Enums/Achievement.php b/app/Enums/Achievement.php index 8a878196c5..1a90edf7fd 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -21,6 +21,7 @@ enum Achievement: string case Luddite = 'luddite'; case ShellShocked = 'shell_shocked'; case SuperSaiyan = 'super_saiyan'; + case Contributor = 'contributor'; public function label(): string { @@ -40,6 +41,7 @@ public function label(): string self::Luddite => 'luddite', self::ShellShocked => 'shell shocked', self::SuperSaiyan => 'super-saiyan', + self::Contributor => 'contributor', }; } @@ -61,6 +63,7 @@ public function description(): string self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.', self::ShellShocked => "Survived the php2shell downtime of Oct '26. Earns 100 XP.", self::SuperSaiyan => 'Y—your follower count. It... it’s over 9000!!!1!', + self::Contributor => 'Shipped code to the /dev/null repository itself. Earns 25 XP.', }; } @@ -82,6 +85,7 @@ public function icon(): string self::Luddite => 'bot-off', self::ShellShocked => 'bomb', self::SuperSaiyan => 'dragon-ball', + self::Contributor => 'git-pull-request', }; } @@ -103,6 +107,7 @@ public function target(): int self::Luddite => 1, self::ShellShocked => 1, self::SuperSaiyan => 9000, + self::Contributor => 1, }; } } diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 8d04079458..4afe256e9d 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -60,6 +60,14 @@ public function get(): ?array ], $accounts); } + /** + * Whether the user is a contributor, found by their GitHub account. False while GitHub can't say. + */ + 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/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 0c27f7ef72..fe65c7ec50 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -27,7 +27,8 @@ * replies: XpContribution, * stars_given: XpContribution, * luddite: XpContribution, - * shell_shocked: XpContribution + * shell_shocked: XpContribution, + * contributor: XpContribution * }, * ranks: list, * badges: list @@ -35,7 +36,7 @@ */ final readonly class UserAchievementsQuery { - public function __construct(private UserStreakQuery $streaks) + public function __construct(private UserStreakQuery $streaks, private ContributorsQuery $contributors) { // } @@ -90,6 +91,7 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array $hasUnfollowedTom = $user->followers()->where('github_id', CreateTomFriendship::GITHUB_ID)->exists() && ! $user->following()->where('github_id', CreateTomFriendship::GITHUB_ID)->exists(); $isShellShocked = $user->created_at->lt('2026-10-21 14:00:00 UTC'); + $isContributor = $this->contributors->includes($user); // XP breakdown: 10 per commit, 15 per streak day, 5 per star received, 5 per reply, 2 per star given $commitsXp = $commitsCount * 10; @@ -99,8 +101,9 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array $starsGivenXp = $starsGivenCount * 2; $ludditeXp = $hasUnfollowedTom ? -100 : 0; $shellShockedXp = $isShellShocked ? 100 : 0; + $contributorXp = $isContributor ? 25 : 0; - $xp = max(0, $commitsXp + $streakXp + $starsReceivedXp + $repliesXp + $starsGivenXp + $ludditeXp + $shellShockedXp); + $xp = max(0, $commitsXp + $streakXp + $starsReceivedXp + $repliesXp + $starsGivenXp + $ludditeXp + $shellShockedXp + $contributorXp); $currentRank = UnixRank::forXp($xp); $nextRank = $currentRank->next(); @@ -117,6 +120,7 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'stars_given' => ['count' => $starsGivenCount, 'multiplier' => 2, 'xp' => $starsGivenXp], 'luddite' => ['count' => $hasUnfollowedTom ? 1 : 0, 'multiplier' => -100, 'xp' => $ludditeXp], 'shell_shocked' => ['count' => $isShellShocked ? 1 : 0, 'multiplier' => 100, 'xp' => $shellShockedXp], + 'contributor' => ['count' => $isContributor ? 1 : 0, 'multiplier' => 25, 'xp' => $contributorXp], ]; $ranks = array_map(fn (UnixRank $rank): array => [ @@ -188,6 +192,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => min($user->followers()->count(), Achievement::SuperSaiyan->target()), 'target' => Achievement::SuperSaiyan->target(), ], + Achievement::Contributor->value => [ + 'progress' => $isContributor ? 1 : 0, + 'target' => Achievement::Contributor->target(), + ], ]; $badges = array_map(function (Achievement $achievement) use ($metrics): array { diff --git a/resources/js/components/achievements-dialog.tsx b/resources/js/components/achievements-dialog.tsx index 392a933675..a8891e7069 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -6,6 +6,7 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, Key, Server, Shield, @@ -46,6 +47,7 @@ const iconMap: Record> = { 'bot-off': BotOff, bomb: Bomb, 'dragon-ball': DragonBallIcon, + 'git-pull-request': GitPullRequest, }; export function AchievementsDialog({ @@ -341,8 +343,8 @@ export function AchievementsDialog({ {/* Dry Unix XP Formula Footnote */}

XP: 10/commit · 15/day streak · 5/star received · 5/reply · - 2/star given · −100/luddite · +100/shell shocked (minimum 0 - XP) + 2/star given · −100/luddite · +100/shell shocked · + +25/contributor (minimum 0 XP)

diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index e7e8129871..805c1784a9 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -8,6 +8,7 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, Key, Server, Shield, @@ -51,6 +52,7 @@ const iconMap: Record> = { 'bot-off': BotOff, bomb: Bomb, 'dragon-ball': DragonBallIcon, + 'git-pull-request': GitPullRequest, }; export function AchievementsShelf({ diff --git a/resources/js/types/achievement.ts b/resources/js/types/achievement.ts index 91a9faa077..e48885257b 100644 --- a/resources/js/types/achievement.ts +++ b/resources/js/types/achievement.ts @@ -30,6 +30,7 @@ export type XpBreakdown = { stars_given: XpMetric; luddite: XpMetric; shell_shocked: XpMetric; + contributor: XpMetric; }; export type Achievements = { diff --git a/tests/Feature/Controllers/SessionControllerTest.php b/tests/Feature/Controllers/SessionControllerTest.php index 9fd9479345..94f7088ff7 100644 --- a/tests/Feature/Controllers/SessionControllerTest.php +++ b/tests/Feature/Controllers/SessionControllerTest.php @@ -228,6 +228,7 @@ it('redirects to the intended url after login', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('hasSponsorsListing')->andReturnFalse(); + $github->shouldReceive('contributors')->andReturn([]); $user = User::factory()->create(); User::factory()->create(['github_id' => '583231']); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index a6c52d2def..fdaf2a5d7d 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -738,6 +738,7 @@ $github = $this->mock(GitHubProvider::class); $github->shouldReceive('login')->with($user->github_id)->andReturn('octocat'); $github->shouldReceive('readme')->with('octocat')->andReturn('

Hi there

'); + $github->shouldReceive('contributors')->andReturn([]); $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page ->where('tab', 'readme') @@ -751,6 +752,7 @@ $github = $this->mock(GitHubProvider::class); $github->shouldReceive('login')->andThrow(new ConnectionException('timed out')); + $github->shouldReceive('contributors')->andReturn([]); $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page ->where('readme', false)); @@ -759,6 +761,7 @@ it('does not ask github for the readme on the other tabs', function (string $tab): void { $github = $this->mock(GitHubProvider::class); $github->shouldNotReceive('login', 'readme'); + $github->shouldReceive('contributors')->andReturn([]); $user = User::factory()->create(['handle' => 'octocat']); diff --git a/tests/Unit/Queries/ContributorsQueryTest.php b/tests/Unit/Queries/ContributorsQueryTest.php index 4fe14a9a8e..f96bc5771f 100644 --- a/tests/Unit/Queries/ContributorsQueryTest.php +++ b/tests/Unit/Queries/ContributorsQueryTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; +use App\Models\User; use App\Queries\ContributorsQuery; use GuzzleHttp\Psr7\Response as Psr7Response; use Illuminate\Http\Client\ConnectionException; @@ -149,3 +150,23 @@ expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'recovered', 'commits' => 12, 'handle' => null]]); $github->shouldHaveReceived('contributors')->times(3); }); + +it('includes a user by their GitHub account, not their handle', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturn([gitHubContributor('xHeaven', 187)]); + + $contributor = User::factory()->create(['handle' => 'jubianchi', 'github_id' => (string) crc32('xHeaven')]); + $namesake = User::factory()->create(['handle' => 'xHeaven']); + + expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue() + ->and(resolve(ContributorsQuery::class)->includes($namesake))->toBeFalse(); + + $github->shouldHaveReceived('contributors')->times(1); +}); + +it('includes nobody while GitHub is unreachable', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andThrow(new ConnectionException('timeout')); + + expect(resolve(ContributorsQuery::class)->includes(User::factory()->create()))->toBeFalse(); +}); diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index 5200495fdd..b990026700 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateTomFriendship; +use App\Contracts\Services\GitHubProvider; use App\Enums\Achievement; use App\Enums\CommitStatus; use App\Enums\UnixRank; @@ -28,7 +29,8 @@ ->and($result['ranks'])->toHaveCount(6) ->and($result['xp_breakdown']['commits']['xp'])->toBe(0) ->and($result['xp_breakdown']['shell_shocked']['xp'])->toBe(0) - ->and($result['badges'])->toHaveCount(15); + ->and($result['xp_breakdown']['contributor']['xp'])->toBe(0) + ->and($result['badges'])->toHaveCount(16); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -360,3 +362,25 @@ 'exactly at cutoff' => ['2026-10-21 14:00:00', false, 0, 1], 'just after cutoff' => ['2026-10-21 14:00:01', false, 0, 1], ]); + +it('awards contributor and 25 xp to repository contributors, found by their GitHub account', function (bool $contributes, bool $unlocked, int $xp): void { + $user = User::factory()->create(['handle' => 'renamed', 'github_id' => (string) crc32('xHeaven')]); + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturn($contributes ? [gitHubContributor('xHeaven', 187)] : [gitHubContributor('jubianchi', 1)]); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', 'contributor'))->toBe([ + 'id' => 'contributor', + 'name' => 'contributor', + 'description' => 'Shipped code to the /dev/null repository itself. Earns 25 XP.', + 'icon' => 'git-pull-request', + 'unlocked' => $unlocked, + 'progress' => $unlocked ? 1 : 0, + 'target' => 1, + ])->and($result['xp_breakdown']['contributor'])->toBe(['count' => $unlocked ? 1 : 0, 'multiplier' => 25, 'xp' => $xp]) + ->and($result['xp'])->toBe($xp); +})->with([ + 'contributor' => [true, true, 25], + 'not a contributor' => [false, false, 0], +]); From 65aa1614ec7a50c30d1873e0989f722a331604ac Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 09:43:20 +0100 Subject: [PATCH 2/6] docs: say the contributor badge can lag GitHub by hours --- KNOWLEDGE_BASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index b2378f290c..f55178eff6 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -151,7 +151,7 @@ Profiles have **16 achievement badges**. Earned badges appear in a single row, w | **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. | | **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. | | **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” | -| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. The list is refreshed hourly, so the badge can take up to an hour to appear. | +| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it is hidden while GitHub can't be reached. | Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone. From d6da9e03720f247c96df7cbaad0d490e6fdaadc6 Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 09:44:00 +0100 Subject: [PATCH 3/6] docs: say the contributor badge locks while GitHub is unreachable --- KNOWLEDGE_BASE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f55178eff6..d822cad043 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -151,7 +151,7 @@ Profiles have **16 achievement badges**. Earned badges appear in a single row, w | **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. | | **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. | | **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” | -| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it is hidden while GitHub can't be reached. | +| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it shows as locked, without its 25 XP, while GitHub can't be reached. | Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone. From f4e97dc9f94199108c843d4cb62d14037dcda084 Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 10:23:44 +0100 Subject: [PATCH 4/6] feat: keep the last contributor list while GitHub is unreachable When GitHub doesn't answer, the contributor list falls back to the last one it gave in the past week for a minute, instead of being empty. So an outage no longer costs contributors their badge and its XP, and the About page keeps showing the list. --- KNOWLEDGE_BASE.md | 4 +-- app/Queries/ContributorsQuery.php | 17 ++++++++-- tests/Unit/Queries/ContributorsQueryTest.php | 35 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d822cad043..c9fe18961d 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -151,7 +151,7 @@ Profiles have **16 achievement badges**. Earned badges appear in a single row, w | **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. | | **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. | | **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” | -| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it shows as locked, without its 25 XP, while GitHub can't be reached. | +| **contributor** | Be among the contributors of [the /dev/null repository](https://github.com/dev-null-sh/dev-null.sh) on GitHub, as listed on the About page: someone with a commit on its production branch, usually through a merged pull request. Bots and AI agents are left out. Earns **25 XP**. GitHub can take a few hours to count a new contribution, so the badge can take a few hours to appear, and it stays during a GitHub outage, unless GitHub has not answered for a week. | Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone. @@ -630,7 +630,7 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts]( ### About, changelog, and policies -The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. +The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page keeps showing the last list GitHub gave in the past week, and only says GitHub cannot be reached when there is none. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. To count active people, /dev/null remembers only the **last day** (not the time) you were signed in and used the site or the API; background refreshes do not count. That day is never shown to other people, is included in your export, and is deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in, a `signin-click` event when you tap to sign in, a `signup` event right after an account is created, and `join-link-sent`, `join-link-visit` and `join-link-signup` when a guest asks for a link by email, opens it, and signs up from it, with nothing that identifies you. diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 4afe256e9d..455b9239af 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -24,6 +24,8 @@ private const int FailedTtl = 60; + private const int LastTtl = 604800; + /** * GitHub reports them as ordinary accounts, so they are named rather than detected. */ @@ -61,7 +63,8 @@ public function get(): ?array } /** - * Whether the user is a contributor, found by their GitHub account. False while GitHub can't say. + * Whether the user is a contributor, found by their GitHub account. False while GitHub can't say and there is no + * recent list to fall back on. */ public function includes(User $user): bool { @@ -69,7 +72,8 @@ public function includes(User $user): bool } /** - * The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every + * The GitHub accounts behind the contributors. When GitHub doesn't answer, the last list it gave in the past week is + * kept for a minute instead, or null when there is none, so an outage neither empties the list nor sends every * visitor to GitHub. * * @return list|null @@ -111,7 +115,14 @@ private function accounts(): ?array $contributors = false; } - Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl); + if ($contributors === false) { + /** @var list|false $contributors */ + $contributors = Cache::get("{$key}:last", false); + Cache::put($key, $contributors, self::FailedTtl); + } else { + Cache::put("{$key}:last", $contributors, self::LastTtl); + Cache::put($key, $contributors, self::Ttl); + } } return $contributors === false ? null : $contributors; diff --git a/tests/Unit/Queries/ContributorsQueryTest.php b/tests/Unit/Queries/ContributorsQueryTest.php index f96bc5771f..a75d17a76e 100644 --- a/tests/Unit/Queries/ContributorsQueryTest.php +++ b/tests/Unit/Queries/ContributorsQueryTest.php @@ -151,6 +151,41 @@ $github->shouldHaveReceived('contributors')->times(3); }); +it('keeps the last list for a minute while GitHub is unreachable, then asks again', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturnUsing( + fn (): array => [gitHubContributor('xHeaven', 187)], + fn () => throw new ConnectionException('timeout'), + fn (): array => [gitHubContributor('xHeaven', 188)], + ); + $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven')]); + + resolve(ContributorsQuery::class)->get(); + $this->travel(61)->minutes(); + + expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'handle' => $contributor->handle]]) + ->and(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue(); + $github->shouldHaveReceived('contributors')->times(2); + + $this->travel(61)->seconds(); + + expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 188, 'handle' => $contributor->handle]]); + $github->shouldHaveReceived('contributors')->times(3); +}); + +it('forgets the last list after a week without an answer from GitHub', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturnUsing( + fn (): array => [gitHubContributor('xHeaven', 187)], + fn () => throw new ConnectionException('timeout'), + ); + + resolve(ContributorsQuery::class)->get(); + $this->travel(8)->days(); + + expect(resolve(ContributorsQuery::class)->get())->toBeNull(); +}); + it('includes a user by their GitHub account, not their handle', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('contributors')->andReturn([gitHubContributor('xHeaven', 187)]); From 41b445b6c8dd443a6c3ea88e826be77a6ac86114 Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 11:02:21 +0100 Subject: [PATCH 5/6] fix: serve the last contributor list without waiting for GitHub Once there is a list from the past week, an expired one is served right away and cached for another minute, and GitHub is asked after the response. So no profile or About page request waits for GitHub, neither for the hourly refresh nor during an outage. GitHub is only asked while the request waits when there is no list at all. --- app/Queries/ContributorsQuery.php | 91 ++++++++++++-------- tests/Unit/Queries/ContributorsQueryTest.php | 21 ++++- 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 455b9239af..8ca67079b5 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -72,9 +72,9 @@ public function includes(User $user): bool } /** - * The GitHub accounts behind the contributors. When GitHub doesn't answer, the last list it gave in the past week is - * kept for a minute instead, or null when there is none, so an outage neither empties the list nor sends every - * visitor to GitHub. + * The GitHub accounts behind the contributors. Once there is a list from the past week, nobody waits for GitHub: an + * expired list is served for another minute while GitHub is asked after the response, so other visitors don't ask + * meanwhile. Without one, GitHub is asked right away, and null means it didn't answer. * * @return list|null */ @@ -87,44 +87,63 @@ private function accounts(): ?array $contributors = Cache::get($key); if ($contributors === null) { - $contributors = []; - - try { - for ($page = 1; $page <= self::MaxPages; $page++) { - $accounts = collect($this->github->contributors($repository, $page, self::PerPage)); - - $contributors = [...$contributors, ...$accounts->where('type', 'User') - ->mapInto(Fluent::class) - ->filter(fn (Fluent $account): bool => $account->string('login')->isNotEmpty() - && is_int($account->get('id')) - && is_int($account->get('contributions')) - && ! in_array($account->string('login')->lower()->value(), self::Agents, true)) - ->map(fn (Fluent $account): array => [ - 'login' => $account->string('login')->value(), - 'github_id' => (string) $account->integer('id'), - 'commits' => $account->integer('contributions'), - ]) - ->values() - ->all()]; - - if ($accounts->count() < self::PerPage) { - break; - } - } - } catch (HttpClientException) { - $contributors = false; - } + /** @var list|null $contributors */ + $contributors = Cache::get("{$key}:last"); - if ($contributors === false) { - /** @var list|false $contributors */ - $contributors = Cache::get("{$key}:last", false); - Cache::put($key, $contributors, self::FailedTtl); + if ($contributors === null) { + $contributors = $this->refresh($repository, $key); } else { - Cache::put("{$key}:last", $contributors, self::LastTtl); - Cache::put($key, $contributors, self::Ttl); + Cache::put($key, $contributors, self::FailedTtl); + defer(fn (): array|false => $this->refresh($repository, $key)); } } return $contributors === false ? null : $contributors; } + + /** + * Asks GitHub for the list and caches it for an hour, keeping it for a week to fall back on. When GitHub doesn't + * answer, that fallback, or false when there is none, is cached for a minute instead. + * + * @return list|false + */ + private function refresh(string $repository, string $key): array|false + { + $contributors = []; + + try { + for ($page = 1; $page <= self::MaxPages; $page++) { + $accounts = collect($this->github->contributors($repository, $page, self::PerPage)); + + $contributors = [...$contributors, ...$accounts->where('type', 'User') + ->mapInto(Fluent::class) + ->filter(fn (Fluent $account): bool => $account->string('login')->isNotEmpty() + && is_int($account->get('id')) + && is_int($account->get('contributions')) + && ! in_array($account->string('login')->lower()->value(), self::Agents, true)) + ->map(fn (Fluent $account): array => [ + 'login' => $account->string('login')->value(), + 'github_id' => (string) $account->integer('id'), + 'commits' => $account->integer('contributions'), + ]) + ->values() + ->all()]; + + if ($accounts->count() < self::PerPage) { + break; + } + } + } catch (HttpClientException) { + /** @var list|false $contributors */ + $contributors = Cache::get("{$key}:last", false); + Cache::put($key, $contributors, self::FailedTtl); + + return $contributors; + } + + Cache::put("{$key}:last", $contributors, self::LastTtl); + Cache::put($key, $contributors, self::Ttl); + + return $contributors; + } } diff --git a/tests/Unit/Queries/ContributorsQueryTest.php b/tests/Unit/Queries/ContributorsQueryTest.php index a75d17a76e..368d66aa48 100644 --- a/tests/Unit/Queries/ContributorsQueryTest.php +++ b/tests/Unit/Queries/ContributorsQueryTest.php @@ -9,6 +9,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; +use Illuminate\Support\Defer\DeferredCallbackCollection; use Illuminate\Support\Facades\Cache; it('lists everyone with a commit, as GitHub counts them', function (): void { @@ -74,10 +75,12 @@ expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'handle' => null]]); }); -it('asks GitHub again an hour later, not before', function (): void { - $accounts = [gitHubContributor('xHeaven', 187)]; +it('asks GitHub again an hour later, after the response, while serving the old list', function (): void { $github = $this->mock(GitHubProvider::class); - $github->shouldReceive('contributors')->andReturnUsing(fn (string $repository, int $page, int $perPage): array => array_slice($accounts, ($page - 1) * $perPage, $perPage)); + $github->shouldReceive('contributors')->andReturnUsing( + fn (): array => [gitHubContributor('xHeaven', 187)], + fn (): array => [gitHubContributor('xHeaven', 188)], + ); resolve(ContributorsQuery::class)->get(); $this->travel(59)->minutes(); @@ -86,8 +89,14 @@ $github->shouldHaveReceived('contributors')->times(1); $this->travel(2)->minutes(); + + expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'handle' => null]]); resolve(ContributorsQuery::class)->get(); + $github->shouldHaveReceived('contributors')->times(1); + + resolve(DeferredCallbackCollection::class)->invoke(); + expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 188, 'handle' => null]]); $github->shouldHaveReceived('contributors')->times(2); }); @@ -151,7 +160,7 @@ $github->shouldHaveReceived('contributors')->times(3); }); -it('keeps the last list for a minute while GitHub is unreachable, then asks again', function (): void { +it('keeps the last list while GitHub is unreachable, and asks again a minute later', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('contributors')->andReturnUsing( fn (): array => [gitHubContributor('xHeaven', 187)], @@ -162,12 +171,16 @@ resolve(ContributorsQuery::class)->get(); $this->travel(61)->minutes(); + resolve(ContributorsQuery::class)->get(); + resolve(DeferredCallbackCollection::class)->invoke(); expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'handle' => $contributor->handle]]) ->and(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue(); $github->shouldHaveReceived('contributors')->times(2); $this->travel(61)->seconds(); + resolve(ContributorsQuery::class)->get(); + resolve(DeferredCallbackCollection::class)->invoke(); expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 188, 'handle' => $contributor->handle]]); $github->shouldHaveReceived('contributors')->times(3); From e83a4b5bfa2d115ef9ca978179c4b502140d0f9a Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Wed, 4 Nov 2026 16:13:33 +0100 Subject: [PATCH 6/6] fix: never make profiles wait for GitHub on a cold contributor cache Profiles now get no contributor list at once when none is cached, and only the first of them in a minute asks GitHub, after the response. The About page still waits for the list, so it doesn't claim GitHub is unreachable right after the cache is cleared. --- app/Queries/ContributorsQuery.php | 19 ++++++++++++++----- tests/Unit/Queries/ContributorsQueryTest.php | 16 ++++++++++++++++ .../Queries/UserAchievementsQueryTest.php | 2 ++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 8ca67079b5..5adfe5b71f 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -63,22 +63,23 @@ public function get(): ?array } /** - * Whether the user is a contributor, found by their GitHub account. False while GitHub can't say and there is no - * recent list to fall back on. + * Whether the user is a contributor, found by their GitHub account. Profiles never wait for GitHub, so this is false + * while there is no list yet or GitHub can't say, and there is no recent list to fall back on. */ public function includes(User $user): bool { - return in_array($user->github_id, array_column($this->accounts() ?? [], 'github_id'), true); + return in_array($user->github_id, array_column($this->accounts(wait: false) ?? [], 'github_id'), true); } /** * The GitHub accounts behind the contributors. Once there is a list from the past week, nobody waits for GitHub: an * expired list is served for another minute while GitHub is asked after the response, so other visitors don't ask - * meanwhile. Without one, GitHub is asked right away, and null means it didn't answer. + * meanwhile. Without one, GitHub is asked right away, and null means it didn't answer. When the caller doesn't wait, + * it gets null at once instead, and only the first such caller in a minute asks GitHub, after the response. * * @return list|null */ - private function accounts(): ?array + private function accounts(bool $wait = true): ?array { $repository = config()->string('services.github.repository'); $key = "about:contributor-accounts:{$repository}"; @@ -90,6 +91,14 @@ private function accounts(): ?array /** @var list|null $contributors */ $contributors = Cache::get("{$key}:last"); + if ($contributors === null && ! $wait) { + if (Cache::add("{$key}:refreshing", true, self::FailedTtl)) { + defer(fn (): array|false => $this->refresh($repository, $key)); + } + + return null; + } + if ($contributors === null) { $contributors = $this->refresh($repository, $key); } else { diff --git a/tests/Unit/Queries/ContributorsQueryTest.php b/tests/Unit/Queries/ContributorsQueryTest.php index 368d66aa48..f0961fbfc0 100644 --- a/tests/Unit/Queries/ContributorsQueryTest.php +++ b/tests/Unit/Queries/ContributorsQueryTest.php @@ -199,12 +199,28 @@ expect(resolve(ContributorsQuery::class)->get())->toBeNull(); }); +it('never makes profiles wait for GitHub on a cold cache, and asks it once after the response', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturn([gitHubContributor('xHeaven', 187)]); + $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven')]); + + expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeFalse() + ->and(resolve(ContributorsQuery::class)->includes($contributor))->toBeFalse(); + $github->shouldNotHaveReceived('contributors'); + + resolve(DeferredCallbackCollection::class)->invoke(); + + expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue(); + $github->shouldHaveReceived('contributors')->once(); +}); + it('includes a user by their GitHub account, not their handle', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('contributors')->andReturn([gitHubContributor('xHeaven', 187)]); $contributor = User::factory()->create(['handle' => 'jubianchi', 'github_id' => (string) crc32('xHeaven')]); $namesake = User::factory()->create(['handle' => 'xHeaven']); + resolve(ContributorsQuery::class)->get(); expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue() ->and(resolve(ContributorsQuery::class)->includes($namesake))->toBeFalse(); diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index 4704be0db5..eefe9a5e43 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -10,6 +10,7 @@ use App\Models\Commit; use App\Models\SshKey; use App\Models\User; +use App\Queries\ContributorsQuery; use App\Queries\UserAchievementsQuery; beforeEach(function (): void { @@ -391,6 +392,7 @@ $user = User::factory()->create(['handle' => 'renamed', 'github_id' => (string) crc32('xHeaven')]); $github = $this->mock(GitHubProvider::class); $github->shouldReceive('contributors')->andReturn($contributes ? [gitHubContributor('xHeaven', 187)] : [gitHubContributor('jubianchi', 1)]); + resolve(ContributorsQuery::class)->get(); $result = resolve(UserAchievementsQuery::class)->get($user, null);