diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index de152c881..c9fe18961 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**. 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. -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 @@ -629,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/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..8ca67079b 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,8 +63,18 @@ public function get(): ?array } /** - * The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every - * visitor to GitHub. + * 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 + { + return in_array($user->github_id, array_column($this->accounts() ?? [], 'github_id'), true); + } + + /** + * The GitHub accounts behind the contributors. Once there is a list from the past week, nobody waits for GitHub: an + * expired list is served for another minute while GitHub is asked after the response, so other visitors don't ask + * meanwhile. Without one, GitHub is asked right away, and null means it didn't answer. * * @return list|null */ @@ -75,37 +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; - } + /** @var list|null $contributors */ + $contributors = Cache::get("{$key}:last"); + + if ($contributors === null) { + $contributors = $this->refresh($repository, $key); + } else { + 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) { - $contributors = false; } + } catch (HttpClientException) { + /** @var list|false $contributors */ + $contributors = Cache::get("{$key}:last", false); + Cache::put($key, $contributors, self::FailedTtl); - Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl); + return $contributors; } - return $contributors === false ? null : $contributors; + Cache::put("{$key}:last", $contributors, self::LastTtl); + Cache::put($key, $contributors, self::Ttl); + + return $contributors; } } 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..368d66aa4 100644 --- a/tests/Unit/Queries/ContributorsQueryTest.php +++ b/tests/Unit/Queries/ContributorsQueryTest.php @@ -3,11 +3,13 @@ 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; 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 { @@ -73,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(); @@ -85,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); }); @@ -149,3 +159,62 @@ expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'recovered', 'commits' => 12, 'handle' => null]]); $github->shouldHaveReceived('contributors')->times(3); }); + +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)], + 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(); + 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); +}); + +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)]); + + $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], +]);