From f0fc1b71a25d35799fa777e7e4c5d64aed3cd8ff Mon Sep 17 00:00:00 2001 From: Christen Dijoux Date: Mon, 19 Oct 2026 19:58:15 -0400 Subject: [PATCH 1/4] feat: add an upstream badge for /dev/null contributors --- KNOWLEDGE_BASE.md | 5 +- app/Enums/Achievement.php | 5 ++ app/Queries/UpstreamContributorsQuery.php | 58 +++++++++++++ app/Queries/UserAchievementsQuery.php | 6 +- .../js/components/achievements-dialog.tsx | 2 + .../js/components/achievements-shelf.tsx | 2 + .../Queries/UpstreamContributorsQueryTest.php | 85 +++++++++++++++++++ .../Queries/UserAchievementsQueryTest.php | 20 ++++- 8 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 app/Queries/UpstreamContributorsQuery.php create mode 100644 tests/Unit/Queries/UpstreamContributorsQueryTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index dd354e0a6..7c0326a3b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -113,7 +113,7 @@ Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [live ### Achievement badges and XP -Profiles display **13 achievement badges**. Locked badges show progress toward their requirements; unlocked badges are highlighted. Open the achievements dialog from a profile's badge or rank to see the full list. +Profiles display **14 achievement badges**. Locked badges show progress toward their requirements; unlocked badges are highlighted. Open the achievements dialog from a profile's badge or rank to see the full list. | Badge | When it unlocks | | --- | --- | @@ -129,13 +129,14 @@ Profiles display **13 achievement badges**. Locked badges show progress toward t | **pipe** | Write **5 replies to other developers' commits**, excluding self-replies. Replies still count when their parent commit is soft-deleted. | | **authorized_keys** | Have at least one public SSH key linked from GitHub. | | **sweet tooth** | Receive **100 pastéis across your account**, not on a single commit. Pastéis mode does not need to be enabled, and the total survives deletion of the awarded commits. | +| **upstream** | Have a commit on /dev/null's own production branch, which is where every merged pull request lands. GitHub's contributor list is checked by account ID, so a renamed username keeps it, and refreshed at most once an hour. | | **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. | 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**. 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. XP 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. -Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and XP calculation](app/Queries/UserAchievementsQuery.php), [streak calculation](app/Queries/UserStreakQuery.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), [streak calculation](app/Queries/UserStreakQuery.php), [upstream contributors](app/Queries/UpstreamContributorsQuery.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 bc5864450..0ae8aa580 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -19,6 +19,7 @@ enum Achievement: string case AuthorizedKeys = 'authorized_keys'; case SweetTooth = 'sweet_tooth'; case Luddite = 'luddite'; + case Upstream = 'upstream'; public function label(): string { @@ -36,6 +37,7 @@ public function label(): string self::AuthorizedKeys => 'authorized_keys', self::SweetTooth => 'sweet tooth', self::Luddite => 'luddite', + self::Upstream => 'upstream', }; } @@ -55,6 +57,7 @@ public function description(): string self::AuthorizedKeys => 'Provisioned a public SSH key from GitHub.', self::SweetTooth => 'Received 100 pastéis across your account.', self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.', + self::Upstream => 'Got a pull request merged into /dev/null itself.', }; } @@ -74,6 +77,7 @@ public function icon(): string self::AuthorizedKeys => 'key', self::SweetTooth => 'cookie', self::Luddite => 'bot-off', + self::Upstream => 'git-pull-request', }; } @@ -93,6 +97,7 @@ public function target(): int self::AuthorizedKeys => 1, self::SweetTooth => 100, self::Luddite => 1, + self::Upstream => 1, }; } } diff --git a/app/Queries/UpstreamContributorsQuery.php b/app/Queries/UpstreamContributorsQuery.php new file mode 100644 index 000000000..8012dbbee --- /dev/null +++ b/app/Queries/UpstreamContributorsQuery.php @@ -0,0 +1,58 @@ +github_id, $this->ids(), true); + } + + /** + * Cached for an hour. When GitHub doesn't answer, the last list it gave is used, and asked for again a minute later. + * + * @return list + */ + private function ids(): array + { + /** @var list|null $ids */ + $ids = Cache::get(self::Key); + + if ($ids !== null) { + return $ids; + } + + try { + $ids = $this->github->accountIds('repos/'.ChangelogQuery::Repository.'/contributors', self::MaxPages); + Cache::put(self::Key.':last', $ids); + Cache::put(self::Key, $ids, now()->addHour()); + } catch (Throwable) { + /** @var list $ids */ + $ids = Cache::get(self::Key.':last', []); + Cache::put(self::Key, $ids, now()->addMinute()); + } + + return $ids; + } +} diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 97847f12c..3ae82a67a 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -14,7 +14,7 @@ final readonly class UserAchievementsQuery { - public function __construct(private UserStreakQuery $streaks) + public function __construct(private UserStreakQuery $streaks, private UpstreamContributorsQuery $upstream) { // } @@ -186,6 +186,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => $hasUnfollowedTom ? 1 : 0, 'target' => Achievement::Luddite->target(), ], + Achievement::Upstream->value => [ + 'progress' => $this->upstream->includes($user) ? 1 : 0, + 'target' => Achievement::Upstream->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 47a4f9ca6..c10e7c25a 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -5,6 +5,7 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, Key, Server, Shield, @@ -40,6 +41,7 @@ const iconMap: Record> = { key: Key, cookie: Cookie, 'bot-off': BotOff, + 'git-pull-request': GitPullRequest, }; export function AchievementsDialog({ diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index 8753918ec..b39db7bd1 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -7,6 +7,7 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, Key, Server, Shield, @@ -39,6 +40,7 @@ const iconMap: Record> = { key: Key, cookie: Cookie, 'bot-off': BotOff, + 'git-pull-request': GitPullRequest, }; export function AchievementsShelf({ diff --git a/tests/Unit/Queries/UpstreamContributorsQueryTest.php b/tests/Unit/Queries/UpstreamContributorsQueryTest.php new file mode 100644 index 000000000..db051ac4e --- /dev/null +++ b/tests/Unit/Queries/UpstreamContributorsQueryTest.php @@ -0,0 +1,85 @@ + $ids + * @return list + */ +function contributorsResponse(array $ids): array +{ + return array_map(fn (int $id): array => ['id' => $id, 'login' => "contributor-{$id}", 'type' => 'User'], $ids); +} + +it('finds contributors by their github account id, not their username', function (): void { + $contributor = User::factory()->create(['github_id' => '123', 'username' => 'renamed-since']); + $other = User::factory()->create(['github_id' => '456']); + + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response(contributorsResponse([123, 789])), + ]); + + $query = resolve(UpstreamContributorsQuery::class); + + expect($query->includes($contributor))->toBeTrue() + ->and($query->includes($other))->toBeFalse(); + + Http::assertSentCount(1); +}); + +it('reads every page of contributors and keeps them for an hour', function (): void { + $lastPage = User::factory()->create(['github_id' => '1000']); + + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::sequence() + ->push(contributorsResponse(range(1, 100))) + ->push(contributorsResponse([1000])), + ]); + + $query = resolve(UpstreamContributorsQuery::class); + + expect($query->includes($lastPage))->toBeTrue(); + + $this->travel(59)->minutes(); + + expect($query->includes($lastPage))->toBeTrue(); + + Http::assertSentCount(2); +}); + +it('keeps the last known contributors while github does not answer', function (): void { + $contributor = User::factory()->create(['github_id' => '123']); + + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::sequence() + ->push(contributorsResponse([123])) + ->push('Unavailable', 503) + ->push(contributorsResponse([])), + ]); + + $query = resolve(UpstreamContributorsQuery::class); + + expect($query->includes($contributor))->toBeTrue(); + + $this->travel(61)->minutes(); + + expect($query->includes($contributor))->toBeTrue(); + + $this->travel(2)->minutes(); + + expect($query->includes($contributor))->toBeFalse(); + + Http::assertSentCount(3); +}); + +it('has nobody when github has never answered', function (): void { + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response('Unavailable', 503), + ]); + + expect(resolve(UpstreamContributorsQuery::class)->includes(User::factory()->create()))->toBeFalse(); +}); diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index a92603672..9e61a800f 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -10,6 +10,7 @@ use App\Models\SshKey; use App\Models\User; use App\Queries\UserAchievementsQuery; +use Illuminate\Support\Facades\Http; beforeEach(function (): void { $this->travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); @@ -27,7 +28,7 @@ ->and($result['level_progress'])->toBe(0) ->and($result['ranks'])->toHaveCount(6) ->and($result['xp_breakdown']['commits']['xp'])->toBe(0) - ->and($result['badges'])->toHaveCount(13); + ->and($result['badges'])->toHaveCount(14); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -65,6 +66,23 @@ ->and($badge['progress'])->toBe(1); }); +it('unlocks upstream badge for developers with a commit on /dev/null itself', function (): void { + $contributor = User::factory()->create(['github_id' => '123']); + $user = User::factory()->create(['github_id' => '456']); + + Http::fake([ + 'api.github.com/repos/nunomaduro/dev-null.sh/contributors*' => Http::response([['id' => 123, 'login' => 'octocat', 'type' => 'User']]), + ]); + + $badge = fn (User $user): array => collect(resolve(UserAchievementsQuery::class)->get($user, $user)['badges']) + ->firstWhere('id', Achievement::Upstream->value); + + expect($badge($contributor)['unlocked'])->toBeTrue() + ->and($badge($contributor)['icon'])->toBe('git-pull-request') + ->and($badge($user)['unlocked'])->toBeFalse() + ->and($badge($user)['progress'])->toBe(0); +}); + it('tracks stars given to other developers and excludes self-starring', function (): void { $user = User::factory()->create(); $other = User::factory()->create(); From be251cc7a4d1f1dde426683a2d7e30cc4a767154 Mon Sep 17 00:00:00 2001 From: Christen Dijoux Date: Mon, 19 Oct 2026 20:08:52 -0400 Subject: [PATCH 2/4] chore: rerun CI From 401cb7c549985e84602f7394793be26ffda12124 Mon Sep 17 00:00:00 2001 From: Christen Dijoux Date: Fri, 23 Oct 2026 15:53:56 -0400 Subject: [PATCH 3/4] fix: refresh the upstream contributors once at a time --- app/Queries/UpstreamContributorsQuery.php | 29 +++++++++++++++++++ .../Queries/UpstreamContributorsQueryTest.php | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/app/Queries/UpstreamContributorsQuery.php b/app/Queries/UpstreamContributorsQuery.php index dd49c0189..87bda78a8 100644 --- a/app/Queries/UpstreamContributorsQuery.php +++ b/app/Queries/UpstreamContributorsQuery.php @@ -31,6 +31,8 @@ public function includes(User $user): bool /** * Cached for an hour. When GitHub doesn't answer, the last list it gave is used, and asked for again a minute later. + * Only one request refreshes the list at a time: the others keep the last list meanwhile instead of all asking + * GitHub at once. * * @return list */ @@ -43,6 +45,33 @@ private function ids(): array return $ids; } + /** @var list|false $refreshed */ + $refreshed = Cache::lock(self::Key.':refresh', 30)->get($this->refresh(...)); + + if ($refreshed !== false) { + return $refreshed; + } + + /** @var list $last */ + $last = Cache::get(self::Key.':last', []); + + return $last; + } + + /** + * Another request may have refreshed the list while this one waited for its turn, so the cache is read again first. + * + * @return list + */ + private function refresh(): array + { + /** @var list|null $ids */ + $ids = Cache::get(self::Key); + + if ($ids !== null) { + return $ids; + } + try { $ids = $this->github->accountIds('repos/'.config()->string('services.github.repository').'/contributors', self::MaxPages); Cache::put(self::Key.':last', $ids); diff --git a/tests/Unit/Queries/UpstreamContributorsQueryTest.php b/tests/Unit/Queries/UpstreamContributorsQueryTest.php index 9eed24ca9..32be98923 100644 --- a/tests/Unit/Queries/UpstreamContributorsQueryTest.php +++ b/tests/Unit/Queries/UpstreamContributorsQueryTest.php @@ -4,6 +4,9 @@ use App\Models\User; use App\Queries\UpstreamContributorsQuery; +use Illuminate\Cache\Events\CacheMissed; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; /** @@ -83,3 +86,28 @@ function contributorsResponse(array $ids): array expect(resolve(UpstreamContributorsQuery::class)->includes(User::factory()->create()))->toBeFalse(); }); + +it('keeps the last known contributors without asking github while another request refreshes them', function (): void { + $contributor = User::factory()->create(['github_id' => '123']); + Cache::put('upstream-contributors:last', ['123']); + Cache::lock('upstream-contributors:refresh', 30)->get(); + Http::fake(); + + expect(resolve(UpstreamContributorsQuery::class)->includes($contributor))->toBeTrue(); + + Http::assertNothingSent(); +}); + +it('uses the list another request refreshed while it waited for its turn', function (): void { + $contributor = User::factory()->create(['github_id' => '123']); + Http::fake(); + + // Another request finishes its refresh just after this one found the list missing. + Event::listen(CacheMissed::class, function (CacheMissed $event): void { + Cache::put($event->key, ['123'], 60); + }); + + expect(resolve(UpstreamContributorsQuery::class)->includes($contributor))->toBeTrue(); + + Http::assertNothingSent(); +}); From 69e98c7c75870fcbc63154ffca3c12352e2b53bf Mon Sep 17 00:00:00 2001 From: Christen Dijoux Date: Sat, 24 Oct 2026 22:26:57 -0400 Subject: [PATCH 4/4] fix: keep the refresh lock for as long as a GitHub lookup can take --- app/Queries/UpstreamContributorsQuery.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Queries/UpstreamContributorsQuery.php b/app/Queries/UpstreamContributorsQuery.php index 87bda78a8..bbfecca74 100644 --- a/app/Queries/UpstreamContributorsQuery.php +++ b/app/Queries/UpstreamContributorsQuery.php @@ -19,6 +19,12 @@ private const int MaxPages = 10; + /** + * Longer than a refresh can take: up to ten pages, each request given five seconds, with room to spare. A lock that + * ran out mid-refresh would let a second one start alongside it. + */ + private const int RefreshLockSeconds = 120; + public function __construct(private GitHub $github) { // @@ -46,7 +52,7 @@ private function ids(): array } /** @var list|false $refreshed */ - $refreshed = Cache::lock(self::Key.':refresh', 30)->get($this->refresh(...)); + $refreshed = Cache::lock(self::Key.':refresh', self::RefreshLockSeconds)->get($this->refresh(...)); if ($refreshed !== false) { return $refreshed;