diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index f08a1cf81..5c24de6a1 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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-` 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 diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 348ec66ae..57aa667e4 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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; @@ -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); @@ -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, diff --git a/app/Queries/ContributorsQuery.php b/app/Queries/ContributorsQuery.php index 5bbaba6d4..78369f311 100644 --- a/app/Queries/ContributorsQuery.php +++ b/app/Queries/ContributorsQuery.php @@ -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. diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 28cc13e3b..ba97a605f 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -2,6 +2,7 @@ import { Head, Link, router, usePage } from '@inertiajs/react'; import { Building2, CalendarDays, + GitPullRequest, Heart, Link2, MapPin, @@ -66,6 +67,7 @@ export default function Show({ isFollowing, followsYou, isMuting, + contributorLogin, followersYouKnow, canPush, moderation, @@ -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; @@ -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( @@ -225,7 +232,7 @@ export default function Show({

{user.name}

-
+

+ {contributorLogin && ( + + + /dev/null contributor + + )}

{moderation && } diff --git a/tests/Feature/ContextualSuggestedUsersTest.php b/tests/Feature/ContextualSuggestedUsersTest.php index 27b54813e..fb9b8ba2d 100644 --- a/tests/Feature/ContextualSuggestedUsersTest.php +++ b/tests/Feature/ContextualSuggestedUsersTest.php @@ -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))); diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index a513ec1d7..e773c2c57 100644 --- a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -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(); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 3bca2cec5..7dd3a5dad 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -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();