diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index dc159419a..45285d0e7 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -152,7 +152,7 @@ Sources: [go-live button](resources/js/components/profile-live-button.tsx), [liv ### Achievement badges and XP -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. +Profiles have **17 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 | | --- | --- | @@ -172,14 +172,15 @@ Profiles have **16 achievement badges**. Earned badges appear in a single row, w | **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!” | | **whoopsie** | “Nuked production at least once.” — unlocked for **@paulinevos**, **@nunomaduro**, **@brendt**, and **@mnapoli**, regardless of handle capitalization. No bonus XP. | +| **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 @@ -679,7 +680,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 **Partners** page at `/partners` thanks the people and companies who sponsor /dev/null through [GitHub Sponsors](https://github.com/sponsors/dev-null-sh) and says where the money goes: hosting and the AI services first, then getting the team together to build more. Partners get a spot on the page, at the top while they sponsor and in the list of past partners after that; sponsoring buys no promoted commits, badges, or say in what trends, and private sponsors aren't listed. No partners are listed yet. The **Changelog**, opened from the navigation or the side column, lists the commits on `production` on GitHub, a few days at a time, each linked to the pull request it came from when there is one. 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. Each time the terms change, the website covers every page with the new terms, which you can scroll through in place, until you accept them; if you don't want to, you can go to settings to export your data or delete your account, or sign out and keep reading as a guest. The terms and privacy pages and every settings tab (profile, appearance, terminal, safety, and devices) stay open without accepting, and accepting on the website or in the mobile apps counts for both. 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 **Partners** page at `/partners` thanks the people and companies who sponsor /dev/null through [GitHub Sponsors](https://github.com/sponsors/dev-null-sh) and says where the money goes: hosting and the AI services first, then getting the team together to build more. Partners get a spot on the page, at the top while they sponsor and in the list of past partners after that; sponsoring buys no promoted commits, badges, or say in what trends, and private sponsors aren't listed. No partners are listed yet. The **Changelog**, opened from the navigation or the side column, lists the commits on `production` on GitHub, a few days at a time, each linked to the pull request it came from when there is one. 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. Each time the terms change, the website covers every page with the new terms, which you can scroll through in place, until you accept them; if you don't want to, you can go to settings to export your data or delete your account, or sign out and keep reading as a guest. The terms and privacy pages and every settings tab (profile, appearance, terminal, safety, and devices) stay open without accepting, and accepting on the website or in the mobile apps counts for both. 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 and how many come back, /dev/null remembers the **days** (not the times) you were signed in and used the site or the API, and the last of them; background refreshes do not count. These days are never shown to other people, are included in your export, and are 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 (unless the browser is driven by a program, like a scraper's), 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 e089fadc7..54e9e8b67 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -22,6 +22,7 @@ enum Achievement: string case ShellShocked = 'shell_shocked'; case SuperSaiyan = 'super_saiyan'; case Whoopsie = 'whoopsie'; + case Contributor = 'contributor'; public function label(): string { @@ -42,6 +43,7 @@ public function label(): string self::ShellShocked => 'shell shocked', self::SuperSaiyan => 'super-saiyan', self::Whoopsie => 'whoopsie', + self::Contributor => 'contributor', }; } @@ -64,6 +66,7 @@ public function description(): string 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::Whoopsie => 'Nuked production at least once.', + self::Contributor => 'Shipped code to the /dev/null repository itself. Earns 25 XP.', }; } @@ -86,6 +89,7 @@ public function icon(): string self::ShellShocked => 'bomb', self::SuperSaiyan => 'dragon-ball', self::Whoopsie => 'mushroom-cloud', + self::Contributor => 'git-pull-request', }; } @@ -108,6 +112,7 @@ public function target(): int self::ShellShocked => 1, self::SuperSaiyan => 9000, self::Whoopsie => 1, + self::Contributor => 1, }; } } diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 8d0407945..5adfe5b71 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,12 +63,23 @@ 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. Profiles never wait for GitHub, so this is false + * while there is no list yet or 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(wait: false) ?? [], '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. When the caller doesn't wait, + * it gets null at once instead, and only the first such caller in a minute asks GitHub, after the response. * * @return list|null */ - private function accounts(): ?array + private function accounts(bool $wait = true): ?array { $repository = config()->string('services.github.repository'); $key = "about:contributor-accounts:{$repository}"; @@ -75,37 +88,71 @@ 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 && ! $wait) { + if (Cache::add("{$key}:refreshing", true, self::FailedTtl)) { + defer(fn (): array|false => $this->refresh($repository, $key)); } - } catch (HttpClientException) { - $contributors = false; + + return null; } - Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl); + 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) { + /** @var list|false $contributors */ + $contributors = Cache::get("{$key}:last", false); + Cache::put($key, $contributors, self::FailedTtl); + + return $contributors; + } + + Cache::put("{$key}:last", $contributors, self::LastTtl); + Cache::put($key, $contributors, self::Ttl); + + return $contributors; + } } diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index 6c0174ef9..1a85c86b9 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 => [ @@ -192,6 +196,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => in_array(mb_strtolower($user->handle), ['paulinevos', 'nunomaduro', 'brendt', 'mnapoli'], true) ? 1 : 0, 'target' => Achievement::Whoopsie->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 7a67e7f22..64918cb46 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, @@ -48,6 +49,7 @@ const iconMap: Record> = { bomb: Bomb, 'dragon-ball': DragonBallIcon, 'mushroom-cloud': MushroomCloudIcon, + 'git-pull-request': GitPullRequest, }; export function AchievementsDialog({ @@ -343,8 +345,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 62839a871..343ca479b 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, @@ -53,6 +54,7 @@ const iconMap: Record> = { bomb: Bomb, 'dragon-ball': DragonBallIcon, 'mushroom-cloud': MushroomCloudIcon, + '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 52c8c4ba2..7c440317c 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -731,6 +731,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') @@ -744,6 +745,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)); @@ -752,6 +754,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..f0961fbfc 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,78 @@ 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('never makes profiles wait for GitHub on a cold cache, and asks it once after the response', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('contributors')->andReturn([gitHubContributor('xHeaven', 187)]); + $contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven')]); + + expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeFalse() + ->and(resolve(ContributorsQuery::class)->includes($contributor))->toBeFalse(); + $github->shouldNotHaveReceived('contributors'); + + resolve(DeferredCallbackCollection::class)->invoke(); + + expect(resolve(ContributorsQuery::class)->includes($contributor))->toBeTrue(); + $github->shouldHaveReceived('contributors')->once(); +}); + +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']); + resolve(ContributorsQuery::class)->get(); + + 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 2db261824..eefe9a5e4 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -3,12 +3,14 @@ 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; use App\Models\Commit; use App\Models\SshKey; use App\Models\User; +use App\Queries\ContributorsQuery; use App\Queries\UserAchievementsQuery; beforeEach(function (): void { @@ -28,7 +30,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(16); + ->and($result['xp_breakdown']['contributor']['xp'])->toBe(0) + ->and($result['badges'])->toHaveCount(17); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -384,3 +387,26 @@ '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)]); + resolve(ContributorsQuery::class)->get(); + + $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], +]);