Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,11 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts](

### 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 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.

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 and a `signup` event right after an account is created, with nothing that identifies you.

Sources: [last seen tracking](app/Http/Middleware/UpdateLastSeenAt.php), [Fathom events](resources/js/lib/analytics.ts), [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: [last seen tracking](app/Http/Middleware/UpdateLastSeenAt.php), [Fathom events](resources/js/lib/analytics.ts), [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

Expand Down
7 changes: 5 additions & 2 deletions app/Http/Controllers/AboutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
90 changes: 90 additions & 0 deletions app/Queries/ContributorsQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Services\GitHub;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Fluent;

/**
* @phpstan-type Contributor array{login: string, commits: int}
*/
final readonly class ContributorsQuery
{
private const int PerPage = 100;

private const int MaxPages = 10;

private const int Ttl = 3600;

private const int FailedTtl = 60;

/**
* GitHub reports them as ordinary accounts, so they are named rather than detected.
*/
private const array Agents = ['ampagent', 'claude'];

public function __construct(private GitHub $github)
{
//
}

/**
* The repository contributors, most commits first, as GitHub counts them. A failed read is kept for a
* minute, so an outage doesn't send every visitor to GitHub.
*
* @return list<Contributor>|null
*/
public function get(): ?array
{
$repository = config()->string('services.github.repository');
$key = "about:contributors:{$repository}";

/** @var list<Contributor>|false|null $contributors */
$contributors = Cache::get($key);

if ($contributors === null) {
$contributors = [];

try {
for ($page = 1; $page <= self::MaxPages; $page++) {
$accounts = $this->github->request()->get("repos/{$repository}/contributors", [
'per_page' => self::PerPage,
'page' => $page,
])->throw()->collect();

if ($accounts->isNotEmpty() && ! array_is_list($accounts->all())) {
$contributors = false;

break;
}

$contributors = [...$contributors, ...$accounts->where('type', 'User')
->mapInto(Fluent::class)
->filter(fn (Fluent $account): bool => $account->string('login')->isNotEmpty()
&& 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(),
'commits' => $account->integer('contributions'),
])
->values()
->all()];

if ($accounts->count() < self::PerPage) {
break;
}
}
} catch (HttpClientException) {
$contributors = false;
}

Cache::put($key, $contributors, $contributors === false ? self::FailedTtl : self::Ttl);
}

return $contributors === false ? null : $contributors;
}
}
67 changes: 0 additions & 67 deletions resources/js/lib/about.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,70 +266,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: 217 },
{ login: 'nunomaduro', commits: 189 },
{ login: 'mnapoli', commits: 83 },
{ login: 'brendt', commits: 49 },
{ login: 'paulinevos', commits: 28 },
{ login: 'jubianchi', commits: 24 },
{ login: 'MrPunyapal', commits: 19 },
{ login: 'simonhamp', commits: 19 },
{ login: 'hbenabdallah', commits: 10 },
{ login: 'sadiqk2', commits: 8 },
{ login: 'SanderMuller', commits: 7 },
{ login: 'fernandofreamunde', commits: 6 },
{ login: 'camilleislasse', commits: 6 },
{ login: 'HelgeSverre', commits: 5 },
{ login: 'jjaybrown', commits: 5 },
{ login: 'richardkeep', commits: 5 },
{ login: 'vtlxx', commits: 5 },
{ login: 'robelsust', commits: 4 },
{ login: 'prateekbhujel', commits: 4 },
{ login: 'MaximeGoncalves', commits: 3 },
{ login: 'mathiasonea', commits: 3 },
{ login: 'virtualtear', commits: 2 },
{ login: 'sulimanbenhalim', commits: 2 },
{ login: 'mon4ssi', commits: 2 },
{ login: 'slovenianGooner', commits: 2 },
{ login: 'hbenjacquet', commits: 2 },
{ login: 'ashkesarmaa', commits: 2 },
{ login: 'Akiyo974', commits: 2 },
{ login: 'Chemaclass', commits: 2 },
{ login: 'eznix86', commits: 2 },
{ login: 'BENNIEV-ZA', commits: 2 },
{ login: 'ahosker', commits: 2 },
{ login: 'aaqidmasoodi', commits: 2 },
{ login: 'kachelle', commits: 1 },
{ login: 'lazarospsa', commits: 1 },
{ login: 'Naimur404', commits: 1 },
{ login: 'OpadaAlzaiede', commits: 1 },
{ login: 'ondrejmirtes', commits: 1 },
{ login: 'Heyosseus', commits: 1 },
{ login: 'RobinMalfait', commits: 1 },
{ login: 'rohitsasi720', commits: 1 },
{ login: 'murrant', commits: 1 },
{ login: 'tooinfinity', commits: 1 },
{ login: 'utsavsomaiya', commits: 1 },
{ login: 'vmitchell85', commits: 1 },
{ login: 'anatolesns', commits: 1 },
{ login: 'eramitgupta', commits: 1 },
{ login: 'kadirtikil', commits: 1 },
{ login: 'jigar-dhulla', commits: 1 },
{ login: 'hosmelq', commits: 1 },
{ login: 'hafeez-bw', commits: 1 },
{ login: 'GTS-MEGHNI', commits: 1 },
{ login: 'faissaloux', commits: 1 },
{ login: 'fabpl', commits: 1 },
{ login: 'Taldres', commits: 1 },
{ login: 'danielh-official', commits: 1 },
{ login: 'chrisgocode', commits: 1 },
{ login: 'chiragparekh', commits: 1 },
{ login: 'AmanuelLegese', commits: 1 },
{ login: 'albertoarena', commits: 1 },
{ login: 'alaminfirdows', commits: 1 },
];
20 changes: 15 additions & 5 deletions resources/js/pages/about/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { WordmarkText } from '@/components/wordmark-text';
import AppLayout from '@/layouts/app-layout';
import type {
StoryCommit,
StoryContributor,
StoryEvent,
StoryEventKind,
StoryStream,
Expand All @@ -25,7 +26,6 @@ import {
storyAfter,
storyAfterLane,
storyBefore,
storyContributors,
storyFounders,
storyStreams,
} from '@/lib/about';
Expand Down Expand Up @@ -265,10 +265,10 @@ function Stream({ stream }: { stream: StoryStream }) {
);
}

function Contributors() {
function Contributors({ contributors }: { contributors: StoryContributor[] }) {
return (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
{storyContributors.map((contributor) => (
{contributors.map((contributor) => (
<li key={contributor.login}>
<a
href={`https://github.com/${contributor.login}`}
Expand Down Expand Up @@ -302,7 +302,11 @@ function Contributors() {
);
}

export default function Show() {
export default function Show({
contributors,
}: {
contributors: StoryContributor[] | null;
}) {
const { repository } = usePage().props;
return (
<>
Expand Down Expand Up @@ -394,7 +398,13 @@ export default function Show() {

<DocumentSection id="contributors" title="Contributors">
<p>Everyone who has a commit on production. Thank you.</p>
<Contributors />
{contributors ? (
<Contributors contributors={contributors} />
) : (
<p className="text-muted-foreground">
GitHub couldn't be reached. Try again in a minute.
</p>
)}
<p className="text-muted-foreground">
Want to be on this list?{' '}
<a
Expand Down
26 changes: 26 additions & 0 deletions tests/Feature/Controllers/AboutControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,50 @@
declare(strict_types=1);

use App\Models\User;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;

it('renders the about page for guests', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);

$this->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page->component('about/show'));
});

it('renders the about page for signed in users', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);

$this->actingAs(User::factory()->create())
->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page->component('about/show'));
});

it('describes the about page to search engines and link previews', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);

$this->get(route('about.show'))
->assertOk()
->assertSee('<title>About - /dev/null</title>', false)
->assertSee('<link rel="canonical" href="'.route('about.show').'">', false)
->assertSee('"@type":"WebPage"', false);
});

it('hands the contributors to the page', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);

$this->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page
->has('contributors', 1)
->where('contributors.0', ['login' => 'xHeaven', 'commits' => 187]));
});

it('leaves the contributors out while GitHub is unreachable', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));

$this->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page->where('contributors', null));
});
24 changes: 24 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
use App\Services\SignedCommits;
use App\Services\SignedRequests;
use Carbon\CarbonInterface;
use GuzzleHttp\Promise\PromiseInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -133,6 +135,28 @@ function fakeGitHubAccount(User $user, array $account = []): void
fakeGitHubSshKeys(User::factory()->make(['github_id' => $user->github_id, 'username' => $account['login']]));
}

/**
* @return array<string, mixed>
*/
function gitHubContributor(string $login, int $commits, string $type = 'User'): array
{
return ['login' => $login, 'contributions' => $commits, 'type' => $type];
}

/**
* Serves the contributors of the repository like GitHub does: most commits first, a page at a time.
*
* @param list<array<string, mixed>> $contributors
*/
function fakeGitHubContributors(array $contributors): void
{
Http::fake(['api.github.com/repos/'.config()->string('services.github.repository').'/contributors*' => function (Request $request) use ($contributors): PromiseInterface {
$perPage = (int) $request['per_page'];

return Http::response(array_slice($contributors, ((int) $request['page'] - 1) * $perPage, $perPage));
}]);
}

/**
* @param list<SshPublicKey> $keys
* @param list<SshPublicKey> $signingKeys
Expand Down
Loading
Loading