diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 9db574eb4..38675ce3a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,7 +98,7 @@ 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, 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 GitHub login, which can differ from their /dev/null handle after a rename on GitHub), 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. diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 348ec66ae..a870f17ec 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -72,6 +72,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'tab' => $request->tab(...), 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null, 'readme' => fn (): string|false|null => $request->tab() === 'readme' ? $readme->get($user) : null, + 'readmeRepository' => fn (): ?string => $request->tab() === 'readme' ? $readme->repository($user) : null, // The README tab shows no commits, so it doesn't look for any. 'commits' => Inertia::scroll(fn () => $request->tab() === 'readme' ? new CursorPaginator([], 20) diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 92f25ff6d..3cd1681b3 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -65,12 +65,43 @@ public function get(User $user): string|false|null return $html === '' ? null : $html; } + /** + * The repository a profile README lives in is named after the GitHub login, which the handle can lag behind after + * a rename on GitHub, so this is the GitHub login, or the handle when GitHub can't tell. + */ + public function repository(User $user): string + { + try { + return $this->login($user) ?? $user->handle; + } catch (HttpClientException) { + return $user->handle; + } + } + + /** + * Looked up by the account id, which never changes, and kept for a day like the README, so a README cached before + * the login was still finds its repository, and reading it again costs one GitHub call instead of two. Null when the + * GitHub account no longer exists. + * + * @throws HttpClientException + */ + private function login(User $user): ?string + { + $key = "github-login:{$user->github_id}"; + + if (! Cache::has($key)) { + Cache::put($key, $this->github->login($user->github_id) ?? '', 86400); + } + + return Cache::string($key) ?: null; + } + /** * @throws HttpClientException */ private function fetch(User $user): string { - $login = $this->github->login($user->github_id); + $login = $this->login($user); if ($login === null) { return ''; diff --git a/resources/js/components/profile-readme.tsx b/resources/js/components/profile-readme.tsx index 68c850622..ffb281486 100644 --- a/resources/js/components/profile-readme.tsx +++ b/resources/js/components/profile-readme.tsx @@ -1,17 +1,20 @@ import { EmptyFeed } from '@/components/commit-feed'; /** - * The README of the developer's GitHub profile repository. The server only sends it once sanitized, with plain + * The README of the developer's GitHub profile repository, which is named after their GitHub login rather than their + * handle, as the two can differ after a rename. The server only sends it once sanitized, with plain * formatting, links and images from GitHub's own hosts. False when GitHub didn't answer, which is not the same as * having no README. */ export function ProfileReadme({ readme, - username, + handle, + repository, isCurrentUser, }: { readme: string | false | null; - username: string; + handle: string; + repository: string; isCurrentUser: boolean; }) { if (readme === false) { @@ -31,7 +34,7 @@ export function ProfileReadme({ {isCurrentUser ? ( <> Add a README.md to a GitHub repository named{' '} - {username}, and + {repository}, and it shows up here.{' '} ) : ( - `@${username} doesn't have a profile README on GitHub.` + `@${handle} doesn't have a profile README on GitHub.` )} @@ -58,12 +61,12 @@ export function ProfileReadme({ dangerouslySetInnerHTML={{ __html: readme }} /> - {username}/README.md on GitHub + {repository}/README.md on GitHub ); diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 28cc13e3b..8dcd36709 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -76,6 +76,7 @@ export default function Show({ tab, pinnedCommit, readme, + readmeRepository, }: { user: Profile; pasteisReceived: number | null; @@ -92,6 +93,7 @@ export default function Show({ tab: 'commits' | 'replies' | 'readme'; pinnedCommit: Commit | null; readme: string | false | null; + readmeRepository: string | null; }) { const { auth } = usePage().props; const isCurrentUser = auth.user?.id === user.id; @@ -434,7 +436,13 @@ export default function Show({ ) : ( diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 3bca2cec5..b9454ffa8 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -704,7 +704,7 @@ }); it('shows the github profile readme on the readme tab, without looking for commits', function (): void { - $user = User::factory()->create(['handle' => 'octocat']); + $user = User::factory()->create(['handle' => 'old-octocat']); Commit::factory()->for($user)->create(); Http::fake([ @@ -715,6 +715,7 @@ $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page ->where('tab', 'readme') ->where('readme', '

Hi there

') + ->where('readmeRepository', 'octocat') ->where('pinnedCommit', null) ->where('commits.data', [])); }); @@ -733,7 +734,8 @@ $user = User::factory()->create(['handle' => 'octocat']); $this->get(route('user.show', ['user' => $user, 'tab' => $tab]))->assertOk()->assertInertia(fn ($page) => $page - ->where('readme', null)); + ->where('readme', null) + ->where('readmeRepository', null)); })->with(['commits', 'replies']); it('names the followers the viewer knows, the most followed first', function (): void { diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php index a03d2be14..cb08cc6e9 100644 --- a/tests/Unit/Queries/GitHubReadmeQueryTest.php +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -5,6 +5,7 @@ use App\Models\User; use App\Queries\GitHubReadmeQuery; use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; function fakeGitHubReadme(User $user, mixed $readme): void @@ -107,7 +108,7 @@ function fakeGitHubReadme(User $user, mixed $readme): void $this->travel($minutes)->minutes(); resolve(GitHubReadmeQuery::class)->get($user); - Http::assertSentCount($isFetchedAgain ? 4 : 2); + Http::assertSentCount($isFetchedAgain ? 3 : 2); })->with([ 'uploaded image, after 4 minutes' => ['', 4, true], 'uploaded image, after 3 minutes' => ['', 3, false], @@ -155,6 +156,48 @@ function fakeGitHubReadme(User $user, mixed $readme): void ->not->toContain('hidden'); }); +it('names the repository after the github login, which the handle can lag behind', function (): void { + $user = User::factory()->create(['handle' => 'old-name']); + fakeGitHubReadme($user, Http::response(status: 404)); + $query = resolve(GitHubReadmeQuery::class); + $query->get($user); + + expect($query->repository($user))->toBe('octocat'); + Http::assertSentCount(2); +}); + +it('finds the repository of a readme cached before its login was', function (): void { + $user = User::factory()->create(['handle' => 'old-name']); + Cache::put("github-readme:{$user->github_id}", '

Hi

', 86400); + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat'])]); + $query = resolve(GitHubReadmeQuery::class); + + expect($query->get($user))->toBe('

Hi

') + ->and($query->repository($user))->toBe('octocat') + ->and($query->repository($user))->toBe('octocat'); + + Http::assertSentCount(1); +}); + +it('names the repository after the handle when github cannot tell', function (): void { + $user = User::factory()->create(['handle' => 'old-name']); + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(status: 502)]); + + expect(resolve(GitHubReadmeQuery::class)->repository($user))->toBe('old-name'); +}); + +it('looks the login up once for a readme read again', function (): void { + $user = User::factory()->create(); + fakeGitHubReadme($user, Http::response('

Hi

')); + $query = resolve(GitHubReadmeQuery::class); + + $query->get($user); + Cache::forget("github-readme:{$user->github_id}"); + $query->get($user); + + Http::assertSentCount(3); +}); + it('shows no readme for a github account that no longer exists', function (): void { $user = User::factory()->create();