Skip to content
Open
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
40 changes: 35 additions & 5 deletions app/Queries/ContributorsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

namespace App\Queries;

use App\Models\User;
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}
* @phpstan-type Account array{login: string, github_id: string, commits: int}
* @phpstan-type Contributor array{login: string, commits: int, username: string|null}
*/
final readonly class ContributorsQuery

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait wat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is that contributor list hardcoded even

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was already hardcoded on production: Nuno added it as a static list in resources/js/lib/about.ts when he built the about page, in bdff7e5. This PR only moves it to PHP, in 541251f.

It had to move because the hovercard needs each contributor's /dev/null username, and the only safe way to match a contributor to a user is by github_id. Logins and usernames can both be renamed. So the server needs the GitHub ids, which is why each entry now has a github_id.

You're right that it doesn't need to be hardcoded, though. GitHub's repos/{repo}/contributors endpoint returns the login, id and commit count for production. I can fetch that through our GitHub service and cache it, with a small list to leave out agent accounts like ampagent. Want me to do that in this PR, or in a follow-up?

{
Expand All @@ -33,17 +35,43 @@ 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.
* The repository contributors, most commits first, as GitHub counts them, each with their /dev/null username, or
* null when they aren't on it. They are found by their GitHub account, since a username can change on either side.
*
* @return list<Contributor>|null
*/
public function get(): ?array
{
$accounts = $this->accounts();

if ($accounts === null) {
return null;
}

$usernames = User::query()
->whereIn('github_id', array_column($accounts, 'github_id'))
->get()
->mapWithKeys(fn (User $user): array => [$user->github_id => $user->username]);

return array_map(fn (array $account): array => [
'login' => $account['login'],
'commits' => $account['commits'],
'username' => $usernames->get($account['github_id']),
], $accounts);
}

/**
* The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every
* visitor to GitHub.
*
* @return list<Account>|null
*/
private function accounts(): ?array
{
$repository = config()->string('services.github.repository');
$key = "about:contributors:{$repository}";
$key = "about:contributor-accounts:{$repository}";

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

if ($contributors === null) {
Expand All @@ -65,10 +93,12 @@ public function get(): ?array
$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()
Expand Down
4 changes: 4 additions & 0 deletions resources/js/lib/about.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ export type StoryPerson = {
name: string;
};

/**
* Someone with a commit on production. The username is theirs on /dev/null, or null when they aren't on it.
*/
export type StoryContributor = {
login: string;
commits: number;
username: string | null;
};

export const storyBefore: StoryEvent[] = [
Expand Down
81 changes: 55 additions & 26 deletions resources/js/pages/about/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DocumentPage,
DocumentSection,
} from '@/components/document-page';
import { UserPreview } from '@/components/user-preview';
import { WordmarkText } from '@/components/wordmark-text';
import AppLayout from '@/layouts/app-layout';
import type {
Expand All @@ -30,6 +31,7 @@ import {
storyStreams,
} from '@/lib/about';
import { cn, laneColor } from '@/lib/utils';
import { show } from '@/routes/user';
import type { Lane } from '@/types';

const kindIcons: Record<StoryEventKind, typeof User> = {
Expand Down Expand Up @@ -265,37 +267,64 @@ function Stream({ stream }: { stream: StoryStream }) {
);
}

function ContributorCard({ contributor }: { contributor: StoryContributor }) {
return (
<>
<img
src={`https://github.com/${contributor.login}.png?size=64`}
alt=""
width={28}
height={28}
loading="lazy"
className="size-7 shrink-0 rounded-full bg-muted"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
@{contributor.login}
</span>
<span className="block font-mono text-xs text-muted-foreground tabular-nums">
{contributor.commits}{' '}
{contributor.commits === 1 ? 'commit' : 'commits'}
</span>
</span>
</>
);
}

/**
* Contributors on /dev/null link to their profile, with their profile card on hover. Everyone else links to GitHub.
*/
function Contributors({ contributors }: { contributors: StoryContributor[] }) {
const className =
'flex items-center gap-2.5 rounded-md border px-2.5 py-2 no-underline! transition-colors hover:bg-muted';

return (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
{contributors.map((contributor) => (
<li key={contributor.login}>
<a
href={`https://github.com/${contributor.login}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 rounded-md border px-2.5 py-2 no-underline! transition-colors hover:bg-muted"
>
<img
src={`https://github.com/${contributor.login}.png?size=64`}
alt=""
width={28}
height={28}
loading="lazy"
className="size-7 shrink-0 rounded-full bg-muted"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
@{contributor.login}
</span>
<span className="block font-mono text-xs text-muted-foreground tabular-nums">
{contributor.commits}{' '}
{contributor.commits === 1
? 'commit'
: 'commits'}
</span>
</span>
</a>
{contributor.username === null ? (
<a
href={`https://github.com/${contributor.login}`}
target="_blank"
rel="noopener noreferrer"
className={className}
>
<ContributorCard contributor={contributor} />
</a>
) : (
<UserPreview
user={{ username: contributor.username }}
className="block"
>
<Link
href={show({ username: contributor.username })}
aria-label={`@${contributor.username} on /dev/null`}
className={className}
>
<ContributorCard contributor={contributor} />
</Link>
</UserPreview>
)}
</li>
))}
</ul>
Expand Down
13 changes: 12 additions & 1 deletion tests/Feature/Controllers/AboutControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@
->assertOk()
->assertInertia(fn ($page) => $page
->has('contributors', 1)
->where('contributors.0', ['login' => 'xHeaven', 'commits' => 187]));
->where('contributors.0', ['login' => 'xHeaven', 'commits' => 187, 'username' => null]));
});

it('links each contributor who is on /dev/null to their profile, found by their GitHub account', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 217, id: 14284867), gitHubContributor('nunomaduro', 189)]);
User::factory()->create(['github_id' => '14284867', 'username' => 'renamed-on-devnull']);

$this->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page
->where('contributors.0', ['login' => 'xHeaven', 'commits' => 217, 'username' => 'renamed-on-devnull'])
->where('contributors.1', ['login' => 'nunomaduro', 'commits' => 189, 'username' => null]));
});

it('leaves the contributors out while GitHub is unreachable', function (): void {
Expand Down
4 changes: 2 additions & 2 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,9 @@ function fakeGitHubAccount(User $user, array $account = []): void
/**
* @return array<string, mixed>
*/
function gitHubContributor(string $login, int $commits, string $type = 'User'): array
function gitHubContributor(string $login, int $commits, string $type = 'User', ?int $id = null): array
{
return ['login' => $login, 'contributions' => $commits, 'type' => $type];
return ['login' => $login, 'id' => $id ?? crc32($login), 'contributions' => $commits, 'type' => $type];
}

/**
Expand Down
21 changes: 13 additions & 8 deletions tests/Unit/Queries/ContributorsQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
fakeGitHubContributors([gitHubContributor('xHeaven', 187), gitHubContributor('jubianchi', 1)]);

expect(resolve(ContributorsQuery::class)->get())->toBe([
['login' => 'xHeaven', 'commits' => 187],
['login' => 'jubianchi', 'commits' => 1],
['login' => 'xHeaven', 'commits' => 187, 'username' => null],
['login' => 'jubianchi', 'commits' => 1, 'username' => null],
]);
});

Expand All @@ -24,7 +24,7 @@
gitHubContributor('dependabot', 4, 'Bot'),
]);

expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187]]);
expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'username' => null]]);
});

it('reads every page GitHub has, up to a thousand people', function (int $contributors, int $expected, int $requests): void {
Expand All @@ -49,10 +49,15 @@
Http::assertSentCount(2);
});

it('skips what GitHub sends without a login or a count', function (): void {
fakeGitHubContributors([['login' => 'ghost', 'type' => 'User'], ['contributions' => 3, 'type' => 'User'], gitHubContributor('xHeaven', 187)]);
it('skips what GitHub sends without a login, an account or a count', function (): void {
fakeGitHubContributors([
['login' => 'ghost', 'id' => 1, 'type' => 'User'],
['id' => 2, 'contributions' => 3, 'type' => 'User'],
['login' => 'nobody', 'contributions' => 3, 'type' => 'User'],
gitHubContributor('xHeaven', 187),
]);

expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187]]);
expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'xHeaven', 'commits' => 187, 'username' => null]]);
});

it('asks GitHub again an hour later, not before', function (): void {
Expand All @@ -74,7 +79,7 @@
fakeGitHubContributors([gitHubContributor('claude', 2)]);

expect(resolve(ContributorsQuery::class)->get())->toBe([])
->and(Cache::get('about:contributors:'.config()->string('services.github.repository')))->toBe([]);
->and(Cache::get('about:contributor-accounts:'.config()->string('services.github.repository')))->toBe([]);

$this->travel(2)->minutes();
resolve(ContributorsQuery::class)->get();
Expand Down Expand Up @@ -123,6 +128,6 @@

$this->travel(61)->seconds();

expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'recovered', 'commits' => 12]]);
expect(resolve(ContributorsQuery::class)->get())->toBe([['login' => 'recovered', 'commits' => 12, 'username' => null]]);
Http::assertSentCount(3);
});