diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 64176d8b3..3e5b1a936 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,9 +98,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. 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. +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). ### Going live @@ -138,7 +140,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, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Someone who already follows you gets **Follow back**. Show or hide these suggestions in Settings → Appearance. You can also follow back from the notifications list. @@ -188,7 +190,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). @@ -444,7 +446,7 @@ Sources: [issue command UI](resources/js/components/issue-command.tsx), [issue v ### Exporting your data -Choose **Download JSON** in Settings → Profile to export your account data. The export includes profile details, your commits and replies (including pending, rejected, and deleted entries still stored), polls and your votes, stars, saved commits, follows, mutes, blocks, messages you sent, notifications, SSH key fingerprints, the devices you approved for an app, and active-session metadata. Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, and scheduled commits are not included either. Image references are URLs, not an archive of the image files. +Choose **Download JSON** in Settings → Profile to export your account data. The export includes profile details, your commits and replies (including pending, rejected, and deleted entries still stored), polls and your votes, stars, saved commits, follows (including the GitHub accounts you follow that aren't on /dev/null yet), mutes, blocks, messages you sent, notifications, SSH key fingerprints, the devices you approved for an app, and active-session metadata. Other people's replies and received messages are not included as your authored data. Message images, message reactions, reports you filed, and scheduled commits are not included either. Image references are URLs, not an archive of the image files. The export does not expose session IDs, authentication tokens, private SSH keys, or other people's poll choices. Keep the downloaded JSON private because it includes personal account information. Exports are limited to **three per hour**. diff --git a/PRODUCT.md b/PRODUCT.md index edaf9cee7..842469273 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -34,7 +34,7 @@ What exists, one line each (details in the knowledge base): - **Commits:** text with code, images, link cards and embedded players, or a poll; replies, quotes, threads, scheduling, translation. - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). -- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. +- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube; a developer mentioned before joining has an unclaimed profile to claim by signing in. - **Remotes:** private one-to-one and group messages (orgs). - **Notifications:** in-app only. - **Search:** developers by name, commits by meaning. diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index bbd43e11c..577d930c8 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/CreateExport.php b/app/Actions/CreateExport.php index 15488d143..998ae33a3 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -30,7 +30,7 @@ /** * Everything stored about the user, deleted commits included, but never secrets like the remember token or session ids. * - * @return array{schema_version: int, exported_at: string, profile: array, commits: Generator>, polls: Generator, created_at: string}>, poll_votes: Generator, stars: Generator, bookmarks: Generator, following: Generator, followers: Generator, muting: Generator, blocking: Generator, messages: Generator, notifications: Generator>, ssh_keys: array, devices: array, sessions: Collection} + * @return array{schema_version: int, exported_at: string, profile: array, commits: Generator>, polls: Generator, created_at: string}>, poll_votes: Generator, stars: Generator, bookmarks: Generator, following: Generator, followers: Generator, waiting_to_follow: Generator, muting: Generator, blocking: Generator, messages: Generator, notifications: Generator>, ssh_keys: array, devices: array, sessions: Collection} */ public function handle(User $user): array { @@ -72,6 +72,7 @@ public function handle(User $user): array 'bookmarks' => $this->bookmarks($user), 'following' => $this->usernames($user->following()), 'followers' => $this->usernames($user->followers()), + 'waiting_to_follow' => $this->waitingToFollow($user), 'muting' => $this->usernames($user->muting()), 'blocking' => $this->usernames($user->blocking()), 'messages' => $this->messages($user), @@ -220,6 +221,18 @@ private function usernames(BelongsToMany $users): Generator } } + /** + * The GitHub accounts the user follows there that aren't on /dev/null yet, by GitHub ID, which is all that is kept. + * + * @return Generator + */ + private function waitingToFollow(User $user): Generator + { + foreach ($user->pendingFollows()->select(['id', 'github_id'])->lazyById(500) as $pending) { + yield $pending->github_id; + } + } + /** * Moderators stay anonymous, as they are on the notifications page. * diff --git a/app/Actions/CreatePendingFollows.php b/app/Actions/CreatePendingFollows.php new file mode 100644 index 000000000..3915b03ec --- /dev/null +++ b/app/Actions/CreatePendingFollows.php @@ -0,0 +1,33 @@ + $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 $registered): string => $registered->github_id)->all(), + )); + + if ($githubIds === []) { + return; + } + + $user->pendingFollows()->insertOrIgnore(array_map( + fn (string $githubId): array => ['user_id' => $user->id, 'github_id' => $githubId], + $githubIds, + )); + } +} diff --git a/app/Actions/CreatePendingMentions.php b/app/Actions/CreatePendingMentions.php new file mode 100644 index 000000000..03dcdfe90 --- /dev/null +++ b/app/Actions/CreatePendingMentions.php @@ -0,0 +1,33 @@ +mentions->usernames($commit->body), array_keys($commit->mentions ?? [])); + + if ($usernames === []) { + return; + } + + $commit->pendingMentions()->insertOrIgnore(array_map( + fn (string $username): array => ['commit_id' => $commit->id, 'username' => $username], + array_values($usernames), + )); + } +} diff --git a/app/Actions/CreateUserGitHubFollows.php b/app/Actions/CreateUserGitHubFollows.php index 49ce0cf98..c67daf85a 100644 --- a/app/Actions/CreateUserGitHubFollows.php +++ b/app/Actions/CreateUserGitHubFollows.php @@ -4,8 +4,10 @@ namespace App\Actions; +use App\Models\PendingFollow; use App\Models\User; use App\Services\GitHub; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -18,11 +20,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 +44,11 @@ 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), withWaiting: true); - 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,27 @@ 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()]); }); } /** + * The users here with the given GitHub accounts, and with those who followed the user on GitHub before they signed + * in when asked, including those past the 1,000 the import reads. + * + * @param list $githubIds * @return Collection */ - private function users(User $user, string $path): Collection + private function users(User $user, array $githubIds, bool $withWaiting = false): Collection { return User::query() - ->whereIn('github_id', $this->github->accountIds($path, self::MaxPages)) + ->where(fn (Builder $query): Builder => $query + ->whereIn('github_id', $githubIds) + ->when($withWaiting, fn (Builder $query): Builder => $query + ->orWhereIn('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..e08f8a4ce --- /dev/null +++ b/app/Actions/UpdateCommitMentions.php @@ -0,0 +1,43 @@ +where('username', $username) + ->chunkById(200, function (Collection $pending) use ($username, $user): void { + DB::transaction(function () use ($pending, $username, $user): void { + $commits = Commit::query()->withTrashed()->whereKey($pending->pluck('commit_id'))->lockForUpdate()->get(); + + foreach ($commits as $commit) { + $commit->timestamps = false; + + // array_replace keeps the keys of usernames that are numbers, which unpacking would renumber... + $commit->update(['mentions' => array_replace($commit->mentions ?? [], [$username => $user->id])]); + } + + PendingMention::query()->whereKey($pending->modelKeys())->delete(); + }); + }); + } +} diff --git a/app/Console/Commands/ImportPendingGitHubFollowsCommand.php b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php new file mode 100644 index 000000000..81844dae6 --- /dev/null +++ b/app/Console/Commands/ImportPendingGitHubFollowsCommand.php @@ -0,0 +1,24 @@ +components->info('Queued. The pending GitHub follows are remembered in the background, leaving GitHub requests to the rest of the app.'); + + return self::SUCCESS; + } +} 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/Dto/GitHubProfile.php b/app/Dto/GitHubProfile.php index 03dba27f2..86a43e522 100644 --- a/app/Dto/GitHubProfile.php +++ b/app/Dto/GitHubProfile.php @@ -55,6 +55,11 @@ public static function fromSocialite(GitHubUser $githubUser): self ]); } + public function nameWithHandle(): string + { + return $this->name === $this->username ? "@{$this->username}" : "{$this->name} (@{$this->username})"; + } + /** * @return array{username: string, name: string, avatar_url: string|null, bio: string|null, location: string|null, website: string|null, company: string|null} */ diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index a377ff63e..87113e1cb 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -8,6 +8,7 @@ use App\Actions\UpdateOrCreateUser; use App\Jobs\ImportGitHubFollowsJob; use App\Jobs\SyncGitHubSshKeysJob; +use App\Jobs\UpdateCommitMentionsJob; use App\Services\GitHubAccountScreening; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -54,6 +55,11 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri dispatch(new SyncGitHubSshKeysJob($user)); dispatch(new ImportGitHubFollowsJob($user, notify: $user->wasRecentlyCreated)); + // Only a username new to /dev/null can have mentions made before anyone here had it... + if ($user->wasRecentlyCreated || $user->wasChanged('username')) { + dispatch(new UpdateCommitMentionsJob($user, $user->username)); + } + Auth::login($user, remember: true); $request->session()->regenerate(); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b3b81496b..e5b78f8a9 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -10,6 +10,8 @@ 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 +28,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, $currentUser, $mentions, $pendingFollowers, $metadata); + } + $blockerId = $currentUser?->blockerIdWith($user); if ($blockerId !== null) { @@ -80,4 +89,30 @@ public function destroy(DeleteUserRequest $request, #[CurrentUser] User $user, D return to_route('home'); } + + /** + * 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, ?User $currentUser, PendingMentionCommitsQuery $mentions, PendingFollowersQuery $pendingFollowers, PageMetadata $metadata): Response|RedirectResponse + { + $account = $request->account(); + $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, $currentUser): array { + return $waiting ??= $pendingFollowers->get($account->id, $currentUser); + }; + + 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/ImportPendingGitHubFollowsJob.php b/app/Jobs/ImportPendingGitHubFollowsJob.php new file mode 100644 index 000000000..7bdddf854 --- /dev/null +++ b/app/Jobs/ImportPendingGitHubFollowsJob.php @@ -0,0 +1,100 @@ +isConfigured() ? $rateLimit->get() : null; + + if ($limit !== null && $limit['remaining'] < self::Reserve) { + // A minute past the reset, so the cached read of the rate limit is a fresh one by then. + $this->continueAt(CarbonImmutable::parse($limit['resets_at'])->addMinute()); + + return; + } + + $users = User::query() + ->whereNotNull('github_follows_imported_at') + ->when($this->after !== null, fn (Builder $query): Builder => $query->where('id', '>', $this->after)) + ->orderBy('id') + ->limit(self::BatchSize) + ->get(); + + foreach ($users as $user) { + try { + // The username is the GitHub login, as of their last sign-in, which saves looking it up by ID... + $createPendingFollows->handle($user, $github->accountIds("users/{$user->username}/following", CreateUserGitHubFollows::MaxPages)); + } catch (RequestException $exception) { + if (in_array($exception->response->status(), [403, 429], true)) { + $this->continueAt(now()->addMinutes(15)); + + return; + } + + // ...and an account renamed or deleted since then is skipped. + report_if($exception->response->status() !== 404, $exception); + } catch (ConnectionException $exception) { + report($exception); + } + + $this->after = $user->id; + } + + if ($users->count() < self::BatchSize) { + Log::info('Remembered the pending GitHub follows of every user.'); + + return; + } + + $this->continueAt(now()); + } + + /** + * Some queues can't hold a job back more than 15 minutes, so a longer wait reads the rate limit again by then. + */ + private function continueAt(CarbonInterface $at): void + { + dispatch(new self($this->after))->delay($at->min(now()->addMinutes(15))); + } +} diff --git a/app/Jobs/UpdateCommitMentionsJob.php b/app/Jobs/UpdateCommitMentionsJob.php new file mode 100644 index 000000000..e43950f53 --- /dev/null +++ b/app/Jobs/UpdateCommitMentionsJob.php @@ -0,0 +1,30 @@ +handle($this->user, $this->username); + } +} 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..3cdd8296c --- /dev/null +++ b/app/Models/PendingFollow.php @@ -0,0 +1,24 @@ + */ + use HasFactory; +} diff --git a/app/Models/PendingMention.php b/app/Models/PendingMention.php new file mode 100644 index 000000000..6ab9403ba --- /dev/null +++ b/app/Models/PendingMention.php @@ -0,0 +1,35 @@ + */ + 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 000cd2338..1f9bfae91 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -66,6 +66,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 @@ -172,6 +173,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/CommitBuilder.php b/app/Queries/CommitBuilder.php index a15cf8792..51a88afad 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -125,6 +125,16 @@ public function newerThan(Commit $commit): self ->where($this->qualifyColumn('id'), '>', $commit->id))); } + /** + * Loads each reply's parent with its author, only when the viewer may see it. + */ + public function withVisibleParent(?User $viewer): self + { + return $this->with(['parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (self $query): self => $query->visibleTo($viewer)) + ->with('user')]); + } + public function newestFirst(): self { return $this->reorder()->latest()->latest('id'); diff --git a/app/Queries/GitHubAccountQuery.php b/app/Queries/GitHubAccountQuery.php new file mode 100644 index 000000000..393f6d9ff --- /dev/null +++ b/app/Queries/GitHubAccountQuery.php @@ -0,0 +1,70 @@ +|false|string|null $account */ + $account = Cache::get($key); + + if ($account === null) { + $account = $this->fetch($username); + + Cache::put($key, $account, $account === self::Unavailable ? 60 : 86400); + } + + return is_array($account) ? GitHubAccount::fromAccount($account) : null; + } + + /** + * Only what the profile shows is kept, not the whole account GitHub answers with. + * + * @return array|false|string + */ + private function fetch(string $username): array|false|string + { + try { + $response = $this->github->request()->get('users/'.rawurlencode($username)); + + if ($response->notFound()) { + return false; + } + + $account = $response->throw()->json(); + } catch (HttpClientException) { + return self::Unavailable; + } + + if (! is_array($account) || ! is_int($account['id'] ?? null) || ! is_string($account['login'] ?? null) || ($account['type'] ?? null) !== 'User') { + return false; + } + + return Arr::only($account, ['id', 'login', 'name', 'avatar_url']); + } +} diff --git a/app/Queries/PendingFollowersQuery.php b/app/Queries/PendingFollowersQuery.php new file mode 100644 index 000000000..fda70b916 --- /dev/null +++ b/app/Queries/PendingFollowersQuery.php @@ -0,0 +1,36 @@ +} + */ + public function get(string $githubId, ?User $viewer): 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) + ->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/app/Queries/PendingMentionCommitsQuery.php b/app/Queries/PendingMentionCommitsQuery.php new file mode 100644 index 000000000..a96f2be2a --- /dev/null +++ b/app/Queries/PendingMentionCommitsQuery.php @@ -0,0 +1,41 @@ +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) + ->withVisibleParent($viewer) + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->newestFirst(); + } + + 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/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php index 6d0284520..0421faa59 100644 --- a/app/Queries/UserCommitsQuery.php +++ b/app/Queries/UserCommitsQuery.php @@ -6,7 +6,6 @@ use App\Models\Commit; use App\Models\User; -use Illuminate\Database\Eloquent\Relations\BelongsTo; final readonly class UserCommitsQuery { @@ -24,16 +23,13 @@ public function builder(User $user, ?User $viewer, bool $replies = false): Commi $replies, fn (CommitBuilder $query): CommitBuilder => $query ->whereNotNull('parent_id') - ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) - ->with('user')]), + ->withVisibleParent($viewer), fn (CommitBuilder $query): CommitBuilder => $query->topLevel() ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), ) ->withVisibleReplyCount($viewer) ->withReplyPreviews($viewer) ->withCardDataFor($viewer) - ->latest() - ->latest('id'); + ->newestFirst(); } } diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 5b8f2e4f6..23c26a40d 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; @@ -49,13 +50,7 @@ public function home(): array 'description' => $description, 'url' => $url, 'robots' => self::INDEXABLE, - 'images' => [[ - 'url' => url('/og.png'), - 'width' => OpenGraphCanvas::WIDTH, - 'height' => OpenGraphCanvas::HEIGHT, - 'type' => 'image/png', - 'alt' => '/dev/null: a social network for developers', - ]], + 'images' => [$this->defaultImage('/dev/null: a social network for developers')], 'structuredData' => [ '@context' => 'https://schema.org', '@graph' => [ @@ -95,13 +90,7 @@ public function page(string $title, string $description, string $url): array 'description' => $description, 'url' => $url, 'robots' => self::INDEXABLE, - 'images' => [[ - 'url' => url('/og.png'), - 'width' => OpenGraphCanvas::WIDTH, - 'height' => OpenGraphCanvas::HEIGHT, - 'type' => 'image/png', - 'alt' => $title, - ]], + 'images' => [$this->defaultImage($title)], 'structuredData' => [ '@context' => 'https://schema.org', '@type' => 'WebPage', @@ -240,6 +229,31 @@ 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->nameWithHandle()} 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' => [$this->defaultImage($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. * @@ -305,6 +319,20 @@ private function feed(User $user): array ]; } + /** + * @return Image + */ + private function defaultImage(string $alt): array + { + return [ + 'url' => url('/og.png'), + 'width' => OpenGraphCanvas::WIDTH, + 'height' => OpenGraphCanvas::HEIGHT, + 'type' => 'image/png', + 'alt' => $alt, + ]; + } + /** * @return Image */ 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..398a66dee --- /dev/null +++ b/database/migrations/2026_10_19_170000_create_pending_mentions_table.php @@ -0,0 +1,22 @@ +id(); + $table->foreignUuid('commit_id')->constrained()->cascadeOnDelete(); + $table->string('username'); + + $table->unique(['commit_id', 'username']); + $table->index(['username', 'id']); + }); + } +}; 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..c75e19c2a --- /dev/null +++ b/database/migrations/2026_10_19_170001_backfill_pending_mentions.php @@ -0,0 +1,67 @@ +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(); + + DB::table('pending_mentions')->insertOrIgnore($usernamesByCommit + ->flatMap(fn (array $usernames, string $id): array => array_map( + fn (string $username): array => ['commit_id' => $id, 'username' => $username], + 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->unique(['user_id', 'github_id']); + $table->index(['github_id', 'id']); + }); + } +}; diff --git a/resources/js/components/chat/org.tsx b/resources/js/components/chat/org.tsx index db947c88e..3dafb8392 100644 --- a/resources/js/components/chat/org.tsx +++ b/resources/js/components/chat/org.tsx @@ -64,16 +64,21 @@ export function orgTitle( export function MemberStack({ users, max = 3, + total = users.length, className, avatarClassName = 'size-7', }: { users: User[]; max?: number; + /** + * How many people there are in all, when only some of them were loaded. + */ + total?: number; className?: string; avatarClassName?: string; }) { const shown = users.slice(0, max); - const rest = users.length - shown.length; + const rest = total - shown.length; return ( diff --git a/resources/js/components/commit-body.tsx b/resources/js/components/commit-body.tsx index aa0eded12..59c5e5ede 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': @@ -64,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, @@ -79,6 +88,7 @@ export function FoldedCommitBody({ }) { const [isUnfolded, setIsUnfolded] = useState(false); const bodyRef = useRef(null); + const linksUnclaimed = mentions !== null; if (previewSegments === null) { return ( @@ -86,6 +96,7 @@ export function FoldedCommitBody({ segments={segments} mentions={mentions} className={className} + linksUnclaimed={linksUnclaimed} /> ); } @@ -112,6 +123,7 @@ export function FoldedCommitBody({ segments={isUnfolded ? segments : previewSegments} mentions={mentions} className={className} + linksUnclaimed={linksUnclaimed} /> {!isUnfolded && ( + )} + + + +

+ {name} +

+

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

+ + + + {waiting.count > 0 && ( +
+ + + + + {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..f1a3c81fa --- /dev/null +++ b/tests/Feature/Commands/ImportPendingGitHubFollowsTest.php @@ -0,0 +1,16 @@ +artisan('follows:pending') + ->expectsOutputToContain('Queued.') + ->assertSuccessful(); + + Queue::assertPushed(ImportPendingGitHubFollowsJob::class, fn (ImportPendingGitHubFollowsJob $job): bool => $job->after === null); +}); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index be8d1f2aa..2828543b1 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -11,6 +11,7 @@ use App\Models\CommitImage; use App\Models\Device; use App\Models\Notification; +use App\Models\PendingFollow; use App\Models\Poll; use App\Models\PollVote; use App\Models\SshKey; @@ -113,6 +114,7 @@ 'bookmarks' => [], 'following' => [], 'followers' => [], + 'waiting_to_follow' => [], 'muting' => [], 'blocking' => [], 'messages' => [], @@ -271,7 +273,7 @@ ->and($response->streamedContent())->not->toContain('Removed A', 'Removed B', $deleted->username); }); -it('exports following and follower usernames without private identity fields', function (): void { +it('exports following and follower usernames, and the github accounts the user waits to follow, without private identity fields', function (): void { $user = User::factory()->create(); $followed = User::factory()->create(['username' => 'demo-developer', 'email' => 'private@example.test', 'remember_token' => 'secret-token']); $follower = User::factory()->create(['username' => 'demo-follower', 'email' => 'follower@example.test']); @@ -279,12 +281,15 @@ $user->following()->attach($followed); $user->followers()->attach($follower); $follower->following()->attach($stranger); + PendingFollow::factory()->create(['user_id' => $user->id, 'github_id' => '583231']); + PendingFollow::factory()->create(['user_id' => $follower->id]); $response = $this->actingAs($user)->get(route('export')); $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); expect($data['following'])->toBe([['username' => 'demo-developer']]) ->and($data['followers'])->toBe([['username' => 'demo-follower']]) + ->and($data['waiting_to_follow'])->toBe(['583231']) ->and($response->streamedContent())->not->toContain( $followed->email, $followed->github_id, $followed->id, $followed->remember_token, $follower->email, $follower->id, $stranger->username, diff --git a/tests/Feature/Controllers/SessionControllerTest.php b/tests/Feature/Controllers/SessionControllerTest.php index e65dd60a9..85981b7e1 100644 --- a/tests/Feature/Controllers/SessionControllerTest.php +++ b/tests/Feature/Controllers/SessionControllerTest.php @@ -5,6 +5,7 @@ use App\Enums\Log; use App\Jobs\ImportGitHubFollowsJob; use App\Jobs\SyncGitHubSshKeysJob; +use App\Jobs\UpdateCommitMentionsJob; use App\Models\User; use Illuminate\Support\Facades\Queue; use Laravel\Socialite\Socialite; @@ -197,6 +198,33 @@ 'existing user' => [true, false], ]); +it('queues claiming the mentions of a username new to devnull on login', function (?string $username, bool $claims): void { + fakeGitHubSponsorsListing(); + + if ($username !== null) { + User::factory()->create(['github_id' => '583231', 'username' => $username]); + } + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + ])); + + $this->get(route('login.store')); + + $user = User::query()->where('github_id', '583231')->sole(); + + if ($claims) { + Queue::assertPushed(UpdateCommitMentionsJob::class, fn (UpdateCommitMentionsJob $job): bool => $job->user->is($user) && $job->username === 'octocat'); + } else { + Queue::assertNotPushed(UpdateCommitMentionsJob::class); + } +})->with([ + 'new user' => [null, true], + 'renamed user' => ['oldname', true], + 'same username' => ['octocat', false], +]); + it('redirects to the intended url after login', function (): void { fakeGitHubSponsorsListing(); diff --git a/tests/Feature/Controllers/UnclaimedProfileTest.php b/tests/Feature/Controllers/UnclaimedProfileTest.php new file mode 100644 index 000000000..c142c794f --- /dev/null +++ b/tests/Feature/Controllers/UnclaimedProfileTest.php @@ -0,0 +1,255 @@ + 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 a minute later', 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')->assertNotFound(); + + $this->travel(61)->seconds(); + + $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))); +}); + +it('leaves out of who is waiting the people across a block from the viewer', function (): void { + fakeUnclaimedAccount(); + + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $visible = PendingFollow::factory()->create(['github_id' => '104888']); + + $viewer->blocking()->attach($blocked); + $blocker->blocking()->attach($viewer); + + PendingFollow::factory()->create(['user_id' => $blocked->id, 'github_id' => '104888']); + PendingFollow::factory()->create(['user_id' => $blocker->id, 'github_id' => '104888']); + mentionOf('ondrejmirtes'); + + $this->actingAs($viewer) + ->get('/@ondrejmirtes') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('waiting.count', 3) + ->has('waiting.users', 1) + ->where('waiting.users.0.id', $visible->user_id)); +}); 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/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index a27621af7..faf602127 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -11,6 +11,8 @@ use App\Models\Device; use App\Models\Message; use App\Models\MessageImage; +use App\Models\PendingFollow; +use App\Models\PendingMention; use App\Models\Poll; use App\Models\PollOption; use App\Models\PollVote; @@ -129,6 +131,19 @@ expect(Device::query()->pluck('id')->all())->toBe([$other->id]); }); +it('erases whom a deleted user was waiting to follow, and the mentions of others in their commits', function (): void { + $user = User::factory()->create(); + PendingFollow::factory()->create(['user_id' => $user->id]); + PendingMention::factory()->for(Commit::factory()->for($user))->create(); + $otherFollow = PendingFollow::factory()->create(); + $otherMention = PendingMention::factory()->create(); + + resolve(DeleteUser::class)->handle($user); + + expect(PendingFollow::query()->pluck('id')->all())->toBe([$otherFollow->id]) + ->and(PendingMention::query()->pluck('id')->all())->toBe([$otherMention->id]); +}); + it('erases the images of a deleted user and of the replies under their commits', function (): void { Storage::fake(); diff --git a/tests/Unit/Actions/UpdateCommitMentionsTest.php b/tests/Unit/Actions/UpdateCommitMentionsTest.php new file mode 100644 index 000000000..78498e317 --- /dev/null +++ b/tests/Unit/Actions/UpdateCommitMentionsTest.php @@ -0,0 +1,44 @@ +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, $user->username); + + 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]); +}); + +it('keeps the mentions of usernames that are numbers', function (): void { + $numeric = User::factory()->create(['username' => '123']); + $commit = Commit::factory()->create(['body' => 'Thanks @123 and @octocat!', 'mentions' => ['123' => $numeric->id]]); + PendingMention::factory()->for($commit)->create(['username' => 'octocat']); + + $user = User::factory()->create(['username' => 'octocat']); + + resolve(UpdateCommitMentions::class)->handle($user, $user->username); + + expect($commit->refresh()->mentions)->toBe(['123' => $numeric->id, 'octocat' => $user->id]); +}); diff --git a/tests/Unit/Jobs/ImportPendingGitHubFollowsJobTest.php b/tests/Unit/Jobs/ImportPendingGitHubFollowsJobTest.php new file mode 100644 index 000000000..f4e09bc61 --- /dev/null +++ b/tests/Unit/Jobs/ImportPendingGitHubFollowsJobTest.php @@ -0,0 +1,143 @@ +call([new ImportPendingGitHubFollowsJob($after), 'handle']); +} + +function fakeGitHubRateLimit(int $remaining, DateTimeInterface $resetsAt): void +{ + config(['services.github.client_id' => 'client-id', 'services.github.client_secret' => 'client-secret']); + + Http::fake(['api.github.com/rate_limit' => Http::response([ + 'resources' => ['core' => ['limit' => 5000, 'remaining' => $remaining, 'reset' => $resetsAt->getTimestamp()]], + ])]); +} + +it('remembers whom a batch of imported users follow on github, then queues the next batch', function (): void { + $users = User::factory()->count(ImportPendingGitHubFollowsJob::BatchSize + 1)->create(['github_follows_imported_at' => now()]); + User::factory()->create(['github_follows_imported_at' => null]); + [$batch, $next] = $users->sortBy('id')->values()->partition(fn (User $user, int $index): bool => $index < ImportPendingGitHubFollowsJob::BatchSize); + + Http::fake(['api.github.com/users/*/following*' => Http::response([['id' => 111, 'login' => 'octocat']])]); + + importPendingGitHubFollows(); + + expect(PendingFollow::query()->where('github_id', '111')->pluck('user_id')->sort()->values()->all())->toBe($batch->pluck('id')->all()); + + Http::assertSentCount(ImportPendingGitHubFollowsJob::BatchSize); + Queue::assertPushed(ImportPendingGitHubFollowsJob::class, fn (ImportPendingGitHubFollowsJob $job): bool => $job->after === $batch->last()->id + && now()->equalTo($job->delay)); + + expect($next->first()->pendingFollows()->exists())->toBeFalse(); +}); + +it('reads the users after the last batch, and stops after the last of them', function (): void { + $first = User::factory()->create(['github_follows_imported_at' => now()]); + $second = User::factory()->create(['github_follows_imported_at' => now()]); + [$first, $second] = collect([$first, $second])->sortBy('id')->values()->all(); + + Log::spy(); + fakeGitHubFollows($second, following: [User::factory()->make(['github_id' => '222'])]); + + importPendingGitHubFollows(after: $first->id); + + expect(PendingFollow::query()->pluck('user_id')->all())->toBe([$second->id]); + + Queue::assertNothingPushed(); + Log::shouldHaveReceived('info')->with('Remembered the pending GitHub follows of every user.'); +}); + +it('skips the users whose github account was renamed or deleted since they signed in', function (): void { + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + Exceptions::fake(); + Http::fake(["api.github.com/users/{$user->username}/following*" => Http::response(['message' => 'Not Found'], 404)]); + + importPendingGitHubFollows(); + + expect(PendingFollow::query()->count())->toBe(0); + + Exceptions::assertNothingReported(); +}); + +it('keeps going when whom a user follows cannot be read', function (string $exception, Closure $response): void { + $failing = User::factory()->create(['username' => 'failing', 'github_follows_imported_at' => now()]); + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + Exceptions::fake(); + Http::fake(['api.github.com/users/failing/following*' => $response]); + fakeGitHubFollows($user, following: [User::factory()->make(['github_id' => '222'])]); + + importPendingGitHubFollows(); + + expect(PendingFollow::query()->pluck('user_id')->all())->toBe([$user->id]); + + 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('waits for the rate limit to reset when it gets down to what the rest of the app needs', function (int $minutesToReset, int $minutesToWait): void { + // GitHub gives the reset to the second. + $this->travelTo(now()->startOfSecond()); + + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + fakeGitHubRateLimit(ImportPendingGitHubFollowsJob::Reserve - 1, now()->addMinutes($minutesToReset)); + + importPendingGitHubFollows(after: 'resume-here'); + + Http::assertSentCount(1); + Queue::assertPushed(ImportPendingGitHubFollowsJob::class, fn (ImportPendingGitHubFollowsJob $job): bool => $job->after === 'resume-here' + && now()->addMinutes($minutesToWait)->equalTo($job->delay)); + + expect($user->pendingFollows()->exists())->toBeFalse(); +})->with([ + 'a minute past the reset' => [10, 11], + 'at most 15 minutes, to read it again' => [50, 15], +]); + +it('goes on while the rate limit leaves enough to the rest of the app', function (): void { + $user = User::factory()->create(['github_follows_imported_at' => now()]); + + fakeGitHubRateLimit(ImportPendingGitHubFollowsJob::Reserve, now()->addHour()); + fakeGitHubFollows($user, following: [User::factory()->make(['github_id' => '222'])]); + + importPendingGitHubFollows(); + + expect($user->pendingFollows()->pluck('github_id')->all())->toBe(['222']); +}); + +it('tries again later from the user github rate limited', function (int $status): void { + $first = User::factory()->create(['github_follows_imported_at' => now()]); + $second = User::factory()->create(['github_follows_imported_at' => now()]); + [$first, $second] = collect([$first, $second])->sortBy('id')->values()->all(); + + fakeGitHubFollows($first, following: [User::factory()->make(['github_id' => '111'])]); + Http::fake(["api.github.com/users/{$second->username}/following*" => Http::response(['message' => 'API rate limit exceeded'], $status)]); + + importPendingGitHubFollows(); + + expect(PendingFollow::query()->pluck('user_id')->all())->toBe([$first->id]); + + Queue::assertPushed(ImportPendingGitHubFollowsJob::class, fn (ImportPendingGitHubFollowsJob $job): bool => $job->after === $first->id + && now()->addMinutes(15)->equalTo($job->delay)); +})->with([403, 429]); diff --git a/tests/Unit/Jobs/UpdateCommitMentionsJobTest.php b/tests/Unit/Jobs/UpdateCommitMentionsJobTest.php new file mode 100644 index 000000000..a04310f40 --- /dev/null +++ b/tests/Unit/Jobs/UpdateCommitMentionsJobTest.php @@ -0,0 +1,20 @@ +create(['body' => 'Thanks @octocat!', 'mentions' => []]); + PendingMention::factory()->for($commit)->create(['username' => 'octocat']); + + $user = User::factory()->create(['username' => 'renamed']); + + dispatch_sync(new UpdateCommitMentionsJob($user, '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..a62b8bcbe 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,56 @@ 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 or scheduled commits', () => { + const segments = [{ type: 'text' as const, text: 'Thanks @OndrejMirtes!' }]; + const commit = renderPage( + , + ); + const message = renderPage(); + const scheduled = 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!')); + assert.ok(scheduled.includes('Thanks @OndrejMirtes!')); +}); + test('keeps captured mention identities in regular bodies alongside highlighted code', () => { const id = '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b'; const html = renderPage(