From 3d2d2af4cd91260ada85136269b3775c8c93bb99 Mon Sep 17 00:00:00 2001 From: Arhen Date: Tue, 20 Oct 2026 18:42:49 +0800 Subject: [PATCH 1/2] feat: add tiered badges for contributions to /dev/null itself Adds patch (1), upstream (5), sudoers (25), and mknod (100) badges for developers who got patches merged into nunomaduro/dev-null.sh. An hourly job reads GitHub's contributor list and keeps it in the cache for a day; profiles only read that cache. No XP. --- KNOWLEDGE_BASE.md | 12 +- app/Enums/Achievement.php | 20 +++ app/Jobs/SyncUpstreamContributions.php | 69 +++++++++ app/Queries/UpstreamContributionsQuery.php | 25 ++++ app/Queries/UserAchievementsQuery.php | 19 ++- .../js/components/achievements-dialog.tsx | 8 + .../js/components/achievements-shelf.tsx | 8 + routes/console.php | 2 + .../Jobs/SyncUpstreamContributionsTest.php | 138 ++++++++++++++++++ .../Queries/UserAchievementsQueryTest.php | 73 ++++++++- 10 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 app/Jobs/SyncUpstreamContributions.php create mode 100644 app/Queries/UpstreamContributionsQuery.php create mode 100644 tests/Unit/Jobs/SyncUpstreamContributionsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 0dc9b3b56..2733d12fb 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 **17 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,19 @@ 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. | +| **patch** | Get your first patch merged into the /dev/null source code. | +| **upstream** | Get **5 patches** merged into the /dev/null source code. | +| **sudoers** | Get **25 patches** merged into the /dev/null source code. | +| **mknod** | Get **100 patches** merged into the /dev/null source code. | | **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. +The contributor badges (**patch**, **upstream**, **sudoers**, and **mknod**) count the patches GitHub credits to your account on the **production** branch of **nunomaduro/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. These badges add no XP, and since no post unlocks them, unlocking one shows no toast or confetti. Tom's top-ten contributor tool is a separate GitHub read. -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). +Badges are calculated from current account data, not permanently recorded awards; the contributor badges use 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**. 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), [contributor counts](app/Queries/UpstreamContributionsQuery.php), [contributor sync](app/Jobs/SyncUpstreamContributions.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..df5b8981c 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -18,6 +18,10 @@ enum Achievement: string case Pipe = 'pipe'; case AuthorizedKeys = 'authorized_keys'; case SweetTooth = 'sweet_tooth'; + case Patch = 'patch'; + case Upstream = 'upstream'; + case Sudoers = 'sudoers'; + case Mknod = 'mknod'; case Luddite = 'luddite'; public function label(): string @@ -35,6 +39,10 @@ public function label(): string self::Pipe => 'pipe', self::AuthorizedKeys => 'authorized_keys', self::SweetTooth => 'sweet tooth', + self::Patch => 'patch', + self::Upstream => 'upstream', + self::Sudoers => 'sudoers', + self::Mknod => 'mknod', self::Luddite => 'luddite', }; } @@ -54,6 +62,10 @@ 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::Patch => 'Got a patch merged into /dev/null.', + self::Upstream => 'Got 5 patches merged into /dev/null.', + self::Sudoers => 'Trusted with 25 merged patches.', + self::Mknod => 'Built /dev/null itself with 100 merged patches.', self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.', }; } @@ -73,6 +85,10 @@ public function icon(): string self::Pipe => 'git-merge', self::AuthorizedKeys => 'key', self::SweetTooth => 'cookie', + self::Patch => 'git-pull-request', + self::Upstream => 'upload', + self::Sudoers => 'hash', + self::Mknod => 'hard-drive', self::Luddite => 'bot-off', }; } @@ -92,6 +108,10 @@ public function target(): int self::Pipe => 5, self::AuthorizedKeys => 1, self::SweetTooth => 100, + self::Patch => 1, + self::Upstream => 5, + self::Sudoers => 25, + self::Mknod => 100, self::Luddite => 1, }; } diff --git a/app/Jobs/SyncUpstreamContributions.php b/app/Jobs/SyncUpstreamContributions.php new file mode 100644 index 000000000..11a93038c --- /dev/null +++ b/app/Jobs/SyncUpstreamContributions.php @@ -0,0 +1,69 @@ + $contributions */ + $contributions = []; + + for ($page = 1; $page <= self::MaxPages; $page++) { + $body = $github->request() + ->get('repos/'.ChangelogQuery::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 97847f12c..5aa4b74f1 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 UpstreamContributionsQuery $contributions) { // } @@ -96,6 +96,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(); @@ -182,6 +183,22 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => min($user->pasteis_received, Achievement::SweetTooth->target()), 'target' => Achievement::SweetTooth->target(), ], + Achievement::Patch->value => [ + 'progress' => min($upstreamContributions, Achievement::Patch->target()), + 'target' => Achievement::Patch->target(), + ], + Achievement::Upstream->value => [ + 'progress' => min($upstreamContributions, Achievement::Upstream->target()), + 'target' => Achievement::Upstream->target(), + ], + Achievement::Sudoers->value => [ + 'progress' => min($upstreamContributions, Achievement::Sudoers->target()), + 'target' => Achievement::Sudoers->target(), + ], + Achievement::Mknod->value => [ + 'progress' => min($upstreamContributions, Achievement::Mknod->target()), + 'target' => Achievement::Mknod->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 47a4f9ca6..fb4aedd9b 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -5,12 +5,16 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, + HardDrive, + Hash, Key, Server, Shield, Sparkles, Star, Terminal, + Upload, Zap, } from 'lucide-react'; import type { CSSProperties } from 'react'; @@ -37,6 +41,10 @@ const iconMap: Record> = { star: Star, sparkles: Sparkles, 'git-merge': GitMerge, + 'git-pull-request': GitPullRequest, + upload: Upload, + hash: Hash, + 'hard-drive': HardDrive, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index 8753918ec..d6f18438b 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -7,12 +7,16 @@ import { FileCode, Flame, GitMerge, + GitPullRequest, + HardDrive, + Hash, Key, Server, Shield, Sparkles, Star, Terminal, + Upload, Zap, } from 'lucide-react'; import type { CSSProperties } from 'react'; @@ -36,6 +40,10 @@ const iconMap: Record> = { star: Star, sparkles: Sparkles, 'git-merge': GitMerge, + 'git-pull-request': GitPullRequest, + upload: Upload, + hash: Hash, + 'hard-drive': HardDrive, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/routes/console.php b/routes/console.php index 99a0f16e0..e3310d591 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..62d6c84ec --- /dev/null +++ b/tests/Unit/Jobs/SyncUpstreamContributionsTest.php @@ -0,0 +1,138 @@ + "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/nunomaduro/dev-null.sh/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 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/nunomaduro/dev-null.sh/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/nunomaduro/dev-null.sh/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/nunomaduro/dev-null.sh/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 a92603672..0b18bd39b 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, 15)->setTime(12, 0)); @@ -27,7 +31,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(17); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -295,3 +299,70 @@ expect(collect($result['badges'])->firstWhere('id', 'luddite')['unlocked'])->toBeFalse(); }); + +it('locks the contributor badges while no contributor counts are cached', function (): void { + $user = User::factory()->create(); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + foreach ([Achievement::Patch, Achievement::Upstream, Achievement::Sudoers, Achievement::Mknod] as $achievement) { + expect(collect($result['badges'])->firstWhere('id', $achievement->value))->toMatchArray([ + 'target' => $achievement->target(), + 'progress' => 0, + 'unlocked' => false, + ]); + } +}); + +it('caps each contributor badge at its target as the github count grows', function (int $contributions, array $tiers): 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']) + ->whereIn('id', [Achievement::Patch->value, Achievement::Upstream->value, Achievement::Sudoers->value, Achievement::Mknod->value]) + ->map(fn (array $badge): array => [$badge['progress'], $badge['unlocked']]) + ->values() + ->all())->toBe($tiers) + ->and($result['xp'])->toBe(0); +})->with([ + 'no changes' => [0, [[0, false], [0, false], [0, false], [0, false]]], + 'one change' => [1, [[1, true], [1, false], [1, false], [1, false]]], + 'four changes' => [4, [[1, true], [4, false], [4, false], [4, false]]], + 'five changes' => [5, [[1, true], [5, true], [5, false], [5, false]]], + 'twenty-four changes' => [24, [[1, true], [5, true], [24, false], [24, false]]], + 'twenty-five changes' => [25, [[1, true], [5, true], [25, true], [25, false]]], + 'ninety-nine changes' => [99, [[1, true], [5, true], [25, true], [99, false]]], + 'a hundred changes' => [100, [[1, true], [5, true], [25, true], [100, true]]], + 'a hundred and fifty changes' => [150, [[1, true], [5, true], [25, true], [100, 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::Mknod->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(17); + + Http::assertNothingSent(); +}); From 8412f0f815f23eb75bd24e22a263558b5f0b25a0 Mon Sep 17 00:00:00 2001 From: Arhen Date: Sun, 25 Oct 2026 03:57:19 +0800 Subject: [PATCH 2/2] fix: simplify contribution achievements to one contributor badge --- KNOWLEDGE_BASE.md | 11 ++-- app/Enums/Achievement.php | 25 ++-------- app/Queries/UserAchievementsQuery.php | 18 ++----- .../js/components/achievements-dialog.tsx | 6 --- .../js/components/achievements-shelf.tsx | 6 --- .../Queries/UserAchievementsQueryTest.php | 50 +++++++++---------- 6 files changed, 36 insertions(+), 80 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2733d12fb..087d9ab57 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 **17 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,17 +129,14 @@ Profiles display **17 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. | -| **patch** | Get your first patch merged into the /dev/null source code. | -| **upstream** | Get **5 patches** merged into the /dev/null source code. | -| **sudoers** | Get **25 patches** merged into the /dev/null source code. | -| **mknod** | Get **100 patches** merged into the /dev/null source code. | +| **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. | 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. -The contributor badges (**patch**, **upstream**, **sudoers**, and **mknod**) count the patches GitHub credits to your account on the **production** branch of **nunomaduro/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. These badges add no XP, and since no post unlocks them, unlocking one shows no toast or confetti. Tom's top-ten contributor tool is a separate GitHub read. +The **contributor** badge unlocks when GitHub credits at least one contribution to your account on the **production** branch of **nunomaduro/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 badges use 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**. 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. +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**. 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), [contributor counts](app/Queries/UpstreamContributionsQuery.php), [contributor sync](app/Jobs/SyncUpstreamContributions.php), [achievements dialog](resources/js/components/achievements-dialog.tsx). diff --git a/app/Enums/Achievement.php b/app/Enums/Achievement.php index df5b8981c..4efcd0dbc 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -18,10 +18,7 @@ enum Achievement: string case Pipe = 'pipe'; case AuthorizedKeys = 'authorized_keys'; case SweetTooth = 'sweet_tooth'; - case Patch = 'patch'; - case Upstream = 'upstream'; - case Sudoers = 'sudoers'; - case Mknod = 'mknod'; + case Contributor = 'contributor'; case Luddite = 'luddite'; public function label(): string @@ -39,10 +36,7 @@ public function label(): string self::Pipe => 'pipe', self::AuthorizedKeys => 'authorized_keys', self::SweetTooth => 'sweet tooth', - self::Patch => 'patch', - self::Upstream => 'upstream', - self::Sudoers => 'sudoers', - self::Mknod => 'mknod', + self::Contributor => 'contributor', self::Luddite => 'luddite', }; } @@ -62,10 +56,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::Patch => 'Got a patch merged into /dev/null.', - self::Upstream => 'Got 5 patches merged into /dev/null.', - self::Sudoers => 'Trusted with 25 merged patches.', - self::Mknod => 'Built /dev/null itself with 100 merged patches.', + self::Contributor => 'Contributed to the /dev/null source code.', self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.', }; } @@ -85,10 +76,7 @@ public function icon(): string self::Pipe => 'git-merge', self::AuthorizedKeys => 'key', self::SweetTooth => 'cookie', - self::Patch => 'git-pull-request', - self::Upstream => 'upload', - self::Sudoers => 'hash', - self::Mknod => 'hard-drive', + self::Contributor => 'git-pull-request', self::Luddite => 'bot-off', }; } @@ -108,10 +96,7 @@ public function target(): int self::Pipe => 5, self::AuthorizedKeys => 1, self::SweetTooth => 100, - self::Patch => 1, - self::Upstream => 5, - self::Sudoers => 25, - self::Mknod => 100, + self::Contributor => 1, self::Luddite => 1, }; } diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 5aa4b74f1..d16057233 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -183,21 +183,9 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => min($user->pasteis_received, Achievement::SweetTooth->target()), 'target' => Achievement::SweetTooth->target(), ], - Achievement::Patch->value => [ - 'progress' => min($upstreamContributions, Achievement::Patch->target()), - 'target' => Achievement::Patch->target(), - ], - Achievement::Upstream->value => [ - 'progress' => min($upstreamContributions, Achievement::Upstream->target()), - 'target' => Achievement::Upstream->target(), - ], - Achievement::Sudoers->value => [ - 'progress' => min($upstreamContributions, Achievement::Sudoers->target()), - 'target' => Achievement::Sudoers->target(), - ], - Achievement::Mknod->value => [ - 'progress' => min($upstreamContributions, Achievement::Mknod->target()), - 'target' => Achievement::Mknod->target(), + Achievement::Contributor->value => [ + 'progress' => min($upstreamContributions, Achievement::Contributor->target()), + 'target' => Achievement::Contributor->target(), ], Achievement::Luddite->value => [ 'progress' => $hasUnfollowedTom ? 1 : 0, diff --git a/resources/js/components/achievements-dialog.tsx b/resources/js/components/achievements-dialog.tsx index fb4aedd9b..84051e162 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -6,15 +6,12 @@ import { Flame, GitMerge, GitPullRequest, - HardDrive, - Hash, Key, Server, Shield, Sparkles, Star, Terminal, - Upload, Zap, } from 'lucide-react'; import type { CSSProperties } from 'react'; @@ -42,9 +39,6 @@ const iconMap: Record> = { sparkles: Sparkles, 'git-merge': GitMerge, 'git-pull-request': GitPullRequest, - upload: Upload, - hash: Hash, - 'hard-drive': HardDrive, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index d6f18438b..9410de523 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -8,15 +8,12 @@ import { Flame, GitMerge, GitPullRequest, - HardDrive, - Hash, Key, Server, Shield, Sparkles, Star, Terminal, - Upload, Zap, } from 'lucide-react'; import type { CSSProperties } from 'react'; @@ -41,9 +38,6 @@ const iconMap: Record> = { sparkles: Sparkles, 'git-merge': GitMerge, 'git-pull-request': GitPullRequest, - upload: Upload, - hash: Hash, - 'hard-drive': HardDrive, key: Key, cookie: Cookie, 'bot-off': BotOff, diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index 0b18bd39b..d8896c67f 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -31,7 +31,7 @@ ->and($result['level_progress'])->toBe(0) ->and($result['ranks'])->toHaveCount(6) ->and($result['xp_breakdown']['commits']['xp'])->toBe(0) - ->and($result['badges'])->toHaveCount(17); + ->and($result['badges'])->toHaveCount(14); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -300,42 +300,40 @@ expect(collect($result['badges'])->firstWhere('id', 'luddite')['unlocked'])->toBeFalse(); }); -it('locks the contributor badges while no contributor counts are cached', function (): void { +it('locks the contributor badge while no contributor counts are cached', function (): void { $user = User::factory()->create(); $result = resolve(UserAchievementsQuery::class)->get($user, null); - foreach ([Achievement::Patch, Achievement::Upstream, Achievement::Sudoers, Achievement::Mknod] as $achievement) { - expect(collect($result['badges'])->firstWhere('id', $achievement->value))->toMatchArray([ - 'target' => $achievement->target(), - 'progress' => 0, - 'unlocked' => false, - ]); - } + expect(collect($result['badges'])->firstWhere('id', Achievement::Contributor->value))->toMatchArray([ + 'target' => 1, + 'progress' => 0, + 'unlocked' => false, + ]); }); -it('caps each contributor badge at its target as the github count grows', function (int $contributions, array $tiers): void { +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']) - ->whereIn('id', [Achievement::Patch->value, Achievement::Upstream->value, Achievement::Sudoers->value, Achievement::Mknod->value]) - ->map(fn (array $badge): array => [$badge['progress'], $badge['unlocked']]) - ->values() - ->all())->toBe($tiers) + 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(14) ->and($result['xp'])->toBe(0); })->with([ - 'no changes' => [0, [[0, false], [0, false], [0, false], [0, false]]], - 'one change' => [1, [[1, true], [1, false], [1, false], [1, false]]], - 'four changes' => [4, [[1, true], [4, false], [4, false], [4, false]]], - 'five changes' => [5, [[1, true], [5, true], [5, false], [5, false]]], - 'twenty-four changes' => [24, [[1, true], [5, true], [24, false], [24, false]]], - 'twenty-five changes' => [25, [[1, true], [5, true], [25, true], [25, false]]], - 'ninety-nine changes' => [99, [[1, true], [5, true], [25, true], [99, false]]], - 'a hundred changes' => [100, [[1, true], [5, true], [25, true], [100, true]]], - 'a hundred and fifty changes' => [150, [[1, true], [5, true], [25, true], [100, true]]], + '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 { @@ -344,7 +342,7 @@ $result = resolve(UserAchievementsQuery::class)->get($user, null); - expect(collect($result['badges'])->firstWhere('id', Achievement::Mknod->value)['progress'])->toBe(0); + 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 { @@ -362,7 +360,7 @@ Cache::forever(UpstreamContributionsQuery::CacheKey, ['583231' => 5]); $user = User::factory()->create(['github_id' => '583231']); - expect(resolve(UserAchievementsQuery::class)->get($user, $user)['badges'])->toHaveCount(17); + expect(resolve(UserAchievementsQuery::class)->get($user, $user)['badges'])->toHaveCount(14); Http::assertNothingSent(); });