From df0f65a151ceb0996c027a7fc1386019b2aa6b22 Mon Sep 17 00:00:00 2001 From: camilleislasse Date: Sun, 18 Oct 2026 20:32:31 +0200 Subject: [PATCH] feat: build the contributors list on /about from GitHub --- KNOWLEDGE_BASE.md | 4 +- app/Http/Controllers/AboutController.php | 7 +- app/Queries/ContributorsQuery.php | 119 ++++++++++++++++++ resources/js/lib/about.ts | 50 -------- resources/js/pages/about/show.tsx | 20 ++- .../Controllers/AboutControllerTest.php | 26 ++++ tests/Pest.php | 24 ++++ tests/Unit/Queries/ContributorsQueryTest.php | 111 ++++++++++++++++ 8 files changed, 302 insertions(+), 59 deletions(-) create mode 100644 app/Queries/ContributorsQuery.php create mode 100644 tests/Unit/Queries/ContributorsQueryTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3a3dfd790..8a03ef82b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -499,9 +499,9 @@ Sources: [metrics controller](app/Http/Controllers/ProductMetricsController.php) ### About, changelog, and policies -The public **About** page describes the livestream project and its contributors. 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 everyone with a commit on production, read from GitHub once an hour, most commits first; 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. -Sources: [public page routes](routes/web.php), [About](resources/js/pages/about/show.tsx), [privacy policy](resources/js/pages/privacy/show.tsx), [terms](resources/js/pages/terms/show.tsx). +Sources: [public page routes](routes/web.php), [About](resources/js/pages/about/show.tsx), [contributors](app/Queries/ContributorsQuery.php), [privacy policy](resources/js/pages/privacy/show.tsx), [terms](resources/js/pages/terms/show.tsx). ### Tom's repository insights and community statistics diff --git a/app/Http/Controllers/AboutController.php b/app/Http/Controllers/AboutController.php index 98b461175..19239dd92 100644 --- a/app/Http/Controllers/AboutController.php +++ b/app/Http/Controllers/AboutController.php @@ -4,15 +4,18 @@ namespace App\Http\Controllers; +use App\Queries\ContributorsQuery; use App\Services\PageMetadata; use Inertia\Inertia; use Inertia\Response; final readonly class AboutController { - public function show(PageMetadata $metadata): Response + public function show(ContributorsQuery $contributors, PageMetadata $metadata): Response { - return Inertia::render('about/show')->withViewData(['preview' => $metadata->page( + return Inertia::render('about/show', [ + 'contributors' => $contributors->get(...), + ])->withViewData(['preview' => $metadata->page( 'About - /dev/null', 'How /dev/null was built in the open: from the first commit on stream to the developers who joined and the people who shipped it.', route('about.show'), diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php new file mode 100644 index 000000000..62389e60c --- /dev/null +++ b/app/Queries/ContributorsQuery.php @@ -0,0 +1,119 @@ +|null + */ + public function get(): ?array + { + $repository = config()->string('services.github.repository'); + $key = "about:contributors:{$repository}"; + + /** @var list|false|null $contributors */ + $contributors = Cache::get($key); + + if ($contributors === null) { + $contributors = $this->fetch($repository) ?? false; + + Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl); + } + + return $contributors === false ? null : $contributors; + } + + /** + * @return list|null + */ + private function fetch(string $repository): ?array + { + $people = []; + + for ($page = 1; $page <= self::MaxPages; $page++) { + try { + $accounts = $this->github->request()->get("repos/{$repository}/contributors", [ + 'per_page' => self::PerPage, + 'page' => $page, + ])->throw()->collect(); + } catch (HttpClientException) { + return null; + } + + if ($accounts->isNotEmpty() && ! array_is_list($accounts->all())) { + return null; + } + + $people = [...$people, ...$this->people($accounts)]; + + if ($accounts->count() < self::PerPage) { + break; + } + } + + return $people; + } + + /** + * @param Collection $accounts + * @return list + */ + private function people(Collection $accounts): array + { + return array_values($accounts->where('type', 'User') + ->mapInto(Fluent::class) + ->filter($this->isPerson(...)) + ->map(fn (Fluent $account): array => [ + 'login' => $account->string('login')->value(), + 'commits' => $account->integer('contributions'), + ]) + ->all()); + } + + /** + * An account with a name and a count behind it, and not one of the agents. + * + * @param Fluent $account + */ + private function isPerson(Fluent $account): bool + { + $login = $account->string('login')->value(); + + return $login !== '' + && is_int($account->get('contributions')) + && ! in_array(mb_strtolower($login), self::Agents, true); + } +} diff --git a/resources/js/lib/about.ts b/resources/js/lib/about.ts index c6bbc3621..faaeb6e56 100644 --- a/resources/js/lib/about.ts +++ b/resources/js/lib/about.ts @@ -181,53 +181,3 @@ export const storyAfter: StoryEvent[] = [ }, }, ]; - -/** - * Everyone with a commit on production, most commits first. Bots and agents are left out. - */ -export const storyContributors: StoryContributor[] = [ - { login: 'xHeaven', commits: 199 }, - { login: 'nunomaduro', commits: 176 }, - { login: 'mnapoli', commits: 61 }, - { login: 'brendt', commits: 42 }, - { login: 'simonhamp', commits: 18 }, - { login: 'paulinevos', commits: 16 }, - { login: 'jubianchi', commits: 16 }, - { login: 'MrPunyapal', commits: 15 }, - { login: 'sadiqk2', commits: 8 }, - { login: 'camilleislasse', commits: 6 }, - { login: 'hbenabdallah', commits: 5 }, - { login: 'vtlxx', commits: 5 }, - { login: 'robelsust', commits: 4 }, - { login: 'HelgeSverre', commits: 4 }, - { login: 'SanderMuller', commits: 3 }, - { login: 'MaximeGoncalves', commits: 3 }, - { login: 'mathiasonea', commits: 3 }, - { login: 'fernandofreamunde', commits: 3 }, - { login: 'mon4ssi', commits: 2 }, - { login: 'hbenjacquet', commits: 2 }, - { login: 'Akiyo974', commits: 2 }, - { login: 'Chemaclass', commits: 2 }, - { login: 'eznix86', commits: 2 }, - { login: 'BENNIEV-ZA', commits: 2 }, - { login: 'ahosker', commits: 2 }, - { login: 'eramitgupta', commits: 1 }, - { login: 'utsavsomaiya', commits: 1 }, - { login: 'tooinfinity', commits: 1 }, - { login: 'murrant', commits: 1 }, - { login: 'RobinMalfait', commits: 1 }, - { login: 'prateekbhujel', commits: 1 }, - { login: 'ondrejmirtes', commits: 1 }, - { login: 'lazarospsa', commits: 1 }, - { login: 'kachelle', commits: 1 }, - { login: 'kadirtikil', commits: 1 }, - { login: 'jjaybrown', commits: 1 }, - { login: 'GTS-MEGHNI', commits: 1 }, - { login: 'faissaloux', commits: 1 }, - { login: 'fabpl', commits: 1 }, - { login: 'danielh-official', commits: 1 }, - { login: 'chrisgocode', commits: 1 }, - { login: 'AmanuelLegese', commits: 1 }, - { login: 'alaminfirdows', commits: 1 }, - { login: 'aaqidmasoodi', commits: 1 }, -]; diff --git a/resources/js/pages/about/show.tsx b/resources/js/pages/about/show.tsx index 409804079..8adba11ec 100644 --- a/resources/js/pages/about/show.tsx +++ b/resources/js/pages/about/show.tsx @@ -16,6 +16,7 @@ import { import AppLayout from '@/layouts/app-layout'; import type { StoryCommit, + StoryContributor, StoryEvent, StoryEventKind, StoryStream, @@ -24,7 +25,6 @@ import { storyAfter, storyAfterLane, storyBefore, - storyContributors, storyFounders, storyStreams, } from '@/lib/about'; @@ -264,10 +264,10 @@ function Stream({ stream }: { stream: StoryStream }) { ); } -function Contributors() { +function Contributors({ contributors }: { contributors: StoryContributor[] }) { return (