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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion app/Queries/GitHubReadmeQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
Expand Down
17 changes: 10 additions & 7 deletions resources/js/components/profile-readme.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -31,7 +34,7 @@ export function ProfileReadme({
{isCurrentUser ? (
<>
Add a README.md to a GitHub repository named{' '}
<span className="font-mono">{username}</span>, and
<span className="font-mono">{repository}</span>, and
it shows up here.{' '}
<a
href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme"
Expand All @@ -43,7 +46,7 @@ export function ProfileReadme({
</a>
</>
) : (
`@${username} doesn't have a profile README on GitHub.`
`@${handle} doesn't have a profile README on GitHub.`
)}
</EmptyFeed>
</div>
Expand All @@ -58,12 +61,12 @@ export function ProfileReadme({
dangerouslySetInnerHTML={{ __html: readme }}
/>
<a
href={`https://github.com/${username}/${username}`}
href={`https://github.com/${repository}/${repository}`}
target="_blank"
rel="noopener noreferrer"
className="mt-6 block font-mono text-xs text-muted-foreground hover:text-foreground"
>
{username}/README.md on GitHub
{repository}/README.md on GitHub
</a>
</div>
);
Expand Down
13 changes: 11 additions & 2 deletions resources/js/pages/user/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export default function Show({
tab,
pinnedCommit,
readme,
readmeRepository,
}: {
user: Profile;
pasteisReceived: number | null;
Expand All @@ -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;
Expand Down Expand Up @@ -434,7 +436,13 @@ export default function Show({
<PageTabs
label="Profile feed"
preserveScroll
only={['tab', 'commits', 'pinnedCommit', 'readme']}
only={[
'tab',
'commits',
'pinnedCommit',
'readme',
'readmeRepository',
]}
reset={['commits']}
tabs={[
{
Expand All @@ -458,7 +466,8 @@ export default function Show({
{tab === 'readme' ? (
<ProfileReadme
readme={readme}
username={user.handle}
handle={user.handle}
repository={readmeRepository ?? user.handle}
isCurrentUser={isCurrentUser}
/>
) : (
Expand Down
6 changes: 4 additions & 2 deletions tests/Feature/Controllers/UserControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -715,6 +715,7 @@
$this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page
->where('tab', 'readme')
->where('readme', '<div><article><h2>Hi there</h2></article></div>')
->where('readmeRepository', 'octocat')
->where('pinnedCommit', null)
->where('commits.data', []));
});
Expand All @@ -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 {
Expand Down
45 changes: 44 additions & 1 deletion tests/Unit/Queries/GitHubReadmeQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' => ['<img src="https://private-user-images.githubusercontent.com/1/a.png?jwt=x">', 4, true],
'uploaded image, after 3 minutes' => ['<img src="https://private-user-images.githubusercontent.com/1/a.png?jwt=x">', 3, false],
Expand Down Expand Up @@ -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}", '<p>Hi</p>', 86400);
Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat'])]);
$query = resolve(GitHubReadmeQuery::class);

expect($query->get($user))->toBe('<p>Hi</p>')
->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('<p>Hi</p>'));
$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();

Expand Down