From 37cdc52f8718be245e6288e7d3181142e1e50497 Mon Sep 17 00:00:00 2001 From: devzain15 <195662226+devzain15@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:02:54 +0500 Subject: [PATCH 1/2] fix: name the profile README repository after the GitHub login The README tab reads repos/{login}/{login}/readme using the GitHub login looked up from the account id, but the empty state and the "README.md on GitHub" link used the /dev/null handle. The two can differ after a rename on GitHub or when an old handle was taken by someone else, so the page could tell you to create, or link you to, the wrong repository. The query now remembers the login it fetched with and the page uses it for the repository name and link, keeping the handle for the @mention. Also renames the component's username prop, which held the handle. --- KNOWLEDGE_BASE.md | 2 +- app/Http/Controllers/UserController.php | 1 + app/Queries/GitHubReadmeQuery.php | 11 +++++++++++ resources/js/components/profile-readme.tsx | 17 ++++++++++------- resources/js/pages/user/show.tsx | 13 +++++++++++-- .../Feature/Controllers/UserControllerTest.php | 6 ++++-- tests/Unit/Queries/GitHubReadmeQueryTest.php | 12 ++++++++++++ 7 files changed, 50 insertions(+), 12 deletions(-) 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..3f71719ac 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -65,6 +65,15 @@ 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 login the README was last looked up with, or the handle until then. + */ + public function repository(User $user): string + { + return Cache::string("github-login:{$user->github_id}", $user->handle); + } + /** * @throws HttpClientException */ @@ -76,6 +85,8 @@ private function fetch(User $user): string return ''; } + Cache::put("github-login:{$user->github_id}", $login, 86400); + $response = $this->github->request() ->replaceHeaders(['Accept' => 'application/vnd.github.html+json']) ->get("repos/{$login}/{$login}/readme"); 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..a8f19fd8d 100644 --- a/tests/Unit/Queries/GitHubReadmeQueryTest.php +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -155,6 +155,18 @@ 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']); + $query = resolve(GitHubReadmeQuery::class); + + expect($query->repository($user))->toBe('old-name'); + + fakeGitHubReadme($user, Http::response(status: 404)); + $query->get($user); + + expect($query->repository($user))->toBe('octocat'); +}); + it('shows no readme for a github account that no longer exists', function (): void { $user = User::factory()->create(); From 2dc6145d887d3a49aa8d92ad20649d5166f875f1 Mon Sep 17 00:00:00 2001 From: devzain15 <195662226+devzain15@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:20:34 +0500 Subject: [PATCH 2/2] fix: look the GitHub login up on its own for the README repository A README cached before the login was had no login to name its repository after, so the link fell back to the handle until the README cache expired. The login is now looked up by account id and cached on its own for a day, so it is always found, and fetching a README again reuses it instead of asking GitHub for the login each time. --- app/Queries/GitHubReadmeQuery.php | 30 ++++++++++++--- tests/Unit/Queries/GitHubReadmeQueryTest.php | 39 ++++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php index 3f71719ac..3cd1681b3 100644 --- a/app/Queries/GitHubReadmeQuery.php +++ b/app/Queries/GitHubReadmeQuery.php @@ -67,11 +67,33 @@ public function get(User $user): string|false|null /** * 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 login the README was last looked up with, or the handle until then. + * a rename on GitHub, so this is the GitHub login, or the handle when GitHub can't tell. */ public function repository(User $user): string { - return Cache::string("github-login:{$user->github_id}", $user->handle); + 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; } /** @@ -79,14 +101,12 @@ public function repository(User $user): string */ private function fetch(User $user): string { - $login = $this->github->login($user->github_id); + $login = $this->login($user); if ($login === null) { return ''; } - Cache::put("github-login:{$user->github_id}", $login, 86400); - $response = $this->github->request() ->replaceHeaders(['Accept' => 'application/vnd.github.html+json']) ->get("repos/{$login}/{$login}/readme"); diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php index a8f19fd8d..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], @@ -157,14 +158,44 @@ function fakeGitHubReadme(User $user, mixed $readme): void 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('old-name'); + 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); - fakeGitHubReadme($user, Http::response(status: 404)); + $query->get($user); + Cache::forget("github-readme:{$user->github_id}"); $query->get($user); - expect($query->repository($user))->toBe('octocat'); + Http::assertSentCount(3); }); it('shows no readme for a github account that no longer exists', function (): void {