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
6 changes: 4 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,15 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes

### Profiles and GitHub sync

Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link.
Every developer has a profile at `/@{handle}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, a **/dev/null contributor** label beside their level when GitHub lists them as a contributor to the /dev/null repository, which opens their merged pull requests on GitHub, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. A **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link.

Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

Contributors are matched by GitHub account ID against the same hourly cached list as the About page, so a first merge can take an hour, plus however long GitHub takes to update its counts, to show; when GitHub cannot be reached, the label is not shown.

Profiles also show [achievement badges and an XP-based Unix rank](#achievement-badges-and-xp). Click a badge or rank to inspect unlock requirements and progress.

Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php).
Sources: [profile page](resources/js/pages/user/show.tsx), [contributors](app/Queries/ContributorsQuery.php), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php).

### Going live

Expand Down
5 changes: 4 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Http\Requests\ShowUserRequest;
use App\Models\Commit;
use App\Models\User;
use App\Queries\ContributorsQuery;
use App\Queries\FollowersYouKnowQuery;
use App\Queries\GitHubReadmeQuery;
use App\Queries\PinnedCommitQuery;
Expand All @@ -32,7 +33,7 @@
/**
* Profiles are public, so guests and crawlers read the same page, with the profile's link preview.
*/
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, PageMetadata $metadata): Response
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, ContributorsQuery $contributors, PageMetadata $metadata): Response
{
$blockerId = $currentUser?->blockerIdWith($user);

Expand All @@ -59,6 +60,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser
'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false,
'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser),
'isMuting' => fn (): bool => $currentUser?->isMuting($user) ?? false,
// GitHub can be slow to list the contributors when they aren't cached, so they load on their own
'contributorLogin' => Inertia::defer(fn (): ?string => $contributors->login($user), 'contributors'),
'followersYouKnow' => fn (): ?array => $currentUser instanceof User && $currentUser->isNot($user)
? $followersYouKnow->preview($user, $currentUser)
: null,
Expand Down
9 changes: 9 additions & 0 deletions app/Queries/ContributorsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ public function get(): ?array
], $accounts);
}

/**
* The GitHub login of the user's account when it is among the repository contributors, as GitHub reports it now,
* which a rename the user hasn't synced yet leaves ahead of their handle. Null when it isn't, or GitHub didn't answer.
*/
public function login(User $user): ?string
{
return array_column($this->accounts() ?? [], 'login', 'github_id')[$user->github_id] ?? null;
}

/**
* The GitHub accounts behind the contributors. A failed read is kept for a minute, so an outage doesn't send every
* visitor to GitHub.
Expand Down
23 changes: 21 additions & 2 deletions resources/js/pages/user/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Head, Link, router, usePage } from '@inertiajs/react';
import {
Building2,
CalendarDays,
GitPullRequest,
Heart,
Link2,
MapPin,
Expand Down Expand Up @@ -66,6 +67,7 @@ export default function Show({
isFollowing,
followsYou,
isMuting,
contributorLogin,
followersYouKnow,
canPush,
moderation,
Expand All @@ -82,6 +84,11 @@ export default function Show({
isFollowing: boolean;
followsYou: boolean;
isMuting: boolean;
/**
* The GitHub login of a contributor to /dev/null, null for everyone else. Deferred, so it is only set once GitHub's
* contributors are read.
*/
contributorLogin?: string | null;
followersYouKnow: FollowersYouKnowPreview | null;
canPush: boolean;
moderation: UserModeration | null;
Expand All @@ -93,7 +100,7 @@ export default function Show({
pinnedCommit: Commit | null;
readme: string | false | null;
}) {
const { auth } = usePage().props;
const { auth, repository } = usePage().props;
const isCurrentUser = auth.user?.id === user.id;
const lane = laneColor(user.lane);
const [highlightedPeriod, setHighlightedPeriod] = useState<string | null>(
Expand Down Expand Up @@ -225,7 +232,7 @@ export default function Show({
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-balance">
{user.name}
</h1>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<p className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground">
<span
aria-hidden
Expand Down Expand Up @@ -259,6 +266,18 @@ export default function Show({
Lv. {achievements.level} · {achievements.rank}
</span>
</button>
{contributorLogin && (
<a
href={`https://github.com/${repository}/pulls?q=is:pr+is:merged+author:${contributorLogin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-[11px] font-medium text-foreground transition hover:bg-muted"
title={`Pull requests by @${contributorLogin} merged into /dev/null`}
>
<GitPullRequest className="size-3" />
/dev/null contributor
</a>
)}
</div>

{moderation && <UserModerationStatus moderation={moderation} />}
Expand Down
2 changes: 1 addition & 1 deletion tests/Feature/ContextualSuggestedUsersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
->assertInertia(fn ($page) => $page
->component('user/show')
->missing('suggestedUsers')
->loadDeferredProps(fn ($reload) => $reload
->loadDeferredProps('default', fn ($reload) => $reload
->has('suggestedUsers', 1)
->where('suggestedUsers.0.id', $related->id)));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
$this->actingAs($this->user)->get($webRoute)
->assertOk()
->assertInertia(function ($inertia) use (&$web) {
return $inertia->loadDeferredProps(function ($reload) use (&$web) {
return $inertia->loadDeferredProps('default', function ($reload) use (&$web) {
return $reload->where('suggestedUsers', function ($users) use (&$web): bool {
$web = collect($users)->pluck('id')->all();

Expand Down
24 changes: 24 additions & 0 deletions tests/Feature/Controllers/UserControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,30 @@
->where('user.has_sponsors_listing', true));
});

it('defers the GitHub login of contributors to /dev/null, matching their GitHub account', function (): void {
$contributor = User::factory()->create(['github_id' => (string) crc32('xHeaven'), 'handle' => 'before-rename']);
$other = User::factory()->create();
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);

$this->get(route('user.show', $contributor))
->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page
->missing('contributorLogin')
->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', 'xHeaven')));

$this->get(route('user.show', $other))
->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page
->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', null)));
});

it('does not show anyone as a contributor when GitHub does not answer', function (): void {
$user = User::factory()->create();
Http::fake(['api.github.com/*' => Http::response(status: 500)]);

$this->get(route('user.show', $user))
->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page
->loadDeferredProps('contributors', fn (AssertableInertia $reload): AssertableInertia => $reload->where('contributorLogin', null)));
});

it('does not count the user relations or activity when reloading only commits', function (string $tab): void {
$user = User::factory()->has(Commit::factory())->create(['handle' => 'octocat']);
Commit::factory()->for($user)->for(Commit::factory(), 'parent')->create();
Expand Down