From 47eee250ba79588d9d6c27637541ca72b2017194 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Fri, 23 Oct 2026 12:30:05 +0200 Subject: [PATCH 1/3] feat: suggest the developers behind your stack when signing up --- KNOWLEDGE_BASE.md | 6 +- app/Actions/CreateStackSuggestions.php | 42 ++++ app/Actions/DeleteUser.php | 2 + app/Http/Controllers/SessionController.php | 5 + app/Jobs/SuggestDependencyContributorsJob.php | 31 +++ app/Queries/DependencyContributorsQuery.php | 215 ++++++++++++++++ app/Queries/SuggestedUsersQuery.php | 39 ++- resources/js/components/who-to-follow.tsx | 4 + resources/js/pages/onboarding/show.tsx | 11 +- resources/js/pages/privacy/show.tsx | 7 +- resources/js/types/auth.ts | 1 + .../Controllers/OnboardingControllerTest.php | 18 ++ .../Controllers/SessionControllerTest.php | 30 +++ .../Actions/CreateStackSuggestionsTest.php | 35 +++ tests/Unit/Actions/DeleteUserTest.php | 12 + .../SuggestDependencyContributorsJobTest.php | 19 ++ .../DependencyContributorsQueryTest.php | 233 ++++++++++++++++++ .../Unit/Queries/SuggestedUsersQueryTest.php | 51 ++++ 18 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 app/Actions/CreateStackSuggestions.php create mode 100644 app/Jobs/SuggestDependencyContributorsJob.php create mode 100644 app/Queries/DependencyContributorsQuery.php create mode 100644 tests/Unit/Actions/CreateStackSuggestionsTest.php create mode 100644 tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php create mode 100644 tests/Unit/Queries/DependencyContributorsQueryTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3cc1b80f8..ff80b38fd 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -143,9 +143,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. 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. 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. 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..83858aec6 --- /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->username)) + ->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 9fb528780..39192e156 100644 --- a/app/Actions/DeleteUser.php +++ b/app/Actions/DeleteUser.php @@ -69,6 +69,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 e2f63697b..61080ef29 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -7,6 +7,7 @@ use App\Actions\CreateTomFriendship; use App\Actions\UpdateOrCreateUser; use App\Jobs\ImportGitHubFollowsJob; +use App\Jobs\SuggestDependencyContributorsJob; use App\Jobs\SyncGitHubSshKeysJob; use App\Services\GitHubAccountScreening; use Illuminate\Http\RedirectResponse; @@ -57,6 +58,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..8469a3a17 --- /dev/null +++ b/app/Queries/DependencyContributorsQuery.php @@ -0,0 +1,215 @@ +[\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. + * + * @return array + */ + public function get(string $login): array + { + $deadline = now()->addSeconds(self::Seconds); + $inTime = fn (): bool => now()->isBefore($deadline); + + return $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() + ->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. + */ + 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) { + return null; + } + + return $repository === false ? null : $repository; + } + + /** + * 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, or one without history, has none. + * + * @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()) { + return []; + } + + return $response->throw() + ->collect() + ->where('type', 'User') + ->mapInto(Fluent::class) + ->map(fn (Fluent $contributor): string => $contributor->string('id')->value()) + ->values() + ->all(); + }); + } catch (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 7ec591ed0..189e34288 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -4,20 +4,22 @@ namespace App\Queries; +use App\Actions\CreateStackSuggestions; use App\Models\Commit; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Random\Engine\Mt19937; use Random\Randomizer; final readonly class SuggestedUsersQuery { /** - * 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. Either side fills in when the other runs short. + * 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. Either side fills in when the other runs short. * * 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. @@ -46,6 +48,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') @@ -60,6 +63,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') @@ -69,6 +73,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); $current = max(0, $round) % count($pages); @@ -114,6 +122,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 40055c223..0ca146c1d 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 837dcb388..e77935441 100644 --- a/resources/js/pages/onboarding/show.tsx +++ b/resources/js/pages/onboarding/show.tsx @@ -353,7 +353,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 93f09b6ae..1a4fac444 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -49,7 +49,12 @@ 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 are read to suggest + the people behind the packages 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 4b12c76df..154ea3474 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -62,4 +62,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 a14fb9923..1935dd74c 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..016ed7eb8 --- /dev/null +++ b/tests/Unit/Actions/CreateStackSuggestionsTest.php @@ -0,0 +1,35 @@ +create(['username' => 'jess']); + $contributor = User::factory()->create(); + + Http::fake([ + '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/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 0222e2599..4a373abfb 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; @@ -300,6 +301,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..c87d76336 --- /dev/null +++ b/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php @@ -0,0 +1,19 @@ +create(['username' => 'jess']); + + Http::fake(['api.github.com/users/jess/repos*' => 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..637a9bdfd --- /dev/null +++ b/tests/Unit/Queries/DependencyContributorsQueryTest.php @@ -0,0 +1,233 @@ + + */ +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], + ]), + '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], + ]), + '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('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]]), + '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(3); +})->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]]), + '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]]), + '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]]), + '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]]), + '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 or that have no history', function (int $status): void { + Http::fake([ + '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/p2/laravel/framework.json' => Http::response(packagistMetadata('laravel/framework', 'https://github.com/laravel/framework.git')), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response(null, $status), + ]); + + expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe([]); +})->with([404, 204]); + +it('fails when github cannot list the repositories or throttles reading them, 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)], + '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), + ], +]); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index cb71fa899..20adc2dc7 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 @@ -275,3 +278,51 @@ function popularUsers(int $count): Collection ->and($suggestions[$follower->id]->follows_you)->toBeTrue() ->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(); +}); From 0413511004ef1abf90d2baeb30bef4fc9101fba0 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Fri, 23 Oct 2026 18:11:57 +0200 Subject: [PATCH 2/3] feat: fill stack suggestions with recent stars when manifests say little --- KNOWLEDGE_BASE.md | 2 +- app/Queries/DependencyContributorsQuery.php | 36 +++++- resources/js/pages/privacy/show.tsx | 7 +- .../Actions/CreateStackSuggestionsTest.php | 1 + .../SuggestDependencyContributorsJobTest.php | 2 +- .../DependencyContributorsQueryTest.php | 107 +++++++++++++++++- 6 files changed, 140 insertions(+), 15 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index ff80b38fd..ee0cd7197 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -145,7 +145,7 @@ Follow or unfollow from a developer's profile or a follow control elsewhere in t **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. You can also follow back from the notifications list. -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. 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. +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. 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). diff --git a/app/Queries/DependencyContributorsQuery.php b/app/Queries/DependencyContributorsQuery.php index 8469a3a17..6ece09745 100644 --- a/app/Queries/DependencyContributorsQuery.php +++ b/app/Queries/DependencyContributorsQuery.php @@ -21,7 +21,8 @@ private const int Repositories = 10; /** - * The packages looked up, the ones the most repositories depend on first. + * The packages looked up, the ones the most repositories depend on first, and the most repositories whose + * contributors are asked about. */ private const int Packages = 20; @@ -50,6 +51,7 @@ 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 */ @@ -58,7 +60,7 @@ public function get(string $login): array $deadline = now()->addSeconds(self::Seconds); $inTime = fn (): bool => now()->isBefore($deadline); - return $this->github->request() + $repositories = $this->github->request() ->get("users/{$login}/repos", ['type' => 'owner', 'sort' => 'pushed', 'per_page' => self::Repositories]) ->throw() ->collect() @@ -71,6 +73,12 @@ public function get(string $login): array ->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() @@ -167,9 +175,29 @@ private function repository(string $package): ?string 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, or one without history, has none. + * 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. * * @return array */ @@ -179,7 +207,7 @@ private function contributors(string $repository): array 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()) { + if ($response->notFound() || $response->noContent() || $response->fluent()->string('message')->contains('too large')) { return []; } diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index 1a4fac444..b1308deb6 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -52,9 +52,10 @@ export default function Show() { who you follow and who follows you. When you sign up, the composer.json and{' '} package.json of your recently - pushed public repositories are read to suggest - the people behind the packages you use, and only - who to suggest is kept, for a week. + 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/tests/Unit/Actions/CreateStackSuggestionsTest.php b/tests/Unit/Actions/CreateStackSuggestionsTest.php index 016ed7eb8..a316f7569 100644 --- a/tests/Unit/Actions/CreateStackSuggestionsTest.php +++ b/tests/Unit/Actions/CreateStackSuggestionsTest.php @@ -13,6 +13,7 @@ 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([ diff --git a/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php b/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php index c87d76336..08d62e698 100644 --- a/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php +++ b/tests/Unit/Jobs/SuggestDependencyContributorsJobTest.php @@ -11,7 +11,7 @@ it('suggests who is behind the stack of the user', function (): void { $user = User::factory()->create(['username' => 'jess']); - Http::fake(['api.github.com/users/jess/repos*' => Http::response([])]); + Http::fake(['api.github.com/users/jess/*' => Http::response([])]); dispatch_sync(new SuggestDependencyContributorsJob($user)); diff --git a/tests/Unit/Queries/DependencyContributorsQueryTest.php b/tests/Unit/Queries/DependencyContributorsQueryTest.php index 637a9bdfd..3a9d32341 100644 --- a/tests/Unit/Queries/DependencyContributorsQueryTest.php +++ b/tests/Unit/Queries/DependencyContributorsQueryTest.php @@ -7,6 +7,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; /** * A package's metadata the way Packagist serves it, newest version first, cut down to what the query reads. @@ -39,6 +40,7 @@ function githubContributors(array $users, array $bots = []): array ['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'], @@ -74,6 +76,7 @@ function githubContributors(array $users, array $bots = []): array ['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' => '*'], @@ -93,16 +96,90 @@ function githubContributors(array $users, array $bots = []): array ->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(3); + 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), @@ -113,6 +190,7 @@ function githubContributors(array $users, array $bots = []): array 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), @@ -130,6 +208,7 @@ function githubContributors(array $users, array $bots = []): array 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')), @@ -151,6 +230,7 @@ function githubContributors(array $users, array $bots = []): array 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() @@ -168,6 +248,7 @@ function githubContributors(array $users, array $bots = []): array })->with([ 'where it is developed' => [503, 200], 'who develops it' => [200, 500], + 'who develops it, while rate limited' => [200, 403], ]); it('stops looking after twenty seconds', function (): void { @@ -192,6 +273,7 @@ function githubContributors(array $users, array $bots = []): array 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')), @@ -208,24 +290,37 @@ function githubContributors(array $users, array $bots = []): array Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), 'pestphp/pest/contributors')); }); -it('leaves out repositories github no longer has or that have no history', function (int $status): void { +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(null, $status), + 'api.github.com/repos/laravel/framework/contributors*' => Http::response($body, $status), ]); - expect(resolve(DependencyContributorsQuery::class)->get('jess'))->toBe([]); -})->with([404, 204]); + $query = resolve(DependencyContributorsQuery::class); -it('fails when github cannot list the repositories or throttles reading them, so the lookup is tried again', function (array $responses): void { + 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 throttles reading them, 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), From f0d83a5b70a195adf2712e6404b9b643fb676cb2 Mon Sep 17 00:00:00 2001 From: sulimanbenhalim Date: Mon, 26 Oct 2026 22:45:10 +0200 Subject: [PATCH 3/3] fix: retry stack lookups that a registry or GitHub throttles --- app/Queries/DependencyContributorsQuery.php | 13 +++++++--- .../DependencyContributorsQueryTest.php | 25 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/Queries/DependencyContributorsQuery.php b/app/Queries/DependencyContributorsQuery.php index 6ece09745..6a77cd3a2 100644 --- a/app/Queries/DependencyContributorsQuery.php +++ b/app/Queries/DependencyContributorsQuery.php @@ -8,6 +8,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\HttpClientException; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Fluent; @@ -150,6 +151,7 @@ private function manifest(string $repository, string $file): Fluent /** * 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 { @@ -168,7 +170,9 @@ private function repository(string $package): ?string return is_string($url) && preg_match(self::RepositoryUrl, $url, $matches) === 1 ? $matches['repository'] : false; }); - } catch (HttpClientException) { + } catch (HttpClientException $httpClientException) { + throw_if($httpClientException instanceof RequestException && $httpClientException->response->tooManyRequests(), $httpClientException); + return null; } @@ -197,7 +201,8 @@ private function starred(string $login): array /** * 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. + * 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 */ @@ -219,7 +224,9 @@ private function contributors(string $repository): array ->values() ->all(); }); - } catch (HttpClientException) { + } catch (HttpClientException $httpClientException) { + throw_if($httpClientException instanceof RequestException && in_array($httpClientException->response->status(), [403, 429], true), $httpClientException); + return []; } } diff --git a/tests/Unit/Queries/DependencyContributorsQueryTest.php b/tests/Unit/Queries/DependencyContributorsQueryTest.php index 3a9d32341..93d5a14fe 100644 --- a/tests/Unit/Queries/DependencyContributorsQueryTest.php +++ b/tests/Unit/Queries/DependencyContributorsQueryTest.php @@ -248,7 +248,6 @@ function githubContributors(array $users, array $bots = []): array })->with([ 'where it is developed' => [503, 200], 'who develops it' => [200, 500], - 'who develops it, while rate limited' => [200, 403], ]); it('stops looking after twenty seconds', function (): void { @@ -311,7 +310,7 @@ function githubContributors(array $users, array $bots = []): array '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 throttles reading them, so the lookup is tried again', function (array $responses): void { +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); @@ -325,4 +324,26 @@ function githubContributors(array $users, array $bots = []): 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), + ], ]);