diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d81ae1a83..65c4cd417 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -115,7 +115,7 @@ Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [live ### 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 | | --- | --- | @@ -131,17 +131,20 @@ Profiles have **15 achievement badges**. Earned badges appear in a single row, w | **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. | +| **contributor** | Get a contribution merged into the /dev/null source code, whether a feature, bug fix, documentation, or another change. | | **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!” | 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. +The **contributor** badge unlocks when GitHub credits at least one contribution to your account on the **production** branch of **dev-null-sh/dev-null.sh**, the repository /dev/null is built from: one per Git commit on that branch, so a squash-merged pull request counts once. Accounts are matched by numeric GitHub ID, not username, so renaming your account keeps your progress. The contributor list is read hourly, so a merge can take up to an hour to show; until the first read, every account shows zero. If GitHub keeps failing, the last list is kept for up to a day, and only the top 1,000 contributors GitHub lists are read. This badge adds no XP, and since no post unlocks it, unlocking it shows no toast or confetti. Tom's top-ten contributor tool is a separate GitHub read. + +Badges are calculated from current account data, not permanently recorded awards; the contributor badge uses the last contributor list read from GitHub. 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. 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), [streak calculation](app/Queries/UserStreakQuery.php), [contributor counts](app/Queries/UpstreamContributionsQuery.php), [contributor sync](app/Jobs/SyncUpstreamContributions.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..cf5adc3cc 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -18,6 +18,7 @@ enum Achievement: string case Pipe = 'pipe'; case AuthorizedKeys = 'authorized_keys'; case SweetTooth = 'sweet_tooth'; + case Contributor = 'contributor'; case Luddite = 'luddite'; case ShellShocked = 'shell_shocked'; case SuperSaiyan = 'super_saiyan'; @@ -37,6 +38,7 @@ public function label(): string self::Pipe => 'pipe', self::AuthorizedKeys => 'authorized_keys', self::SweetTooth => 'sweet tooth', + self::Contributor => 'contributor', self::Luddite => 'luddite', self::ShellShocked => 'shell shocked', self::SuperSaiyan => 'super-saiyan', @@ -58,6 +60,7 @@ public function description(): string self::Pipe => 'Joined conversations with 5 replies to other developers.', self::AuthorizedKeys => 'Provisioned a public SSH key from GitHub.', self::SweetTooth => 'Received 100 pastéis across your account.', + self::Contributor => 'Contributed to the /dev/null source code.', 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!', @@ -79,6 +82,7 @@ public function icon(): string self::Pipe => 'git-merge', self::AuthorizedKeys => 'key', self::SweetTooth => 'cookie', + self::Contributor => 'git-pull-request', self::Luddite => 'bot-off', self::ShellShocked => 'bomb', self::SuperSaiyan => 'dragon-ball', @@ -100,6 +104,7 @@ public function target(): int self::Pipe => 5, self::AuthorizedKeys => 1, self::SweetTooth => 100, + self::Contributor => 1, self::Luddite => 1, self::ShellShocked => 1, self::SuperSaiyan => 9000, diff --git a/app/Jobs/SyncUpstreamContributions.php b/app/Jobs/SyncUpstreamContributions.php new file mode 100644 index 000000000..be3f8dfac --- /dev/null +++ b/app/Jobs/SyncUpstreamContributions.php @@ -0,0 +1,69 @@ + $contributions */ + $contributions = []; + $repository = config()->string('services.github.repository'); + + for ($page = 1; $page <= self::MaxPages; $page++) { + $body = $github->request() + ->get("repos/{$repository}/contributors", ['per_page' => self::PerPage, 'page' => $page]) + ->throw() + ->json(); + + throw_if(! is_array($body) || ! array_is_list($body), RuntimeException::class, 'GitHub returned an unexpected contributor list.'); + + throw_if($page === 1 && $body === [], RuntimeException::class, 'GitHub returned an empty contributor list.'); + + /** @var list $contributors */ + $contributors = $body; + + foreach ($contributors as $contributor) { + if (($contributor['type'] ?? null) !== 'User' || ! isset($contributor['id'], $contributor['contributions'])) { + continue; + } + + $contributions[$contributor['id']] = $contributor['contributions']; + } + + if (count($contributors) < self::PerPage) { + break; + } + } + + Cache::put(UpstreamContributionsQuery::CacheKey, $contributions, now()->addDay()); + } +} diff --git a/app/Queries/UpstreamContributionsQuery.php b/app/Queries/UpstreamContributionsQuery.php new file mode 100644 index 000000000..014724a59 --- /dev/null +++ b/app/Queries/UpstreamContributionsQuery.php @@ -0,0 +1,25 @@ + $contributions */ + $contributions = Cache::memo()->get(self::CacheKey, []); + + return $contributions[$user->github_id] ?? 0; + } +} diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 0c27f7ef7..d976d99e2 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -35,7 +35,7 @@ */ final readonly class UserAchievementsQuery { - public function __construct(private UserStreakQuery $streaks) + public function __construct(private UserStreakQuery $streaks, private UpstreamContributionsQuery $contributions) { // } @@ -87,6 +87,7 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array $longestStreak = ($streaks ?? $this->streaks->get($user, $viewer))['longest']; $hasSshKey = $user->sshKeys()->exists(); + $upstreamContributions = $this->contributions->countFor($user); $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'); @@ -176,6 +177,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => min($user->pasteis_received, Achievement::SweetTooth->target()), 'target' => Achievement::SweetTooth->target(), ], + Achievement::Contributor->value => [ + 'progress' => min($upstreamContributions, Achievement::Contributor->target()), + 'target' => Achievement::Contributor->target(), + ], Achievement::Luddite->value => [ 'progress' => $hasUnfollowedTom ? 1 : 0, 'target' => Achievement::Luddite->target(), diff --git a/resources/js/components/achievements-dialog.tsx b/resources/js/components/achievements-dialog.tsx index 431af14b2..20ca48c7f 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, @@ -41,6 +42,7 @@ const iconMap: Record> = { star: Star, sparkles: Sparkles, 'git-merge': GitMerge, + 'git-pull-request': GitPullRequest, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index e7e812987..01680a444 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, @@ -46,6 +47,7 @@ const iconMap: Record> = { star: Star, sparkles: Sparkles, 'git-merge': GitMerge, + 'git-pull-request': GitPullRequest, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/routes/console.php b/routes/console.php index 4b6746e1b..e19bf529a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -4,11 +4,13 @@ use App\Jobs\EmbedKnowledgeBase; use App\Jobs\EndLiveStreams; +use App\Jobs\SyncUpstreamContributions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schedule; Schedule::command('commits:submit-scheduled')->everyMinute()->withoutOverlapping(5); Schedule::job(new EmbedKnowledgeBase())->hourly(); +Schedule::job(new SyncUpstreamContributions())->hourly(); Schedule::job(new EndLiveStreams())->everyFiveMinutes(); Schedule::call(function (): void { diff --git a/tests/Unit/Jobs/SyncUpstreamContributionsTest.php b/tests/Unit/Jobs/SyncUpstreamContributionsTest.php new file mode 100644 index 000000000..51551e707 --- /dev/null +++ b/tests/Unit/Jobs/SyncUpstreamContributionsTest.php @@ -0,0 +1,149 @@ + "developer{$id}", 'id' => $id, 'type' => $type, 'contributions' => $contributions]; +} + +/** + * Serves the contributor list a hundred to a page, like GitHub does. + * + * @param list> $contributors + */ +function fakeUpstreamContributors(array $contributors): void +{ + Http::fake(['api.github.com/repos/'.config()->string('services.github.repository').'/contributors*' => fn (Request $request) => Http::response( + array_slice($contributors, ((int) $request['page'] - 1) * 100, 100), + )]); +} + +it('stores the changes github counts per account, keyed by the numeric id and without the bots', function (): void { + $user = User::factory()->create(['github_id' => '583231']); + $other = User::factory()->create(['github_id' => '42']); + + fakeUpstreamContributors([ + upstreamContributor(583231, 7), + upstreamContributor(42, 1), + upstreamContributor(7, 120), + upstreamContributor(9, 3, type: 'Bot'), + ]); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7, '42' => 1, '7' => 120]) + ->and(resolve(UpstreamContributionsQuery::class)->countFor($user))->toBe(7) + ->and(resolve(UpstreamContributionsQuery::class)->countFor($other))->toBe(1); + + Http::assertSent(fn (Request $request): bool => $request['per_page'] === 100 && $request['page'] === 1); +}); + +it('reads contributions from the configured repository', function (): void { + config(['services.github.repository' => 'example/dev-null']); + fakeUpstreamContributors([upstreamContributor(583231)]); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 1]); + + Http::assertSent(fn (Request $request): bool => str_starts_with($request->url(), 'https://api.github.com/repos/example/dev-null/contributors?')); +}); + +it('reads the contributor list until a page is not full', function (): void { + fakeUpstreamContributors(array_map(fn (int $id): array => upstreamContributor($id), range(1, 150))); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toHaveCount(150); + + Http::assertSentCount(2); +}); + +it('reads at most ten pages per run, bounding the requests to github', function (): void { + fakeUpstreamContributors(array_map(fn (int $id): array => upstreamContributor($id), range(1, 1000))); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toHaveCount(1000); + + Http::assertSentCount(10); +}); + +it('skips anonymous contributors and entries without a type', function (): void { + fakeUpstreamContributors([ + ['email' => 'anonymous@example.com', 'name' => 'Anonymous', 'contributions' => 9, 'type' => 'Anonymous'], + ['contributions' => 4], + upstreamContributor(583231, 7), + ]); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7]); +}); + +it('throws when github fails, leaving the cached counts as they were', function (): void { + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 7]); + Http::fake(['api.github.com/repos/'.config()->string('services.github.repository').'/contributors*' => Http::response([], 500)]); + + expect(fn () => dispatch_sync(new SyncUpstreamContributions()))->toThrow(RequestException::class); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7]); +}); + +it('keeps the cached counts when a later page fails', function (): void { + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 7]); + + Http::fake(['api.github.com/repos/'.config()->string('services.github.repository').'/contributors*' => fn (Request $request) => (int) $request['page'] === 1 + ? Http::response(array_map(fn (int $id): array => upstreamContributor($id), range(1, 100))) + : Http::response([], 500)]); + + expect(fn () => dispatch_sync(new SyncUpstreamContributions()))->toThrow(RequestException::class); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7]); +}); + +it('keeps the cached counts when github does not return a contributor list', function (mixed $body): void { + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 7]); + Http::fake(['api.github.com/repos/'.config()->string('services.github.repository').'/contributors*' => Http::response($body)]); + + expect(fn () => dispatch_sync(new SyncUpstreamContributions()))->toThrow(RuntimeException::class); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7]); +})->with([ + 'an object body' => [['message' => 'Bad credentials']], + 'an empty first page' => [[]], +]); + +it('expires the stored counts a day after reading them', function (): void { + fakeUpstreamContributors([upstreamContributor(583231, 7)]); + $user = User::factory()->create(['github_id' => '583231']); + + dispatch_sync(new SyncUpstreamContributions()); + + expect(Cache::get(UpstreamContributionsQuery::CacheKey))->toBe(['583231' => 7]); + + $this->travel(25)->hours(); + + expect(resolve(UpstreamContributionsQuery::class)->countFor($user))->toBe(0); +}); + +it('is dispatched every hour', function (): void { + $event = collect(resolve(Schedule::class)->events()) + ->sole(fn (Event $event): bool => $event->description === SyncUpstreamContributions::class); + + expect($event->expression)->toBe('0 * * * *'); +}); diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index ebd27bf12..3fc154c55 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -9,7 +9,11 @@ use App\Models\Commit; use App\Models\SshKey; use App\Models\User; +use App\Queries\UpstreamContributionsQuery; use App\Queries\UserAchievementsQuery; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; beforeEach(function (): void { $this->travelTo(now()->setDate(2026, 10, 22)->setTime(12, 0)); @@ -28,7 +32,7 @@ ->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['badges'])->toHaveCount(16); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -320,6 +324,71 @@ expect(collect($result['badges'])->firstWhere('id', 'luddite')['unlocked'])->toBeFalse(); }); +it('locks the contributor badge while no contributor counts are cached', function (): void { + $user = User::factory()->create(); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', Achievement::Contributor->value))->toMatchArray([ + 'target' => 1, + 'progress' => 0, + 'unlocked' => false, + ]); +}); + +it('unlocks one contributor badge for any positive github contribution count without awarding xp', function (int $contributions, int $progress, bool $unlocked): void { + $user = User::factory()->create(['github_id' => '583231']); + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => $contributions]); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', Achievement::Contributor->value))->toMatchArray([ + 'name' => 'contributor', + 'description' => 'Contributed to the /dev/null source code.', + 'icon' => 'git-pull-request', + 'target' => 1, + 'progress' => $progress, + 'unlocked' => $unlocked, + ])->and($result['badges'])->toHaveCount(16) + ->and($result['xp'])->toBe(0); +})->with([ + 'no contributions' => [0, 0, false], + 'one contribution' => [1, 1, true], + 'five contributions' => [5, 1, true], + 'twenty-five contributions' => [25, 1, true], + 'a hundred contributions' => [100, 1, true], + 'a hundred and fifty contributions' => [150, 1, true], +]); + +it('matches contributor counts by github id, not by a username someone else can take', function (): void { + $user = User::factory()->create(['github_id' => '1', 'username' => 'octocat']); + Cache::forever(UpstreamContributionsQuery::CacheKey, ['2' => 100]); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', Achievement::Contributor->value)['progress'])->toBe(0); +}); + +it('matches a numeric github id after the counts come back from a serialized cache store', function (): void { + config(['cache.default' => 'database']); + + $user = User::factory()->create(['github_id' => '583231']); + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 7]); + + expect(DB::table('cache')->value('value'))->toBeString()->toContain('i:583231;') + ->and(resolve(UpstreamContributionsQuery::class)->countFor($user))->toBe(7); +}); + +it('renders the achievements without calling github', function (): void { + Http::fake(); + Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 5]); + $user = User::factory()->create(['github_id' => '583231']); + + expect(resolve(UserAchievementsQuery::class)->get($user, $user)['badges'])->toHaveCount(16); + + Http::assertNothingSent(); +}); + it('adds the shell shocked reward to activity xp and includes the luddite penalty', function (bool $unfollowedTom, int $xp): void { $user = User::factory()->create(['created_at' => '2026-10-21 13:59:59']); Commit::factory()->for($user)->create();