From 77230ce1fd9cebe5f5ce9f3cf8646b1bc779eeb8 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Mon, 19 Oct 2026 19:50:35 +0200 Subject: [PATCH 1/7] feat: unclaimed profiles for mentioned developers A developer who isn't on /dev/null yet gets a profile at /@username once an approved commit mentions them: their GitHub name and avatar, how many members follow them on GitHub and are waiting to follow them here, and the commits that mention them. Signing in claims it, turns those mentions into regular ones and brings the waiting followers over. --- KNOWLEDGE_BASE.md | 8 +- PRODUCT.md | 5 +- app/Actions/CreateCommit.php | 2 + app/Actions/CreatePendingFollows.php | 35 +++ app/Actions/CreatePendingMentions.php | 35 +++ app/Actions/CreateUserGitHubFollows.php | 34 ++- app/Actions/UpdateCommitMentions.php | 41 ++++ app/Actions/UpdateOrCreateUser.php | 11 +- .../ImportPendingGitHubFollowsCommand.php | 75 ++++++ app/Dto/GitHubAccount.php | 45 ++++ app/Http/Controllers/UserController.php | 37 ++- app/Http/Requests/ShowUserRequest.php | 46 +++- app/Jobs/UpdateCommitMentionsJob.php | 27 +++ app/Models/Commit.php | 8 + app/Models/PendingFollow.php | 25 ++ app/Models/PendingMention.php | 36 +++ app/Models/User.php | 9 + app/Queries/GitHubAccountQuery.php | 47 ++++ app/Queries/PendingFollowersQuery.php | 34 +++ app/Queries/PendingMentionCommitsQuery.php | 45 ++++ app/Services/PageMetadata.php | 32 +++ database/factories/PendingFollowFactory.php | 26 ++ database/factories/PendingMentionFactory.php | 26 ++ ...9_170000_create_pending_mentions_table.php | 23 ++ ...10_19_170001_backfill_pending_mentions.php | 69 ++++++ ...19_170002_create_pending_follows_table.php | 23 ++ resources/js/components/commit-body.tsx | 9 + resources/js/components/linkified-text.tsx | 22 +- resources/js/pages/user/unclaimed.tsx | 154 ++++++++++++ .../ImportPendingGitHubFollowsTest.php | 98 ++++++++ .../Controllers/UnclaimedProfileTest.php | 227 ++++++++++++++++++ tests/Feature/PendingMentionsBackfillTest.php | 24 ++ .../Unit/Actions/CreatePendingFollowsTest.php | 26 ++ .../Actions/CreatePendingMentionsTest.php | 24 ++ .../Actions/CreateUserGitHubFollowsTest.php | 35 +++ .../Unit/Actions/UpdateCommitMentionsTest.php | 32 +++ tests/Unit/Actions/UpdateOrCreateUserTest.php | 17 ++ tests/Unit/LinkifiedText.test.tsx | 47 +++- 38 files changed, 1501 insertions(+), 18 deletions(-) create mode 100644 app/Actions/CreatePendingFollows.php create mode 100644 app/Actions/CreatePendingMentions.php create mode 100644 app/Actions/UpdateCommitMentions.php create mode 100644 app/Console/Commands/ImportPendingGitHubFollowsCommand.php create mode 100644 app/Dto/GitHubAccount.php create mode 100644 app/Jobs/UpdateCommitMentionsJob.php create mode 100644 app/Models/PendingFollow.php create mode 100644 app/Models/PendingMention.php create mode 100644 app/Queries/GitHubAccountQuery.php create mode 100644 app/Queries/PendingFollowersQuery.php create mode 100644 app/Queries/PendingMentionCommitsQuery.php create mode 100644 database/factories/PendingFollowFactory.php create mode 100644 database/factories/PendingMentionFactory.php create mode 100644 database/migrations/2026_10_19_170000_create_pending_mentions_table.php create mode 100644 database/migrations/2026_10_19_170001_backfill_pending_mentions.php create mode 100644 database/migrations/2026_10_19_170002_create_pending_follows_table.php create mode 100644 resources/js/pages/user/unclaimed.tsx create mode 100644 tests/Feature/Commands/ImportPendingGitHubFollowsTest.php create mode 100644 tests/Feature/Controllers/UnclaimedProfileTest.php create mode 100644 tests/Feature/PendingMentionsBackfillTest.php create mode 100644 tests/Unit/Actions/CreatePendingFollowsTest.php create mode 100644 tests/Unit/Actions/CreatePendingMentionsTest.php create mode 100644 tests/Unit/Actions/UpdateCommitMentionsTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 4ccd3e9b8..99b89953b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -97,9 +97,11 @@ Every developer has a profile at `/@{username}`, with their GitHub identity, bio Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile offers **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. A GitHub username rename is picked up on sign-in; accounts are not merged with another user's account. +A developer who isn't on /dev/null yet gets an **unclaimed profile** at `/@{username}` as soon as an approved commit mentions them. It shows their GitHub name and avatar, how many developers here follow them on GitHub and will follow them here, and the commits and replies that mention them. **I am @username** signs in with GitHub, which only that account can do. Signing in turns those mentions into regular ones and brings the waiting followers over. Usernames nobody mentioned are not found, so nothing is fetched from GitHub for them, and unclaimed profiles are kept out of search engines. + 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), [identity sync](app/Actions/UpdateOrCreateUser.php). +Sources: [profile page](resources/js/pages/user/show.tsx), [unclaimed profile](resources/js/pages/user/unclaimed.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [identity sync](app/Actions/UpdateOrCreateUser.php). ### Achievement badges and XP @@ -129,7 +131,7 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and ### Following developers and suggestions -Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. +Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. The GitHub accounts you follow that aren't on /dev/null yet are remembered, and you follow them when they sign in. **Who to follow** suggests developers in the side column or within the log on smaller screens. Show or hide these suggestions in Settings → Appearance. You can also follow back from the notifications list. @@ -179,7 +181,7 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting and a copy control. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor. -Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In an org's messages, suggestions are limited to the org's members. Mentions link to profiles and can notify the mentioned developer after approval. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. +Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In an org's messages, suggestions are limited to the org's members. Mentions link to profiles and can notify the mentioned developer after approval. A mention of someone who isn't on /dev/null yet links to their unclaimed profile. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). diff --git a/PRODUCT.md b/PRODUCT.md index 53831b1fc..eb380c860 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -30,6 +30,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Each profile's commit activity graph is also public as an SVG at `/@{username}/graph.svg` (`?theme=light|dark|auto`, light by default), for embedding in GitHub profile READMEs. It draws the last year of approved commits with the streaks, in the user's lane, and is cached for an hour. - Two logs: **Following** (my commits plus people I follow) and **Everyone** (all commits), newest first, infinite scroll. - Profiles live at `/@{username}` with commit, following, and follower counts, a commit activity graph with longest and current streaks, and Commits and Replies tabs. Commits is the default and lists the user's top-level commits; `?tab=replies` lists their authored replies with visible parent context. Both feeds use infinite scroll. +- Unclaimed profiles: a developer who isn't on /dev/null yet has a profile at `/@{username}` once an approved commit mentions them (a personal GitHub account, not an organization), with their GitHub name and avatar (looked up on GitHub, cached for a day), how many developers here follow them on GitHub and are waiting to follow them here, and a Mentions tab listing the commits and replies that mention them. Guests see "I am @{username}", which signs in with GitHub; only that GitHub account can claim it. Its link preview is not indexed. Any other unknown username is a 404, so GitHub is never crawled: only the mentions and the GitHub follows of people who signed in are stored, never profiles. Signing in turns those mentions into regular mentions and has the developers who were waiting follow the new user. - Operations (`/operations`, admins only; a 404 for everyone else), in tabs like Settings. Health: database and cache connectivity, the GitHub API rate limit when GitHub credentials are configured, the database queue backlog per queue with the age of its oldest job, the most recent failed jobs (each can be retried or forgotten), pending moderation and link previews, and link previews that came back without metadata in the last 24 hours. Features: site-wide feature switches. Activity: today's signups, commits, replies, and active authors next to yesterday's, the same four counts since the first signup, an activity graph of everyone's commits, replies, or signups over the last 24 hours, 7 days, 30 days, or year, and the same window's commits and replies by moderation status and by their ten busiest authors. Traffic, when a Fathom token is configured: visitors over a chosen range. Every tab refreshes every 15 seconds. - Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), appearance (light/dark/system), delete account. - Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), devices (the mobile app on each device the user approved, when it was last used, and revoking it), delete account. @@ -39,11 +40,11 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Commits (posts): plain text, 3–4,000 characters; create and delete only (no editing). Rate limited to 10 commits per minute per user. - Follow/unfollow, idempotent; self-follow rejected. - World: the account @world replies "Hello, {name}." when an approved commit's whole text is hello world, including "Hello, World!". A pending one is answered once it is approved, and only once. @world does not sign in. -- GitHub follows come over once, on first sign-in: you follow the developers you follow on GitHub, and the ones following you there follow you (up to 1,000 each way), with the usual follow notifications. It only ever adds follows, so unfollowing someone sticks. `php artisan follows:import` does the same, silently, for everyone not imported yet. +- GitHub follows come over once, on first sign-in: you follow the developers you follow on GitHub, and the ones following you there follow you (up to 1,000 each way), with the usual follow notifications. It only ever adds follows, so unfollowing someone sticks. `php artisan follows:import` does the same, silently, for everyone not imported yet. The GitHub accounts you follow that aren't on /dev/null yet are remembered, so you follow them as soon as they sign in, beyond the first 1,000 of their followers. `php artisan follows:pending` remembers them for everyone imported before (`--after={user id}` resumes a run GitHub rate limited). - Replies: any visible commit can be replied to; a reply is itself a commit (same 4,000 limit, moderation, and rate limit). Threads expand inline under a commit; logs and the profile's Commits tab list top-level commits only, while the profile's Replies tab lists that user's replies. Every commit has its own page with its ancestors above it and its replies expanded; it is behind login like everything else. When its author replied to it themselves, their earliest such reply continues it, and so on: the page reads that thread in order under the commit, 10 commits at a time with "Show more of this thread", each marked with its place (`3/7`, with a `+` when more than 100 commits follow). The next commit of the thread is left out of the replies and their count, and the commit's other replies stay folded until asked for. It stops at the first reply the viewer can't see, or where the author has no further reply to themselves (deleted ones don't count), and none of it shows if the viewer muted the author. Elsewhere (logs, profiles, Saved, search, and nested replies), a commit its author continued this way shows "Show thread", which opens its page. Deleting a commit removes it from logs, profiles, counts, and its own page, but never takes other people's replies with it: while it still has visible replies it stays in its thread as a `[deleted]` tombstone with no author or body, and it can no longer be replied to. Its author can undo a deletion for 30 seconds, which brings it back exactly as it was, without notifying anyone again. Deleting an account erases that user's commits and replies for good, along with every reply under their commits; replies other people left under their replies survive, under the same authorless `[deleted]` tombstone. - Quotes: any visible commit can be quoted. A quote is a new top-level commit (same 4,000 limit, moderation, and rate limit) with the quoted commit embedded under its body as a compact card that opens the quoted commit's page; a reply cannot also quote. The embed only ever shows what the viewer can see: a deleted quoted commit leaves a `[deleted]` tombstone, one the viewer can no longer see (pending or rejected) reads `[unavailable]` with no author or body, and erasing an account removes embeds of erased commits, while approved quoted replies retained as authorless thread tombstones still read `[deleted]`. Quotes have no count. - Stars: anyone who can see a commit can star and unstar it. Starring is idempotent. The count sits on the commit. A star is the engagement action, named the way GitHub names it, and it is not a git ref. -- Mentions: `@username` in a commit or reply mentions that developer and links to their profile. Typing `@` in either composer suggests developers (people you follow on a bare `@`, then matches by name or username, people you follow first); ↑↓ to move, ↵ or Tab to pick, Esc to dismiss. Any number of people can be mentioned in one commit. Emails, scoped packages (`@inertiajs/react`) and domains (`@vue.js`) are not mentions. +- Mentions: `@username` in a commit or reply mentions that developer and links to their profile. Typing `@` in either composer suggests developers (people you follow on a bare `@`, then matches by name or username, people you follow first); ↑↓ to move, ↵ or Tab to pick, Esc to dismiss. Any number of people can be mentioned in one commit. Emails, scoped packages (`@inertiajs/react`) and domains (`@vue.js`) are not mentions. A mention of someone who isn't on /dev/null yet links to their unclaimed profile. - Reports: anyone who can see an approved commit or reply that isn't theirs can report it privately, with a reason and optional details (up to 500 characters). One report per commit per person; reporting is rate limited and never changes the commit's visibility. Moderators review open reports oldest first without the reporter's identity, and either dismiss a report to keep the commit or reject the commit, which clears its reports from the queue. Deleting a commit removes its reports. - Preview: both composers toggle between writing and a preview of the draft rendered exactly as the posted commit (code highlighting, inline code, emoji, mentions, links, attached images), with the URL that will get the link card marked. The eye button or ⌘/Ctrl+Shift+P toggles it; Esc in the preview goes back to writing. - Link cards: a commit or reply with links shows a card (title, description, image) for one of them. The first link gets it by default; in either composer the author can pick another link from the body or no card. Commits from the terminal always use the first link. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index 4a265ccf2..faf82e493 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -27,6 +27,7 @@ public function __construct( private CreateQuoteNotifications $createQuoteNotifications, private CreateMentionNotifications $createMentionNotifications, private CreateCommitImages $createCommitImages, + private CreatePendingMentions $createPendingMentions, private CreateWorldReply $createWorldReply, private UpdateCommitHotScores $updateCommitHotScores, private TheAlgorithm $theAlgorithm, @@ -69,6 +70,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit ]); $this->createCommitImages->handle($commit, $images, $imageDescriptions); + $this->createPendingMentions->handle($commit); if ($poll instanceof NewPoll) { $this->createPoll($commit, $poll); diff --git a/app/Actions/CreatePendingFollows.php b/app/Actions/CreatePendingFollows.php new file mode 100644 index 000000000..97e8ad10b --- /dev/null +++ b/app/Actions/CreatePendingFollows.php @@ -0,0 +1,35 @@ + $githubIds + */ + public function handle(User $user, array $githubIds): void + { + $githubIds = array_values(array_diff( + $githubIds, + User::query()->whereIn('github_id', $githubIds)->get(['github_id'])->map(fn (User $user): string => $user->github_id)->all(), + )); + + if ($githubIds === []) { + return; + } + + $now = now(); + + $user->pendingFollows()->insertOrIgnore(array_map( + fn (string $githubId): array => ['user_id' => $user->id, 'github_id' => $githubId, 'created_at' => $now, 'updated_at' => $now], + $githubIds, + )); + } +} diff --git a/app/Actions/CreatePendingMentions.php b/app/Actions/CreatePendingMentions.php new file mode 100644 index 000000000..7f7bbbde1 --- /dev/null +++ b/app/Actions/CreatePendingMentions.php @@ -0,0 +1,35 @@ +mentions->usernames($commit->body), array_keys($commit->mentions ?? [])); + + if ($usernames === []) { + return; + } + + $now = now(); + + $commit->pendingMentions()->insertOrIgnore(array_map( + fn (string $username): array => ['commit_id' => $commit->id, 'username' => $username, 'created_at' => $now, 'updated_at' => $now], + array_values($usernames), + )); + } +} diff --git a/app/Actions/CreateUserGitHubFollows.php b/app/Actions/CreateUserGitHubFollows.php index 49ce0cf98..4b2bdeadb 100644 --- a/app/Actions/CreateUserGitHubFollows.php +++ b/app/Actions/CreateUserGitHubFollows.php @@ -4,6 +4,7 @@ namespace App\Actions; +use App\Models\PendingFollow; use App\Models\User; use App\Services\GitHub; use Illuminate\Database\Eloquent\Collection; @@ -18,11 +19,12 @@ /** * Up to 1,000 accounts each way, so a popular account can't use up the rate limit on its own. */ - private const int MaxPages = 10; + public const int MaxPages = 10; public function __construct( private GitHub $github, private CreateFollowNotification $createFollowNotification, + private CreatePendingFollows $createPendingFollows, ) { // } @@ -41,10 +43,12 @@ public function handle(User $user, bool $notify = true): void return; } - $following = $this->users($user, "users/{$login}/following"); - $followers = $this->users($user, "users/{$login}/followers"); + $followingIds = $this->github->accountIds("users/{$login}/following", self::MaxPages); + $following = $this->users($user, $followingIds); + $followers = $this->users($user, $this->github->accountIds("users/{$login}/followers", self::MaxPages)) + ->merge($this->waitingFollowers($user)); - DB::transaction(function () use ($user, $following, $followers, $notify): void { + DB::transaction(function () use ($user, $following, $followers, $followingIds, $notify): void { $followedIds = $user->following()->syncWithoutDetaching($following->modelKeys())['attached']; $followerIds = $user->followers()->syncWithoutDetaching($followers->modelKeys())['attached']; @@ -58,17 +62,35 @@ public function handle(User $user, bool $notify = true): void } } + $this->createPendingFollows->handle($user, $followingIds); + PendingFollow::query()->where('github_id', $user->github_id)->delete(); + $user->update(['github_follows_imported_at' => now()]); }); } /** + * @param list $githubIds + * @return Collection + */ + private function users(User $user, array $githubIds): Collection + { + return User::query() + ->whereIn('github_id', $githubIds) + ->whereKeyNot($user->id) + ->whereNotIn('id', $user->idsAcrossBlocks()) + ->get(); + } + + /** + * Who followed the user on GitHub before they signed in, including those past the 1,000 the import reads. + * * @return Collection */ - private function users(User $user, string $path): Collection + private function waitingFollowers(User $user): Collection { return User::query() - ->whereIn('github_id', $this->github->accountIds($path, self::MaxPages)) + ->whereIn('id', PendingFollow::query()->where('github_id', $user->github_id)->select('user_id')) ->whereKeyNot($user->id) ->whereNotIn('id', $user->idsAcrossBlocks()) ->get(); diff --git a/app/Actions/UpdateCommitMentions.php b/app/Actions/UpdateCommitMentions.php new file mode 100644 index 000000000..46faa9b09 --- /dev/null +++ b/app/Actions/UpdateCommitMentions.php @@ -0,0 +1,41 @@ +username); + + PendingMention::query() + ->where('username', $username) + ->with(['commit' => fn (BelongsTo $query): BelongsTo => $query->withTrashed()]) + ->chunkById(200, function (Collection $pending) use ($username, $user): void { + DB::transaction(function () use ($pending, $username, $user): void { + foreach ($pending as $mention) { + $commit = $mention->commit; + $commit->timestamps = false; + + $commit->update(['mentions' => [...$commit->mentions ?? [], $username => $user->id]]); + } + + PendingMention::query()->whereKey($pending->modelKeys())->delete(); + }); + }); + } +} diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index a8126e3be..48cfef4f5 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -5,11 +5,14 @@ namespace App\Actions; use App\Dto\GitHubProfile; +use App\Jobs\UpdateCommitMentionsJob; +use App\Models\PendingMention; use App\Models\User; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Laravel\Socialite\Two\User as GitHubUser; final readonly class UpdateOrCreateUser @@ -30,7 +33,7 @@ public function handle(GitHubUser $githubUser): User return DB::transaction(function () use ($githubId, $profile, $email, $hasSponsorsListing): User { $this->updateStaleUsernames->handle($profile->username, $githubId); - return User::query()->updateOrCreate([ + $user = User::query()->updateOrCreate([ 'github_id' => $githubId, ], [ ...$profile->attributes(), @@ -38,6 +41,12 @@ public function handle(GitHubUser $githubUser): User 'github_synced_at' => now(), ...($hasSponsorsListing === null ? [] : ['has_sponsors_listing' => $hasSponsorsListing]), ]); + + if (PendingMention::query()->where('username', Str::lower($user->username))->exists()) { + dispatch(new UpdateCommitMentionsJob($user))->afterCommit(); + } + + return $user; }); } diff --git a/app/Console/Commands/ImportPendingGitHubFollowsCommand.php b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php new file mode 100644 index 000000000..52346c1d2 --- /dev/null +++ b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php @@ -0,0 +1,75 @@ +option('after'); + + $users = User::query() + ->whereNotNull('github_follows_imported_at') + ->when(is_string($after), fn (Builder $query): Builder => $query->where('id', '>', $after)); + + foreach ($users->lazyById() as $user) { + try { + $login = $github->login($user->github_id); + + if ($login !== null) { + $createPendingFollows->handle($user, $github->accountIds("users/{$login}/following", CreateUserGitHubFollows::MaxPages)); + } + + $imported++; + } catch (RequestException $exception) { + if (in_array($exception->response->status(), [403, 429], true)) { + $resume = $lastId === null ? '' : " with --after={$lastId}"; + + $this->components->error("GitHub rate limit reached after {$imported} users. Run the command again{$resume} later."); + + return self::FAILURE; + } + + $this->failed($user, $exception); + + $failed++; + } catch (ConnectionException $exception) { + $this->failed($user, $exception); + + $failed++; + } + + $lastId = $user->id; + } + + $this->components->info("Remembered the pending GitHub follows of {$imported} users."); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function failed(User $user, RequestException|ConnectionException $exception): void + { + report($exception); + + $this->components->warn("Could not read whom @{$user->username} follows on GitHub."); + } +} diff --git a/app/Dto/GitHubAccount.php b/app/Dto/GitHubAccount.php new file mode 100644 index 000000000..a5174915f --- /dev/null +++ b/app/Dto/GitHubAccount.php @@ -0,0 +1,45 @@ + $account + */ + public static function fromAccount(array $account): self + { + $id = Arr::get($account, 'id'); + assert(is_int($id)); + + return new self((string) $id, GitHubProfile::fromAccount($account)); + } + + /** + * @return array{username: string, name: string, avatar_url: string|null} + */ + public function toArray(): array + { + return [ + 'username' => $this->profile->username, + 'name' => $this->profile->name, + 'avatar_url' => $this->profile->avatarUrl, + ]; + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b3b81496b..afe8589c5 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -5,11 +5,14 @@ namespace App\Http\Controllers; use App\Actions\DeleteUser; +use App\Dto\GitHubAccount; use App\Enums\PushAccess; use App\Http\Requests\DeleteUserRequest; use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\PendingFollowersQuery; +use App\Queries\PendingMentionCommitsQuery; use App\Queries\PinnedCommitQuery; use App\Queries\PushAccessQuery; use App\Queries\UserAchievementsQuery; @@ -26,10 +29,17 @@ final readonly class UserController { /** - * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. + * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. A developer who + * isn't on /dev/null yet has one too, waiting to be claimed, once someone mentions them. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, PendingMentionCommitsQuery $mentions, PendingFollowersQuery $pendingFollowers, PageMetadata $metadata): Response|RedirectResponse { + $user = $request->profile(); + + if (! $user instanceof User) { + return $this->unclaimed($request, $request->account(), $currentUser, $mentions, $pendingFollowers, $metadata); + } + $blockerId = $currentUser?->blockerIdWith($user); if ($blockerId !== null) { @@ -80,4 +90,27 @@ public function destroy(DeleteUserRequest $request, #[CurrentUser] User $user, D return to_route('home'); } + + /** + * A GitHub account renamed since its owner last signed in still has their profile, under its old username. + */ + private function unclaimed(ShowUserRequest $request, GitHubAccount $account, ?User $currentUser, PendingMentionCommitsQuery $mentions, PendingFollowersQuery $pendingFollowers, PageMetadata $metadata): Response|RedirectResponse + { + $owner = User::query()->where('github_id', $account->id)->first(); + + if ($owner instanceof User) { + return to_route('user.show', $owner); + } + + $waiting = null; + $getWaiting = function () use (&$waiting, $pendingFollowers, $account): array { + return $waiting ??= $pendingFollowers->get($account->id); + }; + + return Inertia::render('user/unclaimed', [ + 'account' => $account->toArray(), + 'waiting' => $getWaiting, + 'commits' => Inertia::scroll(fn () => $mentions->builder($account->profile->username, $currentUser)->cursorPaginate(20)), + ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->unclaimed($account, $getWaiting()['count'])]); + } } diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php index faf0eee49..2b14467b0 100644 --- a/app/Http/Requests/ShowUserRequest.php +++ b/app/Http/Requests/ShowUserRequest.php @@ -4,13 +4,37 @@ namespace App\Http\Requests; +use App\Dto\GitHubAccount; +use App\Models\User; +use App\Queries\GitHubAccountQuery; +use App\Queries\PendingMentionCommitsQuery; +use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ShowUserRequest extends FormRequest { - public function authorize(): bool + private ?User $profile = null; + + private ?GitHubAccount $account = null; + + /** + * A username nobody on /dev/null has still has a profile, waiting to be claimed, once a commit anyone may see + * mentions it and GitHub knows the account. Any other username is not found. + */ + public function authorize(PendingMentionCommitsQuery $mentions, GitHubAccountQuery $accounts): Response { - return true; + $username = $this->route('user'); + assert(is_string($username)); + + $this->profile = User::query()->where('username', $username)->first(); + + if ($this->profile instanceof User) { + return Response::allow(); + } + + $this->account = $mentions->exists($username) ? $accounts->get($username) : null; + + return $this->account instanceof GitHubAccount ? Response::allow() : Response::denyAsNotFound(); } /** @@ -27,4 +51,22 @@ public function tab(): string { return $this->input('tab') === 'replies' ? 'replies' : 'commits'; } + + /** + * The user whose profile it is, or null when the profile is waiting to be claimed. + */ + public function profile(): ?User + { + return $this->profile; + } + + /** + * The GitHub account of a profile waiting to be claimed. + */ + public function account(): GitHubAccount + { + assert($this->account instanceof GitHubAccount); + + return $this->account; + } } diff --git a/app/Jobs/UpdateCommitMentionsJob.php b/app/Jobs/UpdateCommitMentionsJob.php new file mode 100644 index 000000000..1af14f6c2 --- /dev/null +++ b/app/Jobs/UpdateCommitMentionsJob.php @@ -0,0 +1,27 @@ +handle($this->user); + } +} diff --git a/app/Models/Commit.php b/app/Models/Commit.php index 0c0dd2bf6..f6baae395 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -258,6 +258,14 @@ public function images(): HasMany return $this->hasMany(CommitImage::class)->orderBy('position'); } + /** + * @return HasMany + */ + public function pendingMentions(): HasMany + { + return $this->hasMany(PendingMention::class); + } + /** * Images follow the commit's visibility, and moderators see them to review the commit. */ diff --git a/app/Models/PendingFollow.php b/app/Models/PendingFollow.php new file mode 100644 index 000000000..be0b4e580 --- /dev/null +++ b/app/Models/PendingFollow.php @@ -0,0 +1,25 @@ + */ + use HasFactory; +} diff --git a/app/Models/PendingMention.php b/app/Models/PendingMention.php new file mode 100644 index 000000000..71cbe4fb6 --- /dev/null +++ b/app/Models/PendingMention.php @@ -0,0 +1,36 @@ + */ + use HasFactory; + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 6b3a5d984..33084a240 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,6 +61,7 @@ * @property-read Collection $sshKeys * @property-read Collection $pollVotes * @property-read Collection $devices + * @property-read Collection $pendingFollows * @property-read int $commits_count * @property-read int $following_count * @property-read int $followers_count @@ -153,6 +154,14 @@ public function commits(): HasMany return $this->hasMany(Commit::class); } + /** + * @return HasMany + */ + public function pendingFollows(): HasMany + { + return $this->hasMany(PendingFollow::class); + } + /** * Reports keep an author in moderation for seven days, even if the reported commit is deleted. */ diff --git a/app/Queries/GitHubAccountQuery.php b/app/Queries/GitHubAccountQuery.php new file mode 100644 index 000000000..bee515326 --- /dev/null +++ b/app/Queries/GitHubAccountQuery.php @@ -0,0 +1,47 @@ +|false|null $account */ + $account = Cache::remember('github:account:'.Str::lower($username), 86400, function () use ($username): array|false|null { + try { + $response = $this->github->request()->get('users/'.rawurlencode($username)); + + if ($response->notFound()) { + return false; + } + + $account = $response->throw()->json(); + } catch (HttpClientException) { + return null; + } + + return is_array($account) && is_int($account['id'] ?? null) && is_string($account['login'] ?? null) && ($account['type'] ?? null) === 'User' ? $account : false; + }); + + return is_array($account) ? GitHubAccount::fromAccount($account) : null; + } +} diff --git a/app/Queries/PendingFollowersQuery.php b/app/Queries/PendingFollowersQuery.php new file mode 100644 index 000000000..316f5aecd --- /dev/null +++ b/app/Queries/PendingFollowersQuery.php @@ -0,0 +1,34 @@ +} + */ + public function get(string $githubId): array + { + return [ + 'count' => PendingFollow::query()->where('github_id', $githubId)->count(), + 'users' => User::query() + ->select('users.*') + ->join('pending_follows', 'pending_follows.user_id', '=', 'users.id') + ->where('pending_follows.github_id', $githubId) + ->orderByDesc('pending_follows.id') + ->limit(self::Shown) + ->get(), + ]; + } +} diff --git a/app/Queries/PendingMentionCommitsQuery.php b/app/Queries/PendingMentionCommitsQuery.php new file mode 100644 index 000000000..f61dee831 --- /dev/null +++ b/app/Queries/PendingMentionCommitsQuery.php @@ -0,0 +1,45 @@ +mentioning($username)->visibleTo(null)->exists(); + } + + /** + * The commits and replies that mention a username nobody on /dev/null has, as the viewer, or a guest, may see them, + * the replies with the parents the viewer may see. + */ + public function builder(string $username, ?User $viewer): CommitBuilder + { + return $this->mentioning($username) + ->visibleTo($viewer) + ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) + ->with('user')]) + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->latest() + ->latest('id'); + } + + private function mentioning(string $username): CommitBuilder + { + return Commit::query()->whereHas('pendingMentions', fn (Builder $query): Builder => $query->where('username', Str::lower($username))); + } +} diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 5b8f2e4f6..1fba1ebb6 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -4,6 +4,7 @@ namespace App\Services; +use App\Dto\GitHubAccount; use App\Enums\CommitStatus; use App\Models\Commit; use App\Models\LinkPreview; @@ -240,6 +241,37 @@ public function user(User $user): array ]; } + /** + * The link preview of a profile waiting to be claimed. It isn't indexed, since its owner never asked for it. + * + * @return array{type: string, title: string, description: string, url: string, robots: string, images: list, username: string} + */ + public function unclaimed(GitHubAccount $account, int $waiting): array + { + $username = $account->profile->username; + $title = $account->profile->name === $username ? "@{$username} on /dev/null" : "{$account->profile->name} (@{$username}) on /dev/null"; + + return [ + 'type' => 'profile', + 'title' => $title, + 'description' => match ($waiting) { + 0 => "@{$username} is not on /dev/null yet, but developers here are mentioning them.", + 1 => "@{$username} is not on /dev/null yet. 1 developer is waiting to follow.", + default => "@{$username} is not on /dev/null yet. {$waiting} developers are waiting to follow.", + }, + 'url' => route('user.show', ['user' => $username]), + 'robots' => self::NOT_INDEXABLE, + 'images' => [[ + 'url' => url('/og.png'), + 'width' => OpenGraphCanvas::WIDTH, + 'height' => OpenGraphCanvas::HEIGHT, + 'type' => 'image/png', + 'alt' => $title, + ]], + 'username' => $username, + ]; + } + /** * The link preview of who follows a profile, or who it follows. The lists are thin pages, so they are not indexed, though their links are followed. * diff --git a/database/factories/PendingFollowFactory.php b/database/factories/PendingFollowFactory.php new file mode 100644 index 000000000..5d36bbba0 --- /dev/null +++ b/database/factories/PendingFollowFactory.php @@ -0,0 +1,26 @@ + + */ +final class PendingFollowFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'github_id' => (string) fake()->unique()->numberBetween(1_000_000, 9_999_999), + ]; + } +} diff --git a/database/factories/PendingMentionFactory.php b/database/factories/PendingMentionFactory.php new file mode 100644 index 000000000..53dcc173a --- /dev/null +++ b/database/factories/PendingMentionFactory.php @@ -0,0 +1,26 @@ + + */ +final class PendingMentionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'commit_id' => Commit::factory(), + 'username' => mb_strtolower(fake()->unique()->userName()), + ]; + } +} diff --git a/database/migrations/2026_10_19_170000_create_pending_mentions_table.php b/database/migrations/2026_10_19_170000_create_pending_mentions_table.php new file mode 100644 index 000000000..7fa6c11a9 --- /dev/null +++ b/database/migrations/2026_10_19_170000_create_pending_mentions_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignUuid('commit_id')->constrained()->cascadeOnDelete(); + $table->string('username'); + $table->timestamps(); + + $table->unique(['commit_id', 'username']); + $table->index('username'); + }); + } +}; diff --git a/database/migrations/2026_10_19_170001_backfill_pending_mentions.php b/database/migrations/2026_10_19_170001_backfill_pending_mentions.php new file mode 100644 index 000000000..c834b7c45 --- /dev/null +++ b/database/migrations/2026_10_19_170001_backfill_pending_mentions.php @@ -0,0 +1,69 @@ +select(['id', 'body', 'mentions']) + ->chunkById(200, function (Collection $commits): void { + /** @var Collection $commits */ + $usernamesByCommit = $commits->mapWithKeys(fn (object $commit): array => [ + $commit->id => array_values(array_diff($this->usernames($commit->body), $this->captured($commit->mentions))), + ]); + + /** @var Collection $users */ + $users = DB::table('users') + ->whereIn(DB::raw('LOWER(username)'), $usernamesByCommit->flatten()->unique()->all()) + ->get(['username']); + + $registered = $users->map(fn (object $user): string => mb_strtolower($user->username))->all(); + + $now = now(); + + DB::table('pending_mentions')->insertOrIgnore($usernamesByCommit + ->flatMap(fn (array $usernames, string $id): array => array_map( + fn (string $username): array => ['commit_id' => $id, 'username' => $username, 'created_at' => $now, 'updated_at' => $now], + array_values(array_diff($usernames, $registered)), + )) + ->all()); + }); + } + + /** + * The usernames the commit already mentions someone by, even someone who has since deleted their account, who + * shouldn't get a profile to claim back. + * + * @return list + */ + private function captured(?string $mentions): array + { + $identities = $mentions === null ? [] : json_decode($mentions, true, flags: JSON_THROW_ON_ERROR); + + return is_array($identities) ? array_map(strval(...), array_keys($identities)) : []; + } + + /** + * Freeze the parser here so future application changes cannot change this backfill. + * + * @return list + */ + private function usernames(string $body): array + { + $prose = (string) preg_replace('/((?id(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('github_id'); + $table->timestamps(); + + $table->unique(['user_id', 'github_id']); + $table->index(['github_id', 'id']); + }); + } +}; diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index aa0eded12..5b093ebd4 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -11,11 +11,17 @@ export function CommitBody({ mentions, className, isStatic = false, + linksUnclaimed = false, }: { segments: BodySegment[]; mentions: Commit['mentions']; className?: string; isStatic?: boolean; + /** + * Whether mentions of developers who aren't on /dev/null link to their unclaimed profile, which only posted + * commits give them. + */ + linksUnclaimed?: boolean; }) { const { auth } = usePage().props; @@ -37,6 +43,7 @@ export function CommitBody({ mentions={mentions} pasteisMode={Boolean(auth.pasteis)} isStatic={isStatic} + linksUnclaimed={linksUnclaimed} /> ); case 'code': @@ -86,6 +93,7 @@ export function FoldedCommitBody({ segments={segments} mentions={mentions} className={className} + linksUnclaimed /> ); } @@ -112,6 +120,7 @@ export function FoldedCommitBody({ segments={isUnfolded ? segments : previewSegments} mentions={mentions} className={className} + linksUnclaimed /> {!isUnfolded && ( + )} + + + +

+ {name} +

+

+ + @{username} + + Not here yet + +

+ + + + {waiting.count > 0 && ( +
+ + {waiting.users.map((user, position) => ( + + ))} + {waiting.count > waiting.users.length && ( + + +{waiting.count - waiting.users.length} + + )} + + + + + {waiting.count} + {' '} + {waiting.count === 1 + ? 'developer is waiting to follow' + : 'developers are waiting to follow'} + + + They’ll follow as soon as @{username} signs in. + + +
+ )} + + + + + + The commits mentioning @{username} aren't visible to + you. + + } + /> + + ); +} + +Unclaimed.layout = AppLayout; diff --git a/tests/Feature/Commands/ImportPendingGitHubFollowsTest.php b/tests/Feature/Commands/ImportPendingGitHubFollowsTest.php new file mode 100644 index 000000000..05865fcff --- /dev/null +++ b/tests/Feature/Commands/ImportPendingGitHubFollowsTest.php @@ -0,0 +1,98 @@ +create(['github_follows_imported_at' => now()]); + $bob = User::factory()->create(['github_follows_imported_at' => now()]); + $notImported = User::factory()->create(); + + fakeGitHubFollows($alice, following: [$bob, User::factory()->make(['github_id' => '111'])]); + fakeGitHubFollows($bob, following: [User::factory()->make(['github_id' => '222'])]); + + $this->artisan('follows:pending') + ->expectsOutputToContain('Remembered the pending GitHub follows of 2 users.') + ->assertSuccessful(); + + expect($alice->pendingFollows()->pluck('github_id')->all())->toBe(['111']) + ->and($bob->pendingFollows()->pluck('github_id')->all())->toBe(['222']) + ->and(PendingFollow::query()->count())->toBe(2); + + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), "user/{$notImported->github_id}")); +}); + +it('skips users whose github account no longer exists', function (): void { + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(['message' => 'Not Found'], 404)]); + + $this->artisan('follows:pending') + ->expectsOutputToContain('Remembered the pending GitHub follows of 1 users.') + ->assertSuccessful(); + + expect(PendingFollow::query()->count())->toBe(0); +}); + +it('only reads the users after the given one', function (): void { + $first = User::factory()->create(['github_follows_imported_at' => now()]); + $second = User::factory()->create(['github_follows_imported_at' => now()]); + + fakeGitHubFollows($second, following: [User::factory()->make(['github_id' => '222'])]); + + $this->artisan('follows:pending', ['--after' => $first->id]) + ->expectsOutputToContain('Remembered the pending GitHub follows of 1 users.') + ->assertSuccessful(); + + expect($second->pendingFollows()->pluck('github_id')->all())->toBe(['222']); +}); + +it('keeps going when whom a user follows cannot be read', function (string $exception, Closure $response): void { + Exceptions::fake(); + + $failing = User::factory()->create(['username' => 'failing', 'github_follows_imported_at' => now()]); + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + Http::fake(["api.github.com/user/{$failing->github_id}" => $response]); + fakeGitHubFollows($user, following: [User::factory()->make(['github_id' => '222'])]); + + $this->artisan('follows:pending') + ->expectsOutputToContain('Could not read whom @failing follows on GitHub.') + ->expectsOutputToContain('Remembered the pending GitHub follows of 1 users.') + ->assertFailed(); + + expect($user->pendingFollows()->count())->toBe(1); + + Exceptions::assertReported($exception); +})->with([ + 'server error' => [RequestException::class, fn (): Closure => fn () => Http::response(['message' => 'Server Error'], 500)], + 'connection error' => [ConnectionException::class, fn (): Closure => fn () => Http::failedConnection()], +]); + +it('stops when github rate limits the requests, saying where to resume', function (): void { + $first = User::factory()->create(['github_follows_imported_at' => now()]); + $second = User::factory()->create(['github_follows_imported_at' => now()]); + + fakeGitHubFollows($first); + Http::fake(["api.github.com/user/{$second->github_id}" => Http::response(['message' => 'API rate limit exceeded'], 403)]); + + $this->artisan('follows:pending') + ->expectsOutputToContain("GitHub rate limit reached after 1 users. Run the command again with --after={$first->id} later.") + ->assertFailed(); +}); + +it('stops when github rate limits the first request', function (): void { + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(['message' => 'API rate limit exceeded'], 429)]); + + $this->artisan('follows:pending') + ->expectsOutputToContain('GitHub rate limit reached after 0 users. Run the command again later.') + ->assertFailed(); +}); diff --git a/tests/Feature/Controllers/UnclaimedProfileTest.php b/tests/Feature/Controllers/UnclaimedProfileTest.php new file mode 100644 index 000000000..d473e8117 --- /dev/null +++ b/tests/Feature/Controllers/UnclaimedProfileTest.php @@ -0,0 +1,227 @@ + Http::response([ + 'id' => $id, + 'login' => $login, + 'type' => 'User', + 'name' => $name, + 'avatar_url' => "https://avatars.githubusercontent.com/u/{$id}?v=4", + ]), + ]); +} + +function mentionOf(string $username, ?Commit $commit = null): Commit +{ + $commit ??= Commit::factory()->create(['body' => "Thanks @{$username}!"]); + + PendingMention::factory()->for($commit)->create(['username' => mb_strtolower($username)]); + + return $commit; +} + +it('renders the profile of a mentioned developer who is not on devnull yet', function (): void { + fakeUnclaimedAccount(); + + $older = mentionOf('ondrejmirtes', Commit::factory()->create(['created_at' => now()->subDay(), 'body' => 'Older @ondrejmirtes'])); + $newer = mentionOf('ondrejmirtes', Commit::factory()->create(['body' => 'Newer @ondrejmirtes'])); + mentionOf('someoneelse'); + + $first = PendingFollow::factory()->create(['github_id' => '104888']); + $second = PendingFollow::factory()->create(['github_id' => '104888']); + PendingFollow::factory()->create(); + + $this->get('/@ondrejmirtes') + ->assertOk() + ->assertSee('', false) + ->assertSee('Ondřej Mirtes (@ondrejmirtes) on /dev/null', false) + ->assertSee('@ondrejmirtes is not on /dev/null yet. 2 developers are waiting to follow.', false) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('user/unclaimed') + ->where('account', [ + 'username' => 'ondrejmirtes', + 'name' => 'Ondřej Mirtes', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/104888?v=4', + ]) + ->where('waiting.count', 2) + ->has('waiting.users', 2) + ->where('waiting.users.0.id', $second->user_id) + ->where('waiting.users.1.id', $first->user_id) + ->missing('waiting.users.0.email') + ->missing('waiting.users.0.github_id') + ->has('commits.data', 2) + ->where('commits.data.0.id', $newer->id) + ->where('commits.data.1.id', $older->id)); +}); + +it('finds the profile whatever the case of the username', function (): void { + fakeUnclaimedAccount('OndrejMirtes'); + + mentionOf('ondrejmirtes'); + + $this->get('/@OndrejMirtes') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('user/unclaimed') + ->where('account.username', 'OndrejMirtes') + ->has('commits.data', 1)); +}); + +it('lists the replies that mention the developer with their parent', function (): void { + fakeUnclaimedAccount(); + + $parent = Commit::factory()->create(); + $reply = mentionOf('ondrejmirtes', Commit::factory()->for($parent, 'parent')->create(['body' => 'cc @ondrejmirtes'])); + + $this->get('/@ondrejmirtes') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('commits.data.0.id', $reply->id) + ->where('commits.data.0.parent.id', $parent->id)); +}); + +it('shows the mentions as the viewer may see them, without a link preview', function (): void { + fakeUnclaimedAccount(); + + $viewer = User::factory()->create(); + $pending = mentionOf('ondrejmirtes', Commit::factory()->for($viewer)->pending()->create(['body' => 'Mine @ondrejmirtes'])); + mentionOf('ondrejmirtes', Commit::factory()->create(['created_at' => now()->subMinute()])); + mentionOf('ondrejmirtes', Commit::factory()->pending()->create()); + + $this->actingAs($viewer) + ->get('/@ondrejmirtes') + ->assertOk() + ->assertDontSee('assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('user/unclaimed') + ->where('waiting', ['count' => 0, 'users' => []]) + ->has('commits.data', 2) + ->where('commits.data.0.id', $pending->id)); +}); + +it('describes a profile only mentioned, or awaited by one developer, in its link preview', function (int $waiting, string $description): void { + fakeUnclaimedAccount(name: null); + + mentionOf('ondrejmirtes'); + PendingFollow::factory()->count($waiting)->create(['github_id' => '104888']); + + $this->get('/@ondrejmirtes') + ->assertOk() + ->assertSee('', false) + ->assertSee(e($description), false); +})->with([ + [0, '@ondrejmirtes is not on /dev/null yet, but developers here are mentioning them.'], + [1, '@ondrejmirtes is not on /dev/null yet. 1 developer is waiting to follow.'], +]); + +it('is not found until a commit anyone may see mentions the username', function (): void { + fakeUnclaimedAccount(); + + mentionOf('ondrejmirtes', Commit::factory()->pending()->create()); + mentionOf('ondrejmirtes', Commit::factory()->trashed()->create()); + + $this->get('/@ondrejmirtes')->assertNotFound(); + $this->get('/@nobody')->assertNotFound(); + + Http::assertNothingSent(); +}); + +it('is not found when github has no such account, which it remembers', function (): void { + Http::fake(['api.github.com/users/everyone' => Http::response(['message' => 'Not Found'], 404)]); + + mentionOf('everyone'); + + $this->get('/@everyone')->assertNotFound(); + $this->get('/@everyone')->assertNotFound(); + + Http::assertSentCount(1); +}); + +it('is not found for an organization, since nobody can sign in as one', function (): void { + Http::fake(['api.github.com/users/laravel' => Http::response(['id' => 958072, 'login' => 'laravel', 'type' => 'Organization', 'name' => 'Laravel'])]); + + mentionOf('laravel'); + + $this->get('/@laravel')->assertNotFound(); +}); + +it('is not found while github cannot be reached, and asks again on the next visit', function (): void { + Http::fakeSequence('api.github.com/users/ondrejmirtes') + ->pushFailedConnection() + ->push(['id' => 104888, 'login' => 'ondrejmirtes', 'type' => 'User', 'name' => 'Ondřej Mirtes']); + + mentionOf('ondrejmirtes'); + + $this->get('/@ondrejmirtes')->assertNotFound(); + $this->get('/@ondrejmirtes')->assertOk(); + + Http::assertSentCount(2); +}); + +it('remembers the github account for a day', function (): void { + fakeUnclaimedAccount(); + + mentionOf('ondrejmirtes'); + + $this->get('/@ondrejmirtes')->assertOk(); + $this->get('/@ondrejmirtes')->assertOk(); + + Http::assertSentCount(1); +}); + +it('ignores what github answers that is not an account', function (): void { + Http::fake(['api.github.com/users/ondrejmirtes' => Http::response(['message' => 'Moved'])]); + + mentionOf('ondrejmirtes'); + + $this->get('/@ondrejmirtes')->assertNotFound(); +}); + +it('sends a renamed account to the profile its owner already has', function (): void { + $owner = User::factory()->create(['username' => 'oldname', 'github_id' => '104888']); + + fakeUnclaimedAccount('newname'); + mentionOf('newname'); + + $this->get('/@newname')->assertRedirect(route('user.show', $owner)); +}); + +it('shows the profile of a user who signed in rather than the one waiting for them', function (): void { + Http::fake(); + + $user = User::factory()->create(['username' => 'ondrejmirtes']); + + mentionOf('ondrejmirtes'); + + $this->get('/@ondrejmirtes') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->component('user/show') + ->where('user.id', $user->id)); + + Http::assertNothingSent(); +}); + +it('does not count who is waiting when loading more mentions', function (): void { + fakeUnclaimedAccount(); + + mentionOf('ondrejmirtes'); + + $this->get('/@ondrejmirtes') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->reloadOnly('commits', fn (AssertableInertia $reload): AssertableInertia => $reload + ->missing('waiting') + ->has('commits.data', 1))); +}); diff --git a/tests/Feature/PendingMentionsBackfillTest.php b/tests/Feature/PendingMentionsBackfillTest.php new file mode 100644 index 000000000..f8942afde --- /dev/null +++ b/tests/Feature/PendingMentionsBackfillTest.php @@ -0,0 +1,24 @@ +create(['username' => 'Taylor']); + + $commit = Commit::factory()->create(['body' => "Thanks @taylor, @OndrejMirtes and @nuno!\n`@codeuser`"]); + $other = Commit::factory()->create(['body' => 'cc @ondrejmirtes']); + $left = Commit::factory()->create(['body' => 'Thanks @alice!', 'mentions' => ['alice' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b']]); + Commit::factory()->create(['body' => 'No mentions here.']); + PendingMention::factory()->for($other)->create(['username' => 'ondrejmirtes']); + + $migration = require database_path('migrations/2026_10_19_170001_backfill_pending_mentions.php'); + $migration->up(); + + expect($commit->pendingMentions()->orderBy('username')->pluck('username')->all())->toBe(['nuno', 'ondrejmirtes']) + ->and($other->pendingMentions()->pluck('username')->all())->toBe(['ondrejmirtes']) + ->and($left->pendingMentions()->count())->toBe(0); +}); diff --git a/tests/Unit/Actions/CreatePendingFollowsTest.php b/tests/Unit/Actions/CreatePendingFollowsTest.php new file mode 100644 index 000000000..d4114cc39 --- /dev/null +++ b/tests/Unit/Actions/CreatePendingFollowsTest.php @@ -0,0 +1,26 @@ +create(); + $registered = User::factory()->create(); + + resolve(CreatePendingFollows::class)->handle($user, ['111', $registered->github_id, '222']); + resolve(CreatePendingFollows::class)->handle($user, ['111']); + + expect($user->pendingFollows()->orderBy('github_id')->pluck('github_id')->all())->toBe(['111', '222']); +}); + +it('remembers nothing when everyone is on devnull', function (): void { + $user = User::factory()->create(); + + resolve(CreatePendingFollows::class)->handle($user, [User::factory()->create()->github_id]); + resolve(CreatePendingFollows::class)->handle($user, []); + + expect(PendingFollow::query()->count())->toBe(0); +}); diff --git a/tests/Unit/Actions/CreatePendingMentionsTest.php b/tests/Unit/Actions/CreatePendingMentionsTest.php new file mode 100644 index 000000000..ca5eaf4e3 --- /dev/null +++ b/tests/Unit/Actions/CreatePendingMentionsTest.php @@ -0,0 +1,24 @@ +create(); + User::factory()->create(['username' => 'taylor']); + + $commit = resolve(CreateCommit::class)->handle($author, "Thanks @taylor, @OndrejMirtes and @ondrejmirtes!\n`@codeuser`"); + + expect($commit->pendingMentions()->pluck('username')->all())->toBe(['ondrejmirtes']); +}); + +it('keeps nothing when everyone mentioned is on devnull', function (): void { + $author = User::factory()->create(); + User::factory()->create(['username' => 'taylor']); + + $commit = resolve(CreateCommit::class)->handle($author, 'Thanks @taylor!'); + + expect($commit->pendingMentions()->count())->toBe(0); +}); diff --git a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php index 910fdf3da..bffb3232a 100644 --- a/tests/Unit/Actions/CreateUserGitHubFollowsTest.php +++ b/tests/Unit/Actions/CreateUserGitHubFollowsTest.php @@ -5,6 +5,7 @@ use App\Actions\CreateUserGitHubFollows; use App\Enums\NotificationType; use App\Models\Notification; +use App\Models\PendingFollow; use App\Models\User; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; @@ -163,3 +164,37 @@ expect(Notification::query()->count())->toBe(2); expect($user->refresh()->github_follows_imported_at)->not->toBeNull(); }); + +it('remembers whom the user follows on github who is not on devnull yet', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->make(['github_id' => '999']); + + fakeGitHubFollows($user, following: [$followed, $stranger]); + + resolve(CreateUserGitHubFollows::class)->handle($user); + + expect($user->pendingFollows()->pluck('github_id')->all())->toBe(['999']); +}); + +it('has the users who followed them on github before they signed in follow them, past the first thousand', function (): void { + $user = User::factory()->create(); + $waiting = User::factory()->create(); + $alsoOnGitHub = User::factory()->create(); + $blocked = User::factory()->create(); + $other = PendingFollow::factory()->create(); + + $user->blocking()->attach($blocked); + + PendingFollow::factory()->create(['user_id' => $waiting->id, 'github_id' => $user->github_id]); + PendingFollow::factory()->create(['user_id' => $alsoOnGitHub->id, 'github_id' => $user->github_id]); + PendingFollow::factory()->create(['user_id' => $blocked->id, 'github_id' => $user->github_id]); + + fakeGitHubFollows($user, followers: [$alsoOnGitHub]); + + resolve(CreateUserGitHubFollows::class)->handle($user); + + expect($user->followers()->orderBy('users.id')->pluck('users.id')->all())->toBe(collect([$waiting->id, $alsoOnGitHub->id])->sort()->values()->all()) + ->and(Notification::query()->where('type', NotificationType::Followed)->where('recipient_id', $user->id)->count())->toBe(2) + ->and(PendingFollow::query()->pluck('id')->all())->toBe([$other->id]); +}); diff --git a/tests/Unit/Actions/UpdateCommitMentionsTest.php b/tests/Unit/Actions/UpdateCommitMentionsTest.php new file mode 100644 index 000000000..7fd854865 --- /dev/null +++ b/tests/Unit/Actions/UpdateCommitMentionsTest.php @@ -0,0 +1,32 @@ +create(['username' => 'taylor']); + $commit = Commit::factory()->create([ + 'body' => 'Thanks @taylor and @OndrejMirtes!', + 'mentions' => ['taylor' => $taylor->id], + 'updated_at' => now()->subDay(), + ]); + $deleted = Commit::factory()->trashed()->create(['body' => '@ondrejmirtes', 'mentions' => []]); + $unrelated = PendingMention::factory()->create(['username' => 'someoneelse']); + + PendingMention::factory()->for($commit)->create(['username' => 'ondrejmirtes']); + PendingMention::factory()->for($deleted)->create(['username' => 'ondrejmirtes']); + + $user = User::factory()->create(['username' => 'OndrejMirtes']); + $updatedAt = $commit->updated_at; + + resolve(UpdateCommitMentions::class)->handle($user); + + expect($commit->refresh()->mentions)->toBe(['taylor' => $taylor->id, 'ondrejmirtes' => $user->id]) + ->and($commit->updated_at->equalTo($updatedAt))->toBeTrue() + ->and($deleted->refresh()->mentions)->toBe(['ondrejmirtes' => $user->id]) + ->and(PendingMention::query()->pluck('id')->all())->toBe([$unrelated->id]); +}); diff --git a/tests/Unit/Actions/UpdateOrCreateUserTest.php b/tests/Unit/Actions/UpdateOrCreateUserTest.php index 101630198..ac74c633e 100644 --- a/tests/Unit/Actions/UpdateOrCreateUserTest.php +++ b/tests/Unit/Actions/UpdateOrCreateUserTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Actions\UpdateOrCreateUser; +use App\Models\Commit; +use App\Models\PendingMention; use App\Models\User; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -278,3 +280,18 @@ ->and($stale->github_id)->toBe('1') ->and($stale->bio)->toBe('Renamed away'); })->with(['octocat', 'OctoCat']); + +it('turns the mentions made before the user signed in into mentions of them', function (): void { + fakeGitHubSponsorsListing(); + + $commit = Commit::factory()->create(['body' => 'Thanks @octocat!', 'mentions' => []]); + PendingMention::factory()->for($commit)->create(['username' => 'octocat']); + + $user = resolve(UpdateOrCreateUser::class)->handle(GitHubUser::fake([ + 'id' => 583231, + 'nickname' => 'octocat', + ])); + + expect($commit->refresh()->mentions)->toBe(['octocat' => $user->id]) + ->and(PendingMention::query()->count())->toBe(0); +}); diff --git a/tests/Unit/LinkifiedText.test.tsx b/tests/Unit/LinkifiedText.test.tsx index 718454664..bc69558d9 100644 --- a/tests/Unit/LinkifiedText.test.tsx +++ b/tests/Unit/LinkifiedText.test.tsx @@ -4,7 +4,10 @@ import { App } from '@inertiajs/react'; import type { ComponentProps, ReactElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { test } from 'vite-plus/test'; -import { CommitBody } from '../../resources/js/components/commit-body'; +import { + CommitBody, + FoldedCommitBody, +} from '../../resources/js/components/commit-body'; import { LinkifiedText, typedLinks, @@ -54,6 +57,48 @@ test('leaves mentions without captured identities as plain text', () => { assert.equal(html, 'Thanks @original!'); }); +test('links mentions of developers who are not on /dev/null to their unclaimed profile only when asked', () => { + const text = 'Thanks @OndrejMirtes!'; + const linked = renderToStaticMarkup( + , + ); + + assert.ok( + linked.includes( + 'data-test="unclaimed-mention" href="/@OndrejMirtes">@OndrejMirtes', + ), + ); + assert.equal( + renderToStaticMarkup(), + text, + ); + assert.equal( + renderToStaticMarkup( + , + ), + text, + ); +}); + +test('links mentions in posted commits to unclaimed profiles, but not in messages', () => { + const segments = [{ type: 'text' as const, text: 'Thanks @OndrejMirtes!' }]; + const commit = renderPage( + , + ); + const message = renderPage(); + + assert.ok( + commit.includes( + 'rel="nofollow" class="font-medium text-primary hover:underline" data-test="unclaimed-mention" href="/@OndrejMirtes">@OndrejMirtes!', + ), + ); + assert.ok(message.includes('Thanks @OndrejMirtes!')); +}); + test('keeps captured mention identities in regular bodies alongside highlighted code', () => { const id = '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b'; const html = renderPage( From 65f1dc7db866cea3708519f48c64a3c07c075147 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Mon, 19 Oct 2026 20:01:20 +0200 Subject: [PATCH 2/7] fix: address review of unclaimed profiles Scheduled commits keep their mentions as plain text, waiting followers across a block from the viewer are left out, the sign-in job keeps the username it was dispatched for, and a rate-limited follows:pending run keeps its resume point. --- app/Actions/UpdateCommitMentions.php | 4 ++-- app/Actions/UpdateOrCreateUser.php | 2 +- .../ImportPendingGitHubFollowsCommand.php | 3 +-- app/Http/Controllers/UserController.php | 8 ++++--- app/Jobs/UpdateCommitMentionsJob.php | 7 ++++-- app/Queries/PendingFollowersQuery.php | 8 ++++--- resources/js/components/commit-body.tsx | 8 ++++--- .../ImportPendingGitHubFollowsTest.php | 11 +++++++++ .../Controllers/UnclaimedProfileTest.php | 24 +++++++++++++++++++ .../Unit/Actions/UpdateCommitMentionsTest.php | 2 +- tests/Unit/Actions/UpdateOrCreateUserTest.php | 14 +++++++++++ tests/Unit/LinkifiedText.test.tsx | 10 +++++++- 12 files changed, 83 insertions(+), 18 deletions(-) diff --git a/app/Actions/UpdateCommitMentions.php b/app/Actions/UpdateCommitMentions.php index 46faa9b09..9f973e59c 100644 --- a/app/Actions/UpdateCommitMentions.php +++ b/app/Actions/UpdateCommitMentions.php @@ -18,9 +18,9 @@ * profile like any other, a few at a time, since a well-known developer may have been mentioned a lot. The commits * keep their update time, since nobody edited them. */ - public function handle(User $user): void + public function handle(User $user, string $username): void { - $username = Str::lower($user->username); + $username = Str::lower($username); PendingMention::query() ->where('username', $username) diff --git a/app/Actions/UpdateOrCreateUser.php b/app/Actions/UpdateOrCreateUser.php index 48cfef4f5..d173829bd 100644 --- a/app/Actions/UpdateOrCreateUser.php +++ b/app/Actions/UpdateOrCreateUser.php @@ -43,7 +43,7 @@ public function handle(GitHubUser $githubUser): User ]); if (PendingMention::query()->where('username', Str::lower($user->username))->exists()) { - dispatch(new UpdateCommitMentionsJob($user))->afterCommit(); + dispatch(new UpdateCommitMentionsJob($user, $user->username))->afterCommit(); } return $user; diff --git a/app/Console/Commands/ImportPendingGitHubFollowsCommand.php b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php index 52346c1d2..3d0f827a4 100644 --- a/app/Console/Commands/ImportPendingGitHubFollowsCommand.php +++ b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php @@ -23,9 +23,8 @@ public function handle(GitHub $github, CreatePendingFollows $createPendingFollow { $imported = 0; $failed = 0; - $lastId = null; - $after = $this->option('after'); + $lastId = is_string($after) ? $after : null; $users = User::query() ->whereNotNull('github_follows_imported_at') diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index afe8589c5..ddd592e63 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -92,7 +92,9 @@ public function destroy(DeleteUserRequest $request, #[CurrentUser] User $user, D } /** - * A GitHub account renamed since its owner last signed in still has their profile, under its old username. + * The profile of a developer who isn't on /dev/null yet, waiting for them to claim it: what GitHub says about them, + * who is waiting to follow them, and the commits that mention them. A GitHub account renamed since its owner last + * signed in goes to the profile they already have, under its old username. */ private function unclaimed(ShowUserRequest $request, GitHubAccount $account, ?User $currentUser, PendingMentionCommitsQuery $mentions, PendingFollowersQuery $pendingFollowers, PageMetadata $metadata): Response|RedirectResponse { @@ -103,8 +105,8 @@ private function unclaimed(ShowUserRequest $request, GitHubAccount $account, ?Us } $waiting = null; - $getWaiting = function () use (&$waiting, $pendingFollowers, $account): array { - return $waiting ??= $pendingFollowers->get($account->id); + $getWaiting = function () use (&$waiting, $pendingFollowers, $account, $currentUser): array { + return $waiting ??= $pendingFollowers->get($account->id, $currentUser); }; return Inertia::render('user/unclaimed', [ diff --git a/app/Jobs/UpdateCommitMentionsJob.php b/app/Jobs/UpdateCommitMentionsJob.php index 1af14f6c2..e43950f53 100644 --- a/app/Jobs/UpdateCommitMentionsJob.php +++ b/app/Jobs/UpdateCommitMentionsJob.php @@ -15,13 +15,16 @@ final class UpdateCommitMentionsJob implements ShouldQueue { use Queueable; - public function __construct(public User $user) + /** + * The username is the one the user signed in with, which a later rename must not change. + */ + public function __construct(public User $user, public string $username) { // } public function handle(UpdateCommitMentions $updateCommitMentions): void { - $updateCommitMentions->handle($this->user); + $updateCommitMentions->handle($this->user, $this->username); } } diff --git a/app/Queries/PendingFollowersQuery.php b/app/Queries/PendingFollowersQuery.php index 316f5aecd..fda70b916 100644 --- a/app/Queries/PendingFollowersQuery.php +++ b/app/Queries/PendingFollowersQuery.php @@ -6,6 +6,7 @@ use App\Models\PendingFollow; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; final readonly class PendingFollowersQuery @@ -13,12 +14,12 @@ private const int Shown = 5; /** - * How many people follow a GitHub account that isn't on /dev/null yet, and the latest few of them, who will follow - * it here once it signs in. + * How many people follow a GitHub account that isn't on /dev/null yet, and the latest few of them the viewer may + * see, who will follow it here once it signs in. * * @return array{count: int, users: Collection} */ - public function get(string $githubId): array + public function get(string $githubId, ?User $viewer): array { return [ 'count' => PendingFollow::query()->where('github_id', $githubId)->count(), @@ -26,6 +27,7 @@ public function get(string $githubId): array ->select('users.*') ->join('pending_follows', 'pending_follows.user_id', '=', 'users.id') ->where('pending_follows.github_id', $githubId) + ->when($viewer, fn (Builder $query, User $viewer): Builder => $query->whereNotIn('users.id', $viewer->idsAcrossBlocks())) ->orderByDesc('pending_follows.id') ->limit(self::Shown) ->get(), diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index 5b093ebd4..7f46c139f 100644 --- a/resources/js/components/commit-body.tsx +++ b/resources/js/components/commit-body.tsx @@ -71,7 +71,9 @@ export function CommitBody({ } /** - * A body too long to show whole in a list comes with its first lines as a preview, unfolded on demand. + * A body too long to show whole in a list comes with its first lines as a preview, unfolded on demand. A posted + * commit's mentions of developers who aren't on /dev/null link to their unclaimed profile; a scheduled one, whose + * mentions aren't captured yet, has none. */ export function FoldedCommitBody({ segments, @@ -93,7 +95,7 @@ export function FoldedCommitBody({ segments={segments} mentions={mentions} className={className} - linksUnclaimed + linksUnclaimed={mentions !== null} /> ); } @@ -120,7 +122,7 @@ export function FoldedCommitBody({ segments={isUnfolded ? segments : previewSegments} mentions={mentions} className={className} - linksUnclaimed + linksUnclaimed={mentions !== null} /> {!isUnfolded && (