From 685a11aa14ab739f7458c85ea0d89ff442ae680d Mon Sep 17 00:00:00 2001 From: Arjan Kapteijn Date: Fri, 30 Oct 2026 09:33:51 +0100 Subject: [PATCH 1/5] 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 de152c881..b2378f290 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 8a878196c..1a90edf7f 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 8d0407945..4afe256e9 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 0c27f7ef7..fe65c7ec5 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 392a93367..a8891e706 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 e7e812987..805c1784a 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 91a9faa07..e48885257 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 9fd947934..94f7088ff 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 a6c52d2de..fdaf2a5d7 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 4fe14a9a8..f96bc5771 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 5200495fd..b99002670 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/5] 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 b2378f290..f55178eff 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/5] 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 f55178eff..d822cad04 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/5] 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 d822cad04..c9fe18961 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 4afe256e9..455b9239a 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 f96bc5771..a75d17a76 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/5] 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 455b9239a..8ca67079b 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 a75d17a76..368d66aa4 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);