diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2d9ded45b..34b2d7cde 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -148,9 +148,11 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. Following or unfollowing from a log updates the controls, counts, and live-following sidebar without navigating away: Latest and Trending keep their loaded commits and ranking, while Following updates its commits and empty-state suggestions. 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. -**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). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list. +**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 (behind your stack, how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list. -Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions](resources/js/components/who-to-follow.tsx), [suggestion eligibility](app/Queries/SuggestedUsersQuery.php), [appearance settings](resources/js/pages/appearance/update.tsx), [notifications page](resources/js/pages/notification/index.tsx). +For a week after you sign up, the developers behind your stack are suggested during onboarding and in Who to follow, ahead of the other people you might know and alongside a few popular developers: of your 10 most recently pushed public repositories, /dev/null reads the `composer.json` and `package.json` at the root of the ones that aren't forks, takes the 20 Composer and npm packages used by the most of them (leaving out `@types/*`), and suggests whoever among the top five GitHub contributors to each package's repository is on /dev/null. When those packages come from fewer than 20 repositories, the repositories you starred most recently, other than your own, fill the rest, so a stack that lives in an organization's repositories or outside Composer and npm still finds its people. On profiles and commit pages, they are among the regular suggestions that fill the places the page's own suggestions leave. The lookups stop after 20 seconds, keeping whatever was found by then. They are suggested rather than followed for you, since anyone you follow can message you in Remotes. Only who to suggest is kept, for that week, and nothing about your repositories. + +Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [the developers behind your stack](app/Actions/CreateStackSuggestions.php), [suggestions](resources/js/components/who-to-follow.tsx), [suggestion eligibility](app/Queries/SuggestedUsersQuery.php), [appearance settings](resources/js/pages/appearance/update.tsx), [notifications page](resources/js/pages/notification/index.tsx). ### Following, Latest, and Trending logs diff --git a/app/Actions/CreateStackSuggestions.php b/app/Actions/CreateStackSuggestions.php new file mode 100644 index 000000000..92492ba64 --- /dev/null +++ b/app/Actions/CreateStackSuggestions.php @@ -0,0 +1,42 @@ +id}"; + } + + public function handle(User $user): void + { + $ids = User::query() + ->whereIn('github_id', $this->dependencyContributors->get($user->handle)) + ->whereKeyNot($user->id) + ->pluck('id') + ->all(); + + Cache::put(self::key($user), $ids, now()->addDays(self::Days)); + } +} diff --git a/app/Actions/DeleteUser.php b/app/Actions/DeleteUser.php index 479931c44..f80d42c8a 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -70,6 +70,8 @@ public function handle(User $user): void Cache::forget($key); } + Cache::forget(CreateStackSuggestions::key($user)); + return $imagePaths; }); diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index 8725d47a1..fa5ee0757 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\SuggestDependencyContributorsJob; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; use App\Services\GitHubAccountScreening; @@ -59,6 +60,10 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri dispatch(new SyncGitHubSshKeysJob($user)); dispatch(new ImportGitHubFollowsJob($user, notify: $user->wasRecentlyCreated)); + if ($user->wasRecentlyCreated) { + dispatch(new SuggestDependencyContributorsJob($user)); + } + Auth::login($user, remember: true); $request->session()->regenerate(); diff --git a/app/Jobs/SuggestDependencyContributorsJob.php b/app/Jobs/SuggestDependencyContributorsJob.php new file mode 100644 index 000000000..70397bf84 --- /dev/null +++ b/app/Jobs/SuggestDependencyContributorsJob.php @@ -0,0 +1,31 @@ +handle($this->user); + } +} diff --git a/app/Queries/DependencyContributorsQuery.php b/app/Queries/DependencyContributorsQuery.php new file mode 100644 index 000000000..6a77cd3a2 --- /dev/null +++ b/app/Queries/DependencyContributorsQuery.php @@ -0,0 +1,250 @@ +[\w-][\w.-]{0,38}/[\w-][\w.-]{0,99}?)(?:\.git)?/?(?:#.*)?$~i'; + + public function __construct(private GitHub $github) + { + // + } + + /** + * The GitHub IDs of the people behind a user's stack: the top contributors to the repositories of the Composer and + * npm packages their own public repositories depend on, read from the composer.json and package.json at their root. + * When that finds fewer repositories than it may ask about, the ones they starred most recently fill the rest. + * + * @return array + */ + public function get(string $login): array + { + $deadline = now()->addSeconds(self::Seconds); + $inTime = fn (): bool => now()->isBefore($deadline); + + $repositories = $this->github->request() + ->get("users/{$login}/repos", ['type' => 'owner', 'sort' => 'pushed', 'per_page' => self::Repositories]) + ->throw() + ->collect() + ->mapInto(Fluent::class) + ->reject(fn (Fluent $repository): bool => $repository->boolean('fork')) + ->flatMap(fn (Fluent $repository): array => $inTime() ? $this->dependencies($repository->string('full_name')->value()) : []) + ->countBy() + ->sortDesc() + ->take(self::Packages) + ->keys() + ->map(fn (string $package): ?string => $inTime() ? $this->repository($package) : null) + ->filter() + ->unique(); + + return $repositories + ->concat($repositories->count() < self::Packages && $inTime() ? $this->starred($login) : []) + ->unique() + ->take(self::Packages) + ->flatMap(fn (string $repository): array => $inTime() ? $this->contributors($repository) : []) + ->unique() + ->values() + ->all(); + } + + /** + * The packages a repository depends on, in and out of development, as "composer:vendor/name" and "npm:name". + * + * @return list + */ + private function dependencies(string $repository): array + { + return [ + ...$this->packages('composer', $this->manifest($repository, 'composer.json'), ['require', 'require-dev']), + ...$this->packages('npm', $this->manifest($repository, 'package.json'), ['dependencies', 'devDependencies']), + ]; + } + + /** + * The package names in the given sections of a manifest. Platform requirements such as php and ext-json have no + * vendor, so Composer's naming rules leave them out along with anything that isn't a package name. Types from @types + * are left out too: DefinitelyTyped's contributors wrote them, not the people behind the packages they describe. + * + * @param 'composer'|'npm' $registry + * @param Fluent $manifest + * @param list $sections + * @return array + */ + private function packages(string $registry, Fluent $manifest, array $sections): array + { + $pattern = match ($registry) { + 'composer' => '#^[a-z0-9]([_.-]?[a-z0-9]+)*/[a-z0-9](([_.]|-{1,2})?[a-z0-9]+)*$#', + 'npm' => '#^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$#', + }; + + return collect($sections) + ->flatMap(fn (string $section): array => array_keys($manifest->array($section))) + ->filter(fn (int|string $name): bool => preg_match($pattern, (string) $name) === 1) + ->reject(fn (int|string $name): bool => Str::startsWith((string) $name, '@types/')) + ->map(fn (int|string $name): string => "{$registry}:{$name}") + ->unique() + ->values() + ->all(); + } + + /** + * A manifest at the root of a repository's default branch, empty when there is none. Only the first 100 KB are + * asked for: a real manifest is far smaller, and a bigger one stops being valid JSON once cut, so it is skipped. + * When GitHub throttles the reads, the lookup fails so it is tried again later, instead of quietly finding nobody. + * + * @return Fluent + */ + private function manifest(string $repository, string $file): Fluent + { + try { + $manifest = $this->http() + ->withHeaders(['Range' => 'bytes=0-102399']) + ->get("https://raw.githubusercontent.com/{$repository}/HEAD/{$file}") + ->throwIfStatus(429) + ->json(); + } catch (ConnectionException) { + return new Fluent; + } + + return new Fluent(is_array($manifest) ? $manifest : []); + } + + /** + * The GitHub repository a package is developed in, like "laravel/framework", as its registry lists it. A package + * without one is remembered too, so it doesn't cost the next person a request; a failed request isn't, so it's retried. + * When the registry throttles it, the lookup fails so it is tried again later, instead of quietly finding nobody. + */ + private function repository(string $package): ?string + { + try { + $repository = Cache::remember('dependency-repository:'.hash('sha256', $package), 86400, function () use ($package): string|false { + $name = Str::after($package, ':'); + + if (Str::startsWith($package, 'composer:')) { + $url = data_get($this->registry("https://repo.packagist.org/p2/{$name}.json"), ['packages', $name, 0, 'source', 'url']); + } else { + $document = $this->registry('https://registry.npmjs.org/'.str_replace('/', '%2f', $name).'/latest'); + + // npm lists the repository either as an object with a URL or as the URL on its own. + $url = data_get($document, 'repository.url', data_get($document, 'repository')); + } + + return is_string($url) && preg_match(self::RepositoryUrl, $url, $matches) === 1 ? $matches['repository'] : false; + }); + } catch (HttpClientException $httpClientException) { + throw_if($httpClientException instanceof RequestException && $httpClientException->response->tooManyRequests(), $httpClientException); + + return null; + } + + return $repository === false ? null : $repository; + } + + /** + * The repositories a user starred most recently, other than their own. They speak for people whose own repositories + * say little about what they use: when their work lives in an organization's repositories, or outside Composer and npm. + * + * @return array + */ + private function starred(string $login): array + { + return $this->github->request() + ->get("users/{$login}/starred", ['per_page' => self::Packages]) + ->throw() + ->collect() + ->mapInto(Fluent::class) + ->reject(fn (Fluent $repository): bool => strcasecmp($repository->string('owner.login')->value(), $login) === 0) + ->map(fn (Fluent $repository): string => $repository->string('full_name')->value()) + ->values() + ->all(); + } + + /** + * The GitHub IDs of the people among a repository's top contributors, remembered for a day so that everyone whose + * packages come from it shares one request. A repository GitHub no longer has, one without history, or one whose + * history GitHub finds too large to list has none. When GitHub throttles it, the lookup fails so it is tried again + * later, instead of quietly finding nobody. + * + * @return array + */ + private function contributors(string $repository): array + { + try { + return Cache::remember("dependency-contributors:{$repository}", 86400, function () use ($repository): array { + $response = $this->github->request()->get("repos/{$repository}/contributors", ['per_page' => self::Contributors]); + + if ($response->notFound() || $response->noContent() || $response->fluent()->string('message')->contains('too large')) { + return []; + } + + return $response->throw() + ->collect() + ->where('type', 'User') + ->mapInto(Fluent::class) + ->map(fn (Fluent $contributor): string => $contributor->string('id')->value()) + ->values() + ->all(); + }); + } catch (HttpClientException $httpClientException) { + throw_if($httpClientException instanceof RequestException && in_array($httpClientException->response->status(), [403, 429], true), $httpClientException); + + return []; + } + } + + /** + * A registry's document about a package, empty when the registry doesn't know it. + * + * @return array + */ + private function registry(string $url): array + { + $response = $this->http()->get($url); + + return $response->notFound() ? [] : (array) $response->throw()->json(); + } + + private function http(): PendingRequest + { + return Http::withUserAgent(sprintf('devnull/1.0 (+%s)', config()->string('app.url')))->connectTimeout(3)->timeout(5); + } +} diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 2bbc91484..f270f544c 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -4,6 +4,7 @@ namespace App\Queries; +use App\Actions\CreateStackSuggestions; use App\Enums\CommitStatus; use App\Models\Commit; use App\Models\User; @@ -11,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Random\Engine\Mt19937; use Random\Randomizer; @@ -42,9 +44,9 @@ SQL; /** - * People the user does not follow yet: a mix of people their follows already follow and a few - * popular people picked from the 20 most followed. On a profile or discussion page, relevant developers come - * first. The regular suggestions fill any remaining places. + * People the user does not follow yet: a mix of the people behind their stack and people their follows already + * follow, and a few popular people picked from the 20 most followed. On a profile or discussion page, relevant + * developers come first. The regular suggestions fill any remaining places. * * The day's candidates are split into pages of that mix, and each round shows the next page, * going back to the first one after the last. @@ -86,6 +88,7 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, 'users.id', ) ->select(['users.*', 'network.followed_by_following_count']) + ->selectRaw('false as behind_your_stack') ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) ->whereIn('users.id', $this->committers($user)) ->orderByDesc('followed_by_following_count') @@ -100,6 +103,7 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, ->whereNotExists($this->followedByFollowing($user, $following)) ->select('users.*') ->selectRaw('0 as followed_by_following_count') + ->selectRaw('false as behind_your_stack') ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) ->orderByDesc('followers_count') ->latest('users.id'), 'users') @@ -109,6 +113,10 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, ->limit(20) ->get(); + $stack = $this->stack($user, $followingIds, $following); + $network = $stack->concat($network->whereNotIn('id', $stack->pluck('id'))); + $popularPool = $popularPool->whereNotIn('id', $stack->pluck('id')); + $pages = $this->pages($randomizer, $network, $popularPool, $limit); if ($contextual->isNotEmpty()) { @@ -165,6 +173,7 @@ private function contextual(User $viewer, User|Commit $context, QueryBuilder $fo ) ->select('users.*') ->selectRaw('0 as followed_by_following_count') + ->selectRaw('false as behind_your_stack') ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($viewer)]) ->orderByDesc('followers_count') ->latest('users.id') @@ -209,6 +218,33 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ return array_map(fn (array $page): Collection => new Collection($page), $pages); } + /** + * The people behind the packages the user's repositories depend on, looked up when they signed up. They come + * before the people the user might know, whom the people they follow already lead them to. + * + * @param BelongsToMany $following + * @return Collection + */ + private function stack(User $user, QueryBuilder $followingIds, BelongsToMany $following): Collection + { + $ids = Cache::get(CreateStackSuggestions::key($user), []); + + if ($ids === []) { + return new Collection; + } + + return $this->candidates($user, $followingIds) + ->whereKey($ids) + ->whereIn('users.id', $this->committers($user)) + ->select('users.*') + ->selectRaw('0 as followed_by_following_count') + ->selectRaw('true as behind_your_stack') + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->orderByDesc('followers_count') + ->latest('users.id') + ->get(); + } + /** * @return Builder */ diff --git a/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index a06f04722..37496b915 100644 --- a/resources/js/components/who-to-follow.tsx +++ b/resources/js/components/who-to-follow.tsx @@ -32,6 +32,10 @@ import { show } from '@/routes/user'; import type { SuggestedUser } from '@/types'; function reasonFor(user: SuggestedUser, format = formatCount): string | null { + if (user.behind_your_stack) { + return 'Behind your stack'; + } + if (user.followed_by_following_count > 0) { return `Followed by ${format(user.followed_by_following_count)} you follow`; } diff --git a/resources/js/pages/onboarding/show.tsx b/resources/js/pages/onboarding/show.tsx index d03c6becf..2509d7b41 100644 --- a/resources/js/pages/onboarding/show.tsx +++ b/resources/js/pages/onboarding/show.tsx @@ -360,7 +360,16 @@ export default function Show({ actions={ <> {selectedLane === preferredLane ? ( - ) : ( diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index a2c085b65..082c0b4dc 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -52,7 +52,13 @@ export default function Show() { company, location, website, the email address GitHub shares, whether you have a Sponsors listing, your public SSH key fingerprints, and - who you follow and who follows you. + who you follow and who follows you. When you + sign up, the composer.json and{' '} + package.json of your recently + pushed public repositories, and the repositories + you starred most recently, are read to suggest + the people behind what you use, and only who to + suggest is kept, for a week. , <> diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index b8d2bfd0e..d50583af5 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -63,4 +63,5 @@ export type SuggestedUser = User & { followed_by_following_count: number; followers_count: number; follows_you: boolean; + behind_your_stack: boolean; }; diff --git a/tests/Feature/Controllers/OnboardingControllerTest.php b/tests/Feature/Controllers/OnboardingControllerTest.php index 035137947..e6d636a2a 100644 --- a/tests/Feature/Controllers/OnboardingControllerTest.php +++ b/tests/Feature/Controllers/OnboardingControllerTest.php @@ -2,10 +2,12 @@ declare(strict_types=1); +use App\Actions\CreateStackSuggestions; use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; use App\Models\User; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; it('renders the onboarding for a new user', function (): void { @@ -72,6 +74,22 @@ expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'as "followed_by_following_count"')))->toBeEmpty(); }); +it('suggests the people behind the stack of the user when asked for the suggestions again', function (): void { + $user = User::factory()->notOnboarded()->create(); + $contributor = User::factory()->has(Commit::factory())->create(); + + Cache::put(CreateStackSuggestions::key($user), [$contributor->id], 60); + + $this->actingAs($user)->withHeaders([ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + 'X-Inertia-Partial-Component' => 'onboarding/show', + 'X-Inertia-Partial-Data' => 'suggestions', + ])->get(route('onboarding.show'))->assertOk() + ->assertJsonPath('props.suggestions.0.id', $contributor->id) + ->assertJsonPath('props.suggestions.0.behind_your_stack', true); +}); + it('is not found once the user has onboarded', function (): void { $user = User::factory()->create(); diff --git a/tests/Feature/Controllers/SessionControllerTest.php b/tests/Feature/Controllers/SessionControllerTest.php index 18ebd5fb5..88ed75a5e 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\SuggestDependencyContributorsJob; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; use Illuminate\Support\Facades\Queue; @@ -199,6 +200,35 @@ 'existing user' => [true, false], ]); +it('queues suggestions from the stack of a new user', function (): void { + fakeGitHubSponsorsListing(); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + ])); + + $this->get(route('login.store')); + + $user = User::query()->where('github_id', '583231')->sole(); + + Queue::assertPushed(SuggestDependencyContributorsJob::class, fn (SuggestDependencyContributorsJob $job): bool => $job->user->is($user)); +}); + +it('does not queue suggestions from the stack of an existing user', function (): void { + fakeGitHubSponsorsListing(); + User::factory()->create(['github_id' => '583231']); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + ])); + + $this->get(route('login.store')); + + Queue::assertNotPushed(SuggestDependencyContributorsJob::class); +}); + it('redirects to the intended url after login', function (): void { fakeGitHubSponsorsListing(); diff --git a/tests/Unit/Actions/CreateStackSuggestionsTest.php b/tests/Unit/Actions/CreateStackSuggestionsTest.php new file mode 100644 index 000000000..2ab69b5f9 --- /dev/null +++ b/tests/Unit/Actions/CreateStackSuggestionsTest.php @@ -0,0 +1,36 @@ +create(['handle' => 'jess']); + $contributor = User::factory()->create(); + + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response([ + 'packages' => ['laravel/framework' => [['source' => ['url' => 'https://github.com/laravel/framework.git']]]], + ]), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response([ + ['id' => (int) $contributor->github_id, 'type' => 'User'], + ['id' => (int) $user->github_id, 'type' => 'User'], + ['id' => 1_000_000_000, 'type' => 'User'], + ]), + ]); + + resolve(CreateStackSuggestions::class)->handle($user); + + expect(Cache::get(CreateStackSuggestions::key($user)))->toBe([$contributor->id]); + + $this->travel(7)->days(); + + expect(Cache::has(CreateStackSuggestions::key($user)))->toBeFalse(); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index df3242c95..5c6bd17f9 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\CreateCommitTranslation; +use App\Actions\CreateStackSuggestions; use App\Actions\DeleteUser; use App\Actions\UpdateCommitHotScores; use App\Ai\Agents\CommitTranslator; @@ -303,6 +304,17 @@ $this->assertModelMissing($user); }); +it('erases who is suggested from the stack of a deleted user', function (): void { + [$user, $other, $contributor] = User::factory()->count(3)->create(); + Cache::put(CreateStackSuggestions::key($user), [$contributor->id], 60); + Cache::put(CreateStackSuggestions::key($other), [$contributor->id], 60); + + resolve(DeleteUser::class)->handle($user); + + expect(Cache::has(CreateStackSuggestions::key($user)))->toBeFalse() + ->and(Cache::has(CreateStackSuggestions::key($other)))->toBeTrue(); +}); + it('erases the voice notes a deleted user sent in remotes, keeping the ones sent to them', function (): void { Storage::fake(); diff --git a/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php b/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php new file mode 100644 index 000000000..3cc9473c6 --- /dev/null +++ b/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php @@ -0,0 +1,19 @@ +create(['handle' => 'jess']); + + Http::fake(['api.github.com/users/jess/*' => Http::response([])]); + + dispatch_sync(new SuggestDependencyContributorsJob($user)); + + expect(Cache::get(CreateStackSuggestions::key($user)))->toBe([]); +}); diff --git a/tests/Unit/Queries/DependencyContributorsQueryTest.php b/tests/Unit/Queries/DependencyContributorsQueryTest.php new file mode 100644 index 000000000..93d5a14fe --- /dev/null +++ b/tests/Unit/Queries/DependencyContributorsQueryTest.php @@ -0,0 +1,349 @@ + + */ +function packagistMetadata(string $package, string $url): array +{ + return ['packages' => [$package => [['name' => $package, 'source' => ['type' => 'git', 'url' => $url]]]]]; +} + +/** + * A repository's contributors the way GitHub lists them, people and bots alike. + * + * @param list $users + * @param list $bots + * @return list + */ +function githubContributors(array $users, array $bots = []): array +{ + return [ + ...array_map(fn (int $id): array => ['id' => $id, 'type' => 'User'], $users), + ...array_map(fn (int $id): array => ['id' => $id, 'type' => 'Bot'], $bots), + ]; +} + +it("finds the people behind the packages a user's repositories depend on", function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([ + ['full_name' => 'jess/blog', 'fork' => false], + ['full_name' => 'jess/framework', 'fork' => true], + ]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response([ + 'require' => ['php' => '^8.5', 'ext-mbstring' => '*', 'laravel/framework' => '^13.0'], + 'require-dev' => ['pestphp/pest' => '^5.0'], + ]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response([ + 'dependencies' => ['vite' => '^8.0', '@inertiajs/core' => '^3.0'], + 'devDependencies' => ['@inertiajs/react' => '^3.0', '@types/node' => '^24.0'], + ]), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'repo.packagist.org/p2/pestphp/pest.json' => Http::response(packagistMetadata('pestphp/pest', 'https://github.com/pestphp/pest.git')), + 'registry.npmjs.org/vite/latest' => Http::response(['repository' => ['type' => 'git', 'url' => 'git+https://github.com/vitejs/vite.git#main']]), + 'registry.npmjs.org/@inertiajs*' => Http::response(['repository' => 'github:inertiajs/inertia']), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(githubContributors([1], bots: [2])), + 'api.github.com/repos/pestphp/pest/contributors*' => Http::response(githubContributors([3, 1])), + 'api.github.com/repos/vitejs/vite/contributors*' => Http::response(githubContributors([4])), + 'api.github.com/repos/inertiajs/inertia/contributors*' => Http::response(githubContributors([5])), + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe(['1', '3', '4', '5']); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/users/jess/repos?type=owner&sort=pushed&per_page=10'); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/laravel/framework/contributors?per_page=5'); + Http::assertSent(fn (Request $request): bool => str_starts_with($request->url(), 'https://repo.packagist.org') && $request->hasHeader('User-Agent', 'devnull/1.0 (+'.config('app.url').')')); + + expect(Http::recorded(fn (Request $request): bool => str_contains($request->url(), 'inertiajs/inertia/contributors')))->toHaveCount(1); +}); + +it('looks up the twenty packages the most repositories depend on, counting each repository once', function (): void { + $packages = array_map(fn (int $number): string => "acme/package-{$number}", range(1, 22)); + + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([ + ['full_name' => 'jess/one', 'fork' => false], + ['full_name' => 'jess/two', 'fork' => false], + ]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/one/HEAD/composer.json' => Http::response([ + 'require' => array_fill_keys($packages, '*'), + 'require-dev' => ['acme/package-21' => '*'], + ]), + 'raw.githubusercontent.com/jess/two/HEAD/composer.json' => Http::response(['require' => ['acme/package-22' => '*']]), + 'raw.githubusercontent.com/*/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/*' => Http::response('', 404), + ]); + + resolve(DependencyContributorsQuery::class)->get('jess'); + + $lookedUp = Http::recorded(fn (Request $request): bool => str_starts_with($request->url(), 'https://repo.packagist.org')) + ->map(fn (array $exchange): string => $exchange[0]->url()); + + expect($lookedUp)->toHaveCount(20) + ->and($lookedUp->first())->toBe('https://repo.packagist.org/p2/acme/package-22.json') + ->and($lookedUp->last())->toBe('https://repo.packagist.org/p2/acme/package-19.json'); +}); + +it('fills in with the repositories the user starred most recently, other than their own', function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([ + ['full_name' => 'tldraw/tldraw', 'owner' => ['login' => 'tldraw']], + ['full_name' => 'Jess/dotfiles', 'owner' => ['login' => 'Jess']], + ['full_name' => 'laravel/framework', 'owner' => ['login' => 'laravel']], + ]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(githubContributors([1])), + 'api.github.com/repos/tldraw/tldraw/contributors*' => Http::response(githubContributors([2])), + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe(['1', '2']); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/users/jess/starred?per_page=20'); + + expect(Http::recorded(fn (Request $request): bool => str_contains($request->url(), 'laravel/framework/contributors')))->toHaveCount(1); +}); + +it('asks about twenty repositories at most, each once', function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([ + ['full_name' => 'laravel/framework', 'owner' => ['login' => 'laravel']], + ...array_map( + fn (int $number): array => ['full_name' => "acme/repository-{$number}", 'owner' => ['login' => 'acme']], + range(1, 24), + ), + ]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/*' => Http::response([]), + ]); + + resolve(DependencyContributorsQuery::class)->get('jess'); + + $askedAbout = Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/contributors')) + ->map(fn (array $exchange): string => Str::between($exchange[0]->url(), '/repos/', '/contributors')); + + expect($askedAbout)->toHaveCount(20) + ->and($askedAbout->first())->toBe('laravel/framework') + ->and($askedAbout->last())->toBe('acme/repository-19'); +}); + +it('reads the stars only while the packages leave room among the repositories to ask about', function (bool $shared, int $stars): void { + $packages = array_map(fn (int $number): string => "acme/package-{$number}", range(1, 20)); + + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => array_fill_keys($packages, '*')]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/*' => function (Request $request) use ($shared): PromiseInterface { + $package = Str::between($request->url(), '/p2/', '.json'); + $repository = $shared && $package === 'acme/package-20' ? 'acme/package-19' : $package; + + return Http::response(packagistMetadata($package, "https://github.com/{$repository}")); + }, + 'api.github.com/repos/*' => Http::response([]), + ]); + + resolve(DependencyContributorsQuery::class)->get('jess'); + + expect(Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/starred')))->toHaveCount($stars); +})->with([ + 'twenty repositories' => [false, 0], + 'nineteen, as two packages share one' => [true, 1], +]); + +it('skips manifests that are missing, too big to be real, or not json', function (Closure $response): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/*' => $response, + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe([]); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'raw.githubusercontent.com') && $request->hasHeader('Range', 'bytes=0-102399')); + Http::assertSentCount(4); +})->with([ + 'missing' => fn (): Closure => fn () => Http::response('404: Not Found', 404), + 'cut short' => fn (): Closure => fn () => Http::response('{"require": {"laravel/framework": "^13', 206), + 'a list' => fn (): Closure => fn () => Http::response(['laravel/framework']), + 'unreachable' => fn (): Closure => fn () => Http::failedConnection(), +]); + +it('leaves out packages that are not developed on github', function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['acme/private' => '*', 'acme/gitlab' => '*']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response(['dependencies' => ['left-pad' => '*', 'right-pad' => '*', 'up-pad' => '*']]), + 'repo.packagist.org/p2/acme/private.json' => Http::response('', 404), + 'repo.packagist.org/p2/acme/gitlab.json' => Http::response(packagistMetadata('acme/gitlab', 'https://gitlab.com/acme/gitlab.git')), + 'registry.npmjs.org/left-pad/latest' => Http::response(['name' => 'left-pad']), + 'registry.npmjs.org/right-pad/latest' => Http::response(['repository' => ['type' => 'svn']]), + 'registry.npmjs.org/up-pad/latest' => Http::response(['repository' => 'https://github.com/../acme']), + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe([]); + + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), '/contributors')); +}); + +it('remembers the people behind a package for a day', function (): void { + Http::fake([ + 'api.github.com/users/*/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/*/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(githubContributors([1])), + ]); + + $query = resolve(DependencyContributorsQuery::class); + + expect($query->get('jess'))->toBe(['1']) + ->and($query->get('jess'))->toBe(['1']); + + $this->travel(1)->days(); + + expect($query->get('jess'))->toBe(['1']) + ->and(Http::recorded(fn (Request $request): bool => str_starts_with($request->url(), 'https://repo.packagist.org')))->toHaveCount(2) + ->and(Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/contributors')))->toHaveCount(2); +}); + +it('asks again about a package whose lookup failed', function (int $packagist, int $contributors): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::sequence() + ->push(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git'), $packagist) + ->push(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::sequence() + ->push(githubContributors([1]), $contributors) + ->push(githubContributors([1])), + ]); + + $query = resolve(DependencyContributorsQuery::class); + + expect($query->get('jess'))->toBe([]) + ->and($query->get('jess'))->toBe(['1']); +})->with([ + 'where it is developed' => [503, 200], + 'who develops it' => [200, 500], +]); + +it('stops looking after twenty seconds', function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([ + ['full_name' => 'jess/one', 'fork' => false], + ['full_name' => 'jess/two', 'fork' => false], + ]), + 'raw.githubusercontent.com/jess/one/HEAD/composer.json' => function (): PromiseInterface { + $this->travel(20)->seconds(); + + return Http::response(['require' => ['laravel/framework' => '^13.0']]); + }, + 'raw.githubusercontent.com/*' => Http::response('404: Not Found', 404), + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe([]); + + Http::assertSentCount(3); +}); + +it('keeps who it found before the twenty seconds ran out', function (): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0', 'pestphp/pest' => '^5.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'repo.packagist.org/p2/pestphp/pest.json' => Http::response(packagistMetadata('pestphp/pest', 'https://github.com/pestphp/pest.git')), + 'api.github.com/repos/laravel/framework/contributors*' => function (): PromiseInterface { + $this->travel(20)->seconds(); + + return Http::response(githubContributors([1])); + }, + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe(['1']); + + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), 'pestphp/pest/contributors')); +}); + +it('leaves out repositories github no longer has, that have no history, or too much of it, without asking again', function (int $status, ?array $body): void { + Http::fake([ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response($body, $status), + ]); + + $query = resolve(DependencyContributorsQuery::class); + + expect($query->get('jess'))->toBe([]) + ->and($query->get('jess'))->toBe([]) + ->and(Http::recorded(fn (Request $request): bool => str_contains($request->url(), '/contributors')))->toHaveCount(1); +})->with([ + 'gone' => [404, null], + 'empty' => [204, null], + 'too large' => [403, ['message' => 'The history or contributor list is too large to list contributors for this repository via the API.']], +]); + +it('fails when github cannot list the repositories or the stars or a lookup is throttled, so the lookup is tried again', function (array $responses): void { + Http::fake($responses); + + expect(fn () => resolve(DependencyContributorsQuery::class)->get('jess'))->toThrow(RequestException::class); +})->with([ + 'listing' => fn (): array => ['api.github.com/users/jess/repos*' => Http::response(['message' => 'Server Error'], 500)], + 'starring' => fn (): array => [ + 'api.github.com/users/jess/repos*' => Http::response([]), + 'api.github.com/users/jess/starred*' => Http::response(['message' => 'Server Error'], 500), + ], + 'reading' => fn (): array => [ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'raw.githubusercontent.com/*' => Http::response('429: Too Many Requests', 429), + ], + 'finding where a package is developed' => fn (): array => [ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/*' => Http::response('Too Many Requests', 429), + ], + 'finding who develops it' => fn (): array => [ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(['message' => 'You have exceeded a secondary rate limit.'], 429), + ], + 'finding who develops it, past the rate limit' => fn (): array => [ + 'api.github.com/users/jess/repos*' => Http::response([['full_name' => 'jess/blog', 'fork' => false]]), + 'api.github.com/users/jess/starred*' => Http::response([]), + 'raw.githubusercontent.com/jess/blog/HEAD/composer.json' => Http::response(['require' => ['laravel/framework' => '^13.0']]), + 'raw.githubusercontent.com/jess/blog/HEAD/package.json' => Http::response('404: Not Found', 404), + 'repo.packagist.org/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(['message' => 'API rate limit exceeded.'], 403), + ], +]); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 4877aa442..53fe5270c 100644 --- a/tests/Unit/Queries/SuggestedUsersQueryTest.php +++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php @@ -2,10 +2,13 @@ declare(strict_types=1); +use App\Actions\CreateStackSuggestions; use App\Models\Commit; use App\Models\User; use App\Queries\SuggestedUsersQuery; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; /** * @return Collection @@ -276,6 +279,54 @@ function popularUsers(int $count): Collection ->and($suggestions[$stranger->id]->follows_you)->toBeFalse(); }); +it('suggests the people behind the stack of the user before the people they might know', function (): void { + $user = User::factory()->create(); + $stack = User::factory()->count(2)->has(Commit::factory())->create(); + vouchedFor($user, 2); + + Cache::put(CreateStackSuggestions::key($user), $stack->modelKeys(), 60); + + expect(resolve(SuggestedUsersQuery::class)->get($user)->pluck('id'))->toContain(...$stack->modelKeys()); +}); + +it('marks the people behind the stack of the user, even the ones they might know or who are popular', function (): void { + $user = User::factory()->create(); + [$known, $stranger] = vouchedFor($user, 2); + [$popularInStack, $popular] = popularUsers(2); + + Cache::put(CreateStackSuggestions::key($user), [$known->id, $popularInStack->id], 60); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($user, limit: 4)->keyBy('id'); + + expect($suggestions)->toHaveCount(4) + ->and($suggestions[$known->id]->behind_your_stack)->toBeTrue() + ->and($suggestions[$popularInStack->id]->behind_your_stack)->toBeTrue() + ->and($suggestions[$stranger->id]->behind_your_stack)->toBeFalse() + ->and($suggestions[$popular->id]->behind_your_stack)->toBeFalse(); +}); + +it('does not suggest the people behind the stack of the user they follow or who have not committed', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->has(Commit::factory())->create(); + $silent = User::factory()->create(); + + $user->following()->attach($followed); + Cache::put(CreateStackSuggestions::key($user), [$followed->id, $silent->id], 60); + + expect(resolve(SuggestedUsersQuery::class)->get($user))->toBeEmpty(); +}); + +it('does not look for the people behind the stack of a user who has none', function (): void { + $user = User::factory()->create(); + popularUsers(1); + + DB::enableQueryLog(); + + resolve(SuggestedUsersQuery::class)->get($user); + + expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'true as behind_your_stack')))->toBeEmpty(); +}); + it('suggests people followed by the profile being viewed', function (): void { $viewer = User::factory()->create(); $profile = User::factory()->create(); @@ -493,3 +544,18 @@ function popularUsers(int $count): Collection expect($suggestions)->toHaveCount(3) ->and(array_diff($suggestions, $visible->modelKeys()))->toBeEmpty(); }); + +it('puts the page context ahead of the people behind the stack of the user', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $related = User::factory()->has(Commit::factory())->create(); + $stack = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach($related); + Cache::put(CreateStackSuggestions::key($viewer), [$stack->id], 60); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, context: $profile); + + expect($suggestions->pluck('id')->all())->toBe([$related->id, $stack->id]) + ->and($suggestions->pluck('behind_your_stack')->all())->toBe([false, true]); +});