diff --git a/app/Actions/UpdateCommitTopics.php b/app/Actions/UpdateCommitTopics.php index 28c36019d..4e54eeaee 100644 --- a/app/Actions/UpdateCommitTopics.php +++ b/app/Actions/UpdateCommitTopics.php @@ -4,47 +4,25 @@ namespace App\Actions; -use App\Enums\ProfileType; use App\Models\Commit; -use App\Models\Profile; use App\Queries\MentionedOrganizationsQuery; -use App\Services\GitHub; use Carbon\CarbonImmutable; -use Closure; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Str; final readonly class UpdateCommitTopics { - /** - * GitHub is asked about this many handles per commit at most, so one commit can't spend the shared rate limit. - */ - private const int MaxLookups = 5; - - /** - * Below this many requests left, lookups wait for the rate limit to reset, leaving the rest to SSH key syncs. - */ - private const int RateLimitFloor = 1000; - - private const int NotFoundDays = 1; - public function __construct( - private GitHub $github, + private UpdateMentionedOrganizations $updateMentionedOrganizations, private MentionedOrganizationsQuery $mentionedOrganizations, - private UpdateOrCreateOrganization $updateOrCreateOrganization, - private DeleteOrganization $deleteOrganization, private CreateCommitTopics $createCommitTopics, ) { // } /** - * Asks GitHub about the handles the commit mentions that are neither a user nor a known organization, and - * refreshes the known organizations it mentions once their profile is a day old, then links the commit to them. - * A handle that isn't an organization's isn't asked about again for a day, and an organization GitHub no longer - * has is deleted, with the links to it. The links come from the commit as it is after the lookups, locked, so a - * commit erased in the meantime gets none. Returns when to try again if the rate limit cut the lookups short. + * Looks the organizations the commit mentions up, then links the commit to them. The links come from the commit as + * it is after the lookups, locked, so a commit erased in the meantime gets none. Returns when to try again if the + * rate limit cut the lookups short. */ public function handle(Commit $commit): ?CarbonImmutable { @@ -54,61 +32,7 @@ public function handle(Commit $commit): ?CarbonImmutable return null; } - $known = Profile::query() - ->where('type', ProfileType::Organization) - ->whereIn(DB::raw('LOWER(handle)'), $handles) - ->get(); - - /** @var list $lookups */ - $lookups = collect($handles) - ->diff($known->map(fn (Profile $organization): string => Str::lower($organization->handle))) - ->reject(fn (string $handle): bool => Cache::has("github-organizations:not-found:{$handle}")) - ->map(fn (string $handle): array => [ - "users/{$handle}", - fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)), - ]) - ->merge($known - ->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true) - ->map(fn (Profile $organization): array => [ - "user/{$organization->github_id}", - function () use ($organization): void { - $this->deleteOrganization->handle($organization); - }, - ])) - ->take(self::MaxLookups) - ->values() - ->all(); - - $retryAt = null; - - foreach ($lookups as [$path, $notFound]) { - $pausedUntil = Cache::integer('github-organizations:paused', 0); - - if ($pausedUntil > 0) { - $retryAt = CarbonImmutable::createFromTimestamp($pausedUntil); - - break; - } - - $response = $this->github->request()->get($path); - $remaining = $response->header('X-RateLimit-Remaining'); - - if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) { - $resetsAt = CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset')); - Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); - } - - if ($response->notFound() || $response->throw()->json('type') !== 'Organization') { - $notFound(); - - continue; - } - - /** @var array $account */ - $account = $response->json(); - - $this->updateOrCreateOrganization->handle($account); - } + $retryAt = $this->updateMentionedOrganizations->handle($handles); DB::transaction(function () use ($commit): void { $commit = Commit::withTrashed()->lockForUpdate()->find($commit->id); diff --git a/app/Actions/UpdateMentionedOrganizations.php b/app/Actions/UpdateMentionedOrganizations.php new file mode 100644 index 000000000..b87c613c0 --- /dev/null +++ b/app/Actions/UpdateMentionedOrganizations.php @@ -0,0 +1,105 @@ + $handles + */ + public function handle(array $handles): ?CarbonImmutable + { + $profiles = Profile::query()->whereIn(DB::raw('LOWER(handle)'), $handles)->get(); + $known = $profiles->where('type', ProfileType::Organization); + + /** @var list $lookups */ + $lookups = collect($handles) + ->diff($profiles->map(fn (Profile $profile): string => Str::lower($profile->handle))) + ->reject(fn (string $handle): bool => Cache::has("github-organizations:not-found:{$handle}")) + ->map(fn (string $handle): array => [ + "users/{$handle}", + fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)), + ]) + ->merge($known + ->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true) + ->map(fn (Profile $organization): array => [ + "user/{$organization->github_id}", + function () use ($organization): void { + $this->deleteOrganization->handle($organization); + }, + ])) + ->take(self::MaxLookups) + ->values() + ->all(); + + $retryAt = null; + + foreach ($lookups as [$path, $notFound]) { + $pausedUntil = Cache::integer('github-organizations:paused', 0); + + if ($pausedUntil > 0) { + $retryAt = CarbonImmutable::createFromTimestamp($pausedUntil); + + break; + } + + $response = $this->github->request()->get($path); + $remaining = $response->header('X-RateLimit-Remaining'); + + if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) { + $resetsAt = CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset')); + Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); + } + + if ($response->notFound() || $response->throw()->json('type') !== 'Organization') { + $notFound(); + + continue; + } + + /** @var array $account */ + $account = $response->json(); + + $this->updateOrCreateOrganization->handle($account); + } + + return $retryAt; + } +} diff --git a/app/Actions/UpdateUserProfile.php b/app/Actions/UpdateUserProfile.php index fb20d7f0e..3333eb16f 100644 --- a/app/Actions/UpdateUserProfile.php +++ b/app/Actions/UpdateUserProfile.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Dto\GitHubProfile; +use App\Jobs\LookUpUserOrganizations; use App\Models\User; use App\Services\GitHub; use Illuminate\Database\UniqueConstraintViolationException; @@ -13,7 +14,8 @@ use Illuminate\Support\Facades\DB; /** - * Mirrors the GitHub profile and SSH keys of a user again, without them having to sign in. + * Mirrors the GitHub profile and SSH keys of a user again, without them having to sign in, and looks the organizations + * their bio and company mention up. */ final readonly class UpdateUserProfile { @@ -47,6 +49,8 @@ public function handle(User $user): bool // The email is left alone: the public API only has the public one, sign-in also knows the private one. $user->update(['company' => $profile->company]); }); + + LookUpUserOrganizations::dispatchFor($user); } catch (ConnectionException|RequestException|UniqueConstraintViolationException $exception) { report($exception); diff --git a/app/Console/Commands/LookUpUserOrganizationsCommand.php b/app/Console/Commands/LookUpUserOrganizationsCommand.php new file mode 100644 index 000000000..0281abea2 --- /dev/null +++ b/app/Console/Commands/LookUpUserOrganizationsCommand.php @@ -0,0 +1,31 @@ +lazyById() + ->filter(LookUpUserOrganizations::dispatchFor(...)) + ->count(); + + $this->components->info("Queued the organizations of {$queued} users to be looked up."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index 07ebfa671..c0798fbaf 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -8,6 +8,7 @@ use App\Actions\DeletePushSubscription; use App\Actions\UpdateOrCreateUser; use App\Jobs\ImportGitHubFollowsJob; +use App\Jobs\LookUpUserOrganizations; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; use App\Services\GitHubAccountScreening; @@ -70,6 +71,7 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri dispatch(new SyncGitHubSshKeysJob($user)); dispatch(new ImportGitHubFollowsJob($user, notify: $user->wasRecentlyCreated)); + LookUpUserOrganizations::dispatchFor($user); Auth::login($user, remember: true); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 348ec66ae..f54a2dd86 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -10,6 +10,7 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\BioProfilesQuery; use App\Queries\FollowersYouKnowQuery; use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; @@ -32,7 +33,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -55,6 +56,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser return Inertia::render('user/show', [ 'user' => $getCountedUser, + 'bioProfiles' => fn (): array => $bioProfiles->get($user), 'pasteisReceived' => $currentUser?->pasteis_mode ? $user->pasteis_received : null, 'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false, 'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser), diff --git a/app/Http/Controllers/UserPreviewController.php b/app/Http/Controllers/UserPreviewController.php index 7691dac4c..fc6bd34a8 100644 --- a/app/Http/Controllers/UserPreviewController.php +++ b/app/Http/Controllers/UserPreviewController.php @@ -5,16 +5,18 @@ namespace App\Http\Controllers; use App\Models\User; +use App\Queries\BioProfilesQuery; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; final readonly class UserPreviewController { - public function show(#[CurrentUser] ?User $currentUser, User $user): JsonResponse + public function show(#[CurrentUser] ?User $currentUser, User $user, BioProfilesQuery $bioProfiles): JsonResponse { if (! $currentUser instanceof User) { return response()->json([ 'user' => $user->loadPublicCounts(), + 'bioProfiles' => $bioProfiles->get($user), 'isFollowing' => false, 'followsYou' => false, 'isMuting' => false, @@ -27,6 +29,7 @@ public function show(#[CurrentUser] ?User $currentUser, User $user): JsonRespons return response()->json([ 'user' => $blockerId === null ? $user->loadCounts($currentUser) : $user, + 'bioProfiles' => $bioProfiles->get($user), 'isFollowing' => $currentUser->isFollowing($user), 'followsYou' => $user->isFollowing($currentUser), 'isMuting' => $currentUser->isMuting($user), diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7aac3eac8..aac05f251 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -10,6 +10,7 @@ use App\Http\Requests\ListNotificationsRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\BioProfilesQuery; use App\Queries\ConversationsQuery; use App\Queries\DevelopersCountQuery; use App\Queries\LiveFollowingQuery; @@ -91,6 +92,7 @@ public function share(Request $request): array 'repository' => config('services.github.repository'), 'auth' => [ 'user' => fn (): ?User => $request->user()?->loadCounts(), + 'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [], 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, 'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null, diff --git a/app/Jobs/LookUpUserOrganizations.php b/app/Jobs/LookUpUserOrganizations.php new file mode 100644 index 000000000..36a2f678c --- /dev/null +++ b/app/Jobs/LookUpUserOrganizations.php @@ -0,0 +1,68 @@ +addDay(); + } + + public function handle(UpdateMentionedOrganizations $action): void + { + $retryAt = $action->handle(self::handles($this->user)); + + if ($retryAt instanceof CarbonImmutable) { + $this->release($retryAt); + } + } + + /** + * @return list + */ + private static function handles(User $user): array + { + return resolve(Mentions::class)->handles("{$user->bio}\n{$user->company}"); + } +} diff --git a/app/Queries/BioProfilesQuery.php b/app/Queries/BioProfilesQuery.php new file mode 100644 index 000000000..bbb6656c4 --- /dev/null +++ b/app/Queries/BioProfilesQuery.php @@ -0,0 +1,40 @@ + + */ + public function get(User $user): array + { + $handles = $this->mentions->handles("{$user->bio}\n{$user->company}"); + + if ($handles === []) { + return []; + } + + return Profile::query() + ->whereIn(DB::raw('LOWER(handle)'), $handles) + ->get(['handle']) + ->mapWithKeys(fn (Profile $profile): array => [Str::lower($profile->handle) => $profile->handle]) + ->all(); + } +} diff --git a/resources/js/components/app-aside.tsx b/resources/js/components/app-aside.tsx index 7ddcf8d55..53765777c 100644 --- a/resources/js/components/app-aside.tsx +++ b/resources/js/components/app-aside.tsx @@ -28,7 +28,7 @@ export function AppAside({ onShowShortcuts: () => void; onSearch: () => void; }) { - const { user } = usePage().props.auth; + const { user, bioProfiles } = usePage().props.auth; const { developersCount } = usePage().props; const keys = useKeyLabels(); const [commitsCount] = useSharedState( @@ -163,7 +163,7 @@ export function AppAside({ {user.bio && (

- +

)} diff --git a/resources/js/components/bio-text.tsx b/resources/js/components/bio-text.tsx index 43c283cf4..6985f4b5c 100644 --- a/resources/js/components/bio-text.tsx +++ b/resources/js/components/bio-text.tsx @@ -1,24 +1,19 @@ +import { Link } from '@inertiajs/react'; import type { ReactNode } from 'react'; import { mentionPattern } from '@/lib/mentions'; +import { show } from '@/routes/user'; -// Like a shortened URL, the "@" stays in the text, so copying the bio keeps the mention. -function GitHubMention({ handle }: { handle: string }) { - const href = `https://github.com/${handle}`; +const mentionClassName = + 'inline-flex h-[1.55em] items-center gap-[0.3em] rounded-md border bg-muted pr-[0.55em] pl-[calc(0.2em-1px)] align-[-0.3em] text-[0.93em] font-medium whitespace-nowrap text-foreground transition-colors hover:border-primary/45 hover:bg-primary-soft hover:text-primary-hover'; +// Like a shortened URL, the "@" stays in the text, so copying the bio keeps the mention. +function MentionContent({ handle }: { handle: string }) { return ( - + <> {/* A handle GitHub doesn't know has no avatar, so the broken image leaves just this placeholder. */} @ {handle} + + ); +} + +function Mention({ + handle, + profile, +}: { + handle: string; + profile: string | null; +}) { + if (profile !== null) { + return ( + + + + ); + } + + const href = `https://github.com/${handle}`; + + return ( + + ); } /** - * A bio or company synced from GitHub, with each tagged user or organization as a badge that opens it on GitHub. + * A bio or company synced from GitHub, with each tagged user or organization as a badge. The badge opens the profile + * on dev/null when there is one, by lowercase handle in `profiles`, and on GitHub otherwise. */ -export function BioText({ bio }: { bio: string }) { +export function BioText({ + bio, + profiles = null, +}: { + bio: string; + profiles?: Record | null; +}) { const parts: ReactNode[] = []; let position = 0; for (const match of bio.matchAll(mentionPattern)) { + // PHP sends no profiles as an empty array, whose "length" isn't a handle. + const profile = profiles?.[match[1].toLowerCase()]; + parts.push( bio.slice(position, match.index), - , + , ); position = match.index + match[0].length; } diff --git a/resources/js/components/user-preview.tsx b/resources/js/components/user-preview.tsx index dfc245cbd..80ef633da 100644 --- a/resources/js/components/user-preview.tsx +++ b/resources/js/components/user-preview.tsx @@ -31,6 +31,7 @@ import { show as userPreview } from '@/routes/user-preview'; import type { Commit, CursorPaginated, Profile, User } from '@/types'; type PreviewData = { + bioProfiles: Record; isFollowing: boolean; followsYou: boolean; isMuting: boolean; @@ -478,7 +479,7 @@ function UserPreviewContent({ {user.bio && !data?.isUnavailable && (

- +

)} diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 28cc13e3b..df299334b 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -62,6 +62,7 @@ function pluralize(count: number, singular: string, plural: string) { export default function Show({ user, + bioProfiles, pasteisReceived, isFollowing, followsYou, @@ -78,6 +79,7 @@ export default function Show({ readme, }: { user: Profile; + bioProfiles: Record; pasteisReceived: number | null; isFollowing: boolean; followsYou: boolean; @@ -265,7 +267,7 @@ export default function Show({ {user.bio && (

- +

)} @@ -274,7 +276,10 @@ export default function Show({
  • - +
  • )} diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index b8d2bfd0e..d1ace0527 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -44,6 +44,7 @@ export type Profile = User & { */ export type Auth = { user: Profile | null; + bioProfiles: Record; isAdmin: boolean; automaticTranslation: boolean; mustReadCommitRules: boolean; diff --git a/tests/Feature/Commands/LookUpUserOrganizationsTest.php b/tests/Feature/Commands/LookUpUserOrganizationsTest.php new file mode 100644 index 000000000..991bc1e66 --- /dev/null +++ b/tests/Feature/Commands/LookUpUserOrganizationsTest.php @@ -0,0 +1,23 @@ +create(['bio' => 'Building @laravel']); + $company = User::factory()->create(['company' => '@laravel']); + User::factory()->create(['bio' => 'Building things', 'company' => null]); + + $this->artisan('users:look-up-organizations') + ->expectsOutputToContain('Queued the organizations of 2 users to be looked up.') + ->assertSuccessful(); + + Queue::assertPushed(LookUpUserOrganizations::class, 2); + Queue::assertPushed(LookUpUserOrganizations::class, fn (LookUpUserOrganizations $job): bool => $job->user->is($bio)); + Queue::assertPushed(LookUpUserOrganizations::class, fn (LookUpUserOrganizations $job): bool => $job->user->is($company)); +}); diff --git a/tests/Feature/Controllers/SessionControllerTest.php b/tests/Feature/Controllers/SessionControllerTest.php index ce7aaffdf..00227eb13 100644 --- a/tests/Feature/Controllers/SessionControllerTest.php +++ b/tests/Feature/Controllers/SessionControllerTest.php @@ -4,6 +4,7 @@ use App\Enums\Log; use App\Jobs\ImportGitHubFollowsJob; +use App\Jobs\LookUpUserOrganizations; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; use Illuminate\Support\Facades\Queue; @@ -177,6 +178,22 @@ Queue::assertPushed(SyncGitHubSshKeysJob::class, fn (SyncGitHubSshKeysJob $job): bool => $job->user->is($user)); }); +it('queues a lookup of the organizations the bio of the user mentions on login', function (): void { + fakeGitHubSponsorsListing(); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + 'company' => '@github', + ])); + + $this->get(route('login.store')); + + $user = User::query()->where('github_id', '583231')->sole(); + + Queue::assertPushed(LookUpUserOrganizations::class, fn (LookUpUserOrganizations $job): bool => $job->user->is($user)); +}); + it('queues an import of the github follows of the user on login, notifying only for new users', function (bool $exists, bool $notify): void { fakeGitHubSponsorsListing(); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 3bca2cec5..14c5c007a 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -6,6 +6,7 @@ use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; use App\Models\LinkPreview; +use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -49,6 +50,32 @@ ->where('commits.data.1.body', 'Older')); }); +it('says which accounts the bio and company mention are on dev/null', function (): void { + Profile::factory()->organization()->create(['handle' => 'laravel']); + User::factory()->create(['handle' => 'TaylorOtwell']); + User::factory()->create(['handle' => 'octocat', 'bio' => 'Thanks @taylorotwell and @nobody', 'company' => '@laravel']); + + $this->get('/@octocat') + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('bioProfiles', ['taylorotwell' => 'TaylorOtwell', 'laravel' => 'laravel'])); +}); + +it('shares which accounts the bio of the signed-in user mentions are on dev/null', function (): void { + Profile::factory()->organization()->create(['handle' => 'laravel']); + $user = User::factory()->create(['bio' => 'Building @laravel']); + + $this->actingAs($user) + ->get('/@laravel') + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('auth.bioProfiles', ['laravel' => 'laravel'])); + + auth()->logout(); + + $this->get('/@laravel') + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('auth.bioProfiles', [])); +}); + it('only lets guests see the local time of users who opted in', function (): void { User::factory()->create(['handle' => 'shared', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]); User::factory()->create(['handle' => 'private', 'timezone' => 'Europe/Berlin']); diff --git a/tests/Feature/Controllers/UserPreviewControllerTest.php b/tests/Feature/Controllers/UserPreviewControllerTest.php index 781b41abf..3f8b4078f 100644 --- a/tests/Feature/Controllers/UserPreviewControllerTest.php +++ b/tests/Feature/Controllers/UserPreviewControllerTest.php @@ -4,6 +4,7 @@ use App\Actions\DeleteUser; use App\Models\Commit; +use App\Models\Profile; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -33,6 +34,16 @@ ->assertJsonMissingPath('user.github_id'); }); +it('says which accounts the bio mentions are on dev/null', function (bool $guest): void { + Profile::factory()->organization()->create(['handle' => 'Laravel']); + User::factory()->create(['handle' => 'octocat', 'bio' => 'Building @laravel, previously @nobody']); + + ($guest ? $this : $this->actingAs(User::factory()->create())) + ->getJson('/@octocat/preview') + ->assertOk() + ->assertJsonPath('bioProfiles', ['laravel' => 'Laravel']); +})->with(['guest' => true, 'member' => false]); + it('does not count deleted commits in the user preview', function (): void { $user = User::factory()->create(['handle' => 'octocat']); diff --git a/tests/Unit/Actions/UpdateUserProfileTest.php b/tests/Unit/Actions/UpdateUserProfileTest.php index 045003b0e..28c8d84e2 100644 --- a/tests/Unit/Actions/UpdateUserProfileTest.php +++ b/tests/Unit/Actions/UpdateUserProfileTest.php @@ -3,10 +3,12 @@ declare(strict_types=1); use App\Actions\UpdateUserProfile; +use App\Jobs\LookUpUserOrganizations; use App\Models\SshKey; use App\Models\User; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; use Tests\Support\SshKeyPair; it('mirrors the github profile of the user', function (): void { @@ -44,6 +46,20 @@ ->github_synced_at->toDateTimeString()->toBe(now()->toDateTimeString()); }); +it('queues a lookup of the organizations the synced bio mentions', function (bool $mentions): void { + Queue::fake(); + + $user = User::factory()->create(['github_id' => '583231']); + + fakeGitHubAccount($user, ['login' => 'octocat', 'bio' => $mentions ? 'Building @laravel' : 'Building things', 'company' => null]); + + resolve(UpdateUserProfile::class)->handle($user); + + $mentions + ? Queue::assertPushed(LookUpUserOrganizations::class, fn (LookUpUserOrganizations $job): bool => $job->user->is($user)) + : Queue::assertNotPushed(LookUpUserOrganizations::class); +})->with(['a mention' => true, 'no mention' => false]); + it('clears profile fields removed on github', function (): void { $user = User::factory()->create(); diff --git a/tests/Unit/BioText.test.tsx b/tests/Unit/BioText.test.tsx index bf28eb7e3..2433a23a3 100644 --- a/tests/Unit/BioText.test.tsx +++ b/tests/Unit/BioText.test.tsx @@ -41,3 +41,29 @@ test('leaves emails, package names and domains as plain text', () => { assert.ok(!html.includes(' { + const html = renderToStaticMarkup( + , + ); + + assert.ok(html.includes('href="/@laravel"')); + assert.ok(html.includes('aria-label="@Laravel"')); + assert.ok(html.includes('src="https://github.com/Laravel.png?size=40"')); + assert.equal(html.match(/target="_blank"/g)?.length, 1); + assert.ok(html.includes('href="https://github.com/vercel"')); +}); + +test('opens GitHub when no account is on dev/null, sent as an empty array', () => { + const html = renderToStaticMarkup( + } + />, + ); + + assert.ok(html.includes('href="https://github.com/length"')); +}); diff --git a/tests/Unit/Jobs/LookUpUserOrganizationsTest.php b/tests/Unit/Jobs/LookUpUserOrganizationsTest.php new file mode 100644 index 000000000..ec0be97f7 --- /dev/null +++ b/tests/Unit/Jobs/LookUpUserOrganizationsTest.php @@ -0,0 +1,67 @@ + Http::response(['login' => 'laravel', 'id' => 958072, 'type' => 'Organization', 'name' => 'Laravel', 'avatar_url' => null, 'bio' => null, 'location' => null, 'blog' => '', 'company' => null]), + 'api.github.com/users/*' => Http::response(status: 404), + ]); + + User::factory()->create(['handle' => 'taylorotwell']); + $user = User::factory()->create(['bio' => 'Thanks @TaylorOtwell and @nobody', 'company' => '@laravel']); + + dispatch_sync(new LookUpUserOrganizations($user)); + + expect(Profile::query()->where('type', ProfileType::Organization)->pluck('handle')->all())->toBe(['laravel']); + Http::assertSentCount(2); + Http::assertNotSent(fn ($request): bool => str_contains((string) $request->url(), 'taylorotwell')); +}); + +it('runs again once the rate limit resets when it cut the lookups short', function (): void { + Http::fake(); + + $resetsAt = now()->addMinutes(30)->startOfSecond(); + Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt); + $job = new LookUpUserOrganizations(User::factory()->create(['bio' => 'Building @laravel']))->withFakeQueueInteractions(); + + $job->handle(resolve(UpdateMentionedOrganizations::class)); + + Http::assertNothingSent(); + $job->assertReleased($resetsAt); +}); + +it('is done once it looked every handle up', function (): void { + Http::fake(['api.github.com/users/*' => Http::response(status: 404)]); + + $job = new LookUpUserOrganizations(User::factory()->create(['bio' => 'Building @laravel']))->withFakeQueueInteractions(); + + $job->handle(resolve(UpdateMentionedOrganizations::class)); + + $job->assertNotReleased(); +}); + +it('is queued only for a bio or company that mentions someone', function (?string $bio, ?string $company, bool $queued): void { + Queue::fake(); + + $user = User::factory()->create(['bio' => $bio, 'company' => $company]); + + expect(LookUpUserOrganizations::dispatchFor($user))->toBe($queued); + $queued + ? Queue::assertPushed(LookUpUserOrganizations::class, fn (LookUpUserOrganizations $job): bool => $job->user->is($user)) + : Queue::assertNothingPushed(); +})->with([ + 'a bio mention' => ['Building @laravel', null, true], + 'a company mention' => [null, '@laravel', true], + 'no mention' => ['Building things', 'Acme', false], + 'nothing' => [null, null, false], +]); diff --git a/tests/Unit/Queries/BioProfilesQueryTest.php b/tests/Unit/Queries/BioProfilesQueryTest.php new file mode 100644 index 000000000..ae1b2b0c0 --- /dev/null +++ b/tests/Unit/Queries/BioProfilesQueryTest.php @@ -0,0 +1,25 @@ +organization()->create(['handle' => 'Laravel']); + User::factory()->create(['handle' => 'taylorotwell']); + $user = User::factory()->create(['bio' => 'Thanks @TaylorOtwell, not `@laravel` in code, and @nobody', 'company' => '@laravel']); + + expect(resolve(BioProfilesQuery::class)->get($user))->toEqual(['laravel' => 'Laravel', 'taylorotwell' => 'taylorotwell']); +}); + +it('asks nothing for a bio that mentions no one', function (): void { + $user = User::factory()->create(['bio' => 'Building things', 'company' => null]); + + DB::enableQueryLog(); + + expect(resolve(BioProfilesQuery::class)->get($user))->toBe([]); + expect(DB::getQueryLog())->toBe([]); +});