From 079675471bc88afd748c427f9a8d37207bd6fe10 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 19:50:38 -0800 Subject: [PATCH 01/10] feat: read who owns the repositories a user starred on GitHub Organizations are left out since only people can be suggested. The page loop moves into its own helper so the starred list can reuse it without pretending repositories are accounts. --- app/Contracts/Services/GitHubProvider.php | 8 +++++ .../GitHubProviders/HttpGitHubProvider.php | 34 +++++++++++++++---- .../GitHubProviders/NullGitHubProvider.php | 5 +++ .../HttpGitHubProviderTest.php | 26 ++++++++++++++ .../NullGitHubProviderTest.php | 1 + 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/Contracts/Services/GitHubProvider.php b/app/Contracts/Services/GitHubProvider.php index 435064461..382901a34 100644 --- a/app/Contracts/Services/GitHubProvider.php +++ b/app/Contracts/Services/GitHubProvider.php @@ -70,6 +70,14 @@ public function followingIds(string $login, int $maxPages): array; */ public function followerIds(string $login, int $maxPages): array; + /** + * The IDs of the people owning the repositories the user starred, once per repository, most recently starred + * first, up to the given number of pages. The repositories of organizations are left out. + * + * @return list + */ + public function starredOwnerIds(string $login, int $maxPages): array; + /** * The public SSH keys of the account, for authentication and signing, in OpenSSH format. * diff --git a/app/Services/GitHubProviders/HttpGitHubProvider.php b/app/Services/GitHubProviders/HttpGitHubProvider.php index ba6429b36..9e71f35d7 100644 --- a/app/Services/GitHubProviders/HttpGitHubProvider.php +++ b/app/Services/GitHubProviders/HttpGitHubProvider.php @@ -115,6 +115,17 @@ public function followerIds(string $login, int $maxPages): array return array_map(fn (array $account): string => (string) $account['id'], $this->accounts("users/{$login}/followers", $maxPages)); } + public function starredOwnerIds(string $login, int $maxPages): array + { + /** @var list $repositories */ + $repositories = $this->pages("users/{$login}/starred", $maxPages); + + return array_values(array_map( + fn (array $repository): string => (string) $repository['owner']['id'], + array_filter($repositories, fn (array $repository): bool => $repository['owner']['type'] === 'User'), + )); + } + public function sshKeys(string $login): array { /** @var list */ @@ -297,20 +308,31 @@ private function find(string $path): ?array */ private function accounts(string $path, int $maxPages): array { - $accounts = []; + /** @var list */ + return $this->pages($path, $maxPages); + } + + /** + * The items of a paginated list, up to the given number of pages. + * + * @return list> + */ + private function pages(string $path, int $maxPages): array + { + $items = []; for ($page = 1; $page <= $maxPages; $page++) { - /** @var list $pageAccounts */ - $pageAccounts = $this->request()->get($path, ['per_page' => self::PerPage, 'page' => $page])->throw()->json(); + /** @var list> $pageItems */ + $pageItems = $this->request()->get($path, ['per_page' => self::PerPage, 'page' => $page])->throw()->json(); - array_push($accounts, ...$pageAccounts); + array_push($items, ...$pageItems); - if (count($pageAccounts) < self::PerPage) { + if (count($pageItems) < self::PerPage) { break; } } - return $accounts; + return $items; } /** diff --git a/app/Services/GitHubProviders/NullGitHubProvider.php b/app/Services/GitHubProviders/NullGitHubProvider.php index 7d18520ee..590320bb1 100644 --- a/app/Services/GitHubProviders/NullGitHubProvider.php +++ b/app/Services/GitHubProviders/NullGitHubProvider.php @@ -51,6 +51,11 @@ public function followerIds(string $login, int $maxPages): array return []; } + public function starredOwnerIds(string $login, int $maxPages): array + { + return []; + } + public function sshKeys(string $login): array { return []; diff --git a/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php b/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php index b7c229561..d4ed81e82 100644 --- a/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php +++ b/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php @@ -165,6 +165,32 @@ expect(new HttpGitHubProvider()->followingIds('octocat', 10))->toBe(['users' => ['1', '2'], 'organizations' => ['958072']]); }); +it('reads who owns the repositories the user starred, once per repository, leaving organizations out', function (): void { + Http::fake([ + 'api.github.com/users/octocat/starred?per_page=100&page=1' => Http::response([ + ['id' => 10, 'full_name' => 'mona/dotfiles', 'owner' => ['id' => 1, 'login' => 'mona', 'type' => 'User']], + ['id' => 11, 'full_name' => 'laravel/framework', 'owner' => ['id' => 958072, 'login' => 'laravel', 'type' => 'Organization']], + ['id' => 12, 'full_name' => 'hubot/hubot', 'owner' => ['id' => 2, 'login' => 'hubot', 'type' => 'User']], + ['id' => 13, 'full_name' => 'mona/hello-world', 'owner' => ['id' => 1, 'login' => 'mona', 'type' => 'User']], + ]), + ]); + + expect(new HttpGitHubProvider()->starredOwnerIds('octocat', 3))->toBe(['1', '2', '1']); +}); + +it('reads the starred repositories a page at a time, up to the given number of pages', function (): void { + $page = fn (int $from): array => array_map(fn (int $id): array => ['id' => $id, 'owner' => ['id' => $id, 'type' => 'User']], range($from, $from + 99)); + + Http::fake([ + 'api.github.com/users/octocat/starred?per_page=100&page=1' => Http::response($page(1)), + 'api.github.com/users/octocat/starred?per_page=100&page=2' => Http::response($page(101)), + ]); + + expect(new HttpGitHubProvider()->starredOwnerIds('octocat', 2))->toBe(array_map(strval(...), range(1, 200))); + + Http::assertSentCount(2); +}); + it('reads the authentication and signing keys of the account', function (): void { Http::fake([ 'api.github.com/users/octocat/keys*' => Http::response([['id' => 1, 'key' => 'ssh-ed25519 AAAA1']]), diff --git a/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php b/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php index 99a5c4444..00ecc5208 100644 --- a/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php +++ b/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php @@ -21,6 +21,7 @@ ->and($provider->hasSponsorsListing('token'))->toBeNull() ->and($provider->followingIds('octocat', 10))->toBe(['users' => [], 'organizations' => []]) ->and($provider->followerIds('octocat', 10))->toBe([]) + ->and($provider->starredOwnerIds('octocat', 3))->toBe([]) ->and($provider->sshKeys('octocat'))->toBe([]) ->and($provider->avatar('https://avatars.githubusercontent.com/u/583231'))->toBeNull() ->and($provider->readme('octocat'))->toBeNull() From 4dcc39a18293ea6e5614e86fcec8ea5e37fe3253 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 19:54:06 -0800 Subject: [PATCH 02/10] feat: remember whose repositories each user starred on GitHub Imported once at sign-in, on its own job so it can fail without holding back the follow import. Only the last 300 stars are read to keep the rate limit in check. Owners who aren't on /dev/null yet are kept for when they join. The rows go out with the export and go away with the account. --- app/Actions/CreateExport.php | 18 ++++ app/Actions/CreateUserGitHubStars.php | 54 +++++++++++ app/Http/Controllers/SessionController.php | 7 ++ app/Jobs/ImportGitHubStarsJob.php | 29 ++++++ app/Models/User.php | 3 + database/factories/UserFactory.php | 1 + ...000_create_starred_github_owners_table.php | 28 ++++++ ...ithub_stars_imported_at_to_users_table.php | 21 +++++ .../Controllers/ExportControllerTest.php | 18 ++++ .../Controllers/SessionControllerTest.php | 33 +++++++ .../Actions/CreateUserGitHubStarsTest.php | 90 +++++++++++++++++++ tests/Unit/Actions/DeleteUserTest.php | 13 +++ tests/Unit/Jobs/ImportGitHubStarsJobTest.php | 20 +++++ 13 files changed, 335 insertions(+) create mode 100644 app/Actions/CreateUserGitHubStars.php create mode 100644 app/Jobs/ImportGitHubStarsJob.php create mode 100644 database/migrations/2026_11_02_200000_create_starred_github_owners_table.php create mode 100644 database/migrations/2026_11_02_200001_add_github_stars_imported_at_to_users_table.php create mode 100644 tests/Unit/Actions/CreateUserGitHubStarsTest.php create mode 100644 tests/Unit/Jobs/ImportGitHubStarsJobTest.php diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index acab06411..2afbd4713 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -52,6 +52,7 @@ * organizations: array, * followed_organizations: array, * pending_follows: list, + * starred_github_owners: list, * messages: Generator, * reports: Traversable>, * remote_pins: array, @@ -124,6 +125,7 @@ public function handle(User $user): array 'organizations' => $this->organizations($user), 'followed_organizations' => $this->followedOrganizations($user), 'pending_follows' => $this->pendingFollows($user), + 'starred_github_owners' => $this->starredGitHubOwners($user), 'messages' => $this->messages($user), 'reports' => CommitReport::query()->where('reporter_id', $user->id)->oldest('id')->cursor()->map(fn (CommitReport $report): array => [ 'commit_id' => $report->commit_id, @@ -407,6 +409,22 @@ private function pendingFollows(User $user): array return DB::table('pending_follows')->where('user_id', $user->id)->orderBy('github_id')->pluck('github_id')->all(); } + /** + * The GitHub IDs of the people whose repositories the user starred on GitHub, and how many, for suggestions. + * + * @return list + */ + private function starredGitHubOwners(User $user): array + { + /** @var Collection $owners */ + $owners = DB::table('starred_github_owners')->where('user_id', $user->id)->orderBy('github_id')->get(['github_id', 'repositories_count']); + + return array_values($owners->map(fn (object $owner): array => [ + 'github_id' => $owner->github_id, + 'repositories_count' => $owner->repositories_count, + ])->all()); + } + /** * Moderators stay anonymous, as they are on the notifications page, and level-ups are the user's own doing. An * organization's notifications name the organization the user heard about it for. diff --git a/app/Actions/CreateUserGitHubStars.php b/app/Actions/CreateUserGitHubStars.php new file mode 100644 index 000000000..bf975e338 --- /dev/null +++ b/app/Actions/CreateUserGitHubStars.php @@ -0,0 +1,54 @@ +github_stars_imported_at !== null) { + return; + } + + $login = $this->github->login($user->github_id); + $ownerIds = $login === null ? [] : $this->github->starredOwnerIds($login, self::MaxPages); + + $rows = collect($ownerIds) + ->reject(fn (string $id): bool => $id === $user->github_id) + ->countBy() + ->map(fn (int $count, int|string $id): array => [ + 'user_id' => $user->id, + 'github_id' => (string) $id, + 'repositories_count' => $count, + 'created_at' => now(), + ]) + ->values() + ->all(); + + DB::transaction(function () use ($user, $rows): void { + DB::table('starred_github_owners')->insertOrIgnore($rows); + + $user->update(['github_stars_imported_at' => now()]); + }); + } +} diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index c0798fbaf..c73ff89da 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\ImportGitHubStarsJob; use App\Jobs\LookUpUserOrganizations; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; @@ -70,6 +71,12 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri } dispatch(new SyncGitHubSshKeysJob($user)); + + // Ahead of the follows, since onboarding reloads its suggestions once those are in. + if ($user->github_stars_imported_at === null) { + dispatch(new ImportGitHubStarsJob($user)); + } + dispatch(new ImportGitHubFollowsJob($user, notify: $user->wasRecentlyCreated)); LookUpUserOrganizations::dispatchFor($user); diff --git a/app/Jobs/ImportGitHubStarsJob.php b/app/Jobs/ImportGitHubStarsJob.php new file mode 100644 index 000000000..df543d227 --- /dev/null +++ b/app/Jobs/ImportGitHubStarsJob.php @@ -0,0 +1,29 @@ +handle($this->user); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8f8f264b5..1ad619b29 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,6 +61,7 @@ * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at * @property-read CarbonInterface|null $github_organization_follows_imported_at + * @property-read CarbonInterface|null $github_stars_imported_at * @property-read string|null $remember_token * @property-read CarbonInterface|null $github_synced_at * @property-read CarbonInterface|null $last_seen_at @@ -112,6 +113,7 @@ 'onboarded_at', 'github_follows_imported_at', 'github_organization_follows_imported_at', + 'github_stars_imported_at', 'live_platform', 'live_url', 'live_ends_at', @@ -208,6 +210,7 @@ public function casts(): array 'onboarded_at' => 'datetime', 'github_follows_imported_at' => 'datetime', 'github_organization_follows_imported_at' => 'datetime', + 'github_stars_imported_at' => 'datetime', 'remember_token' => 'string', 'live_platform' => LivePlatform::class, 'live_url' => 'string', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 698b1db43..f7023f8df 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -43,6 +43,7 @@ public function definition(): array 'onboarded_at' => now(), 'github_follows_imported_at' => null, 'github_organization_follows_imported_at' => null, + 'github_stars_imported_at' => null, 'commit_rules_accepted_at' => now(), 'remember_token' => Str::random(10), ]; diff --git a/database/migrations/2026_11_02_200000_create_starred_github_owners_table.php b/database/migrations/2026_11_02_200000_create_starred_github_owners_table.php new file mode 100644 index 000000000..f82dad9de --- /dev/null +++ b/database/migrations/2026_11_02_200000_create_starred_github_owners_table.php @@ -0,0 +1,28 @@ +foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('github_id'); + $table->unsignedSmallInteger('repositories_count'); + $table->timestamp('created_at'); + + $table->primary(['user_id', 'github_id']); + $table->index('github_id'); + }); + } +}; diff --git a/database/migrations/2026_11_02_200001_add_github_stars_imported_at_to_users_table.php b/database/migrations/2026_11_02_200001_add_github_stars_imported_at_to_users_table.php new file mode 100644 index 000000000..6d013c86a --- /dev/null +++ b/database/migrations/2026_11_02_200001_add_github_stars_imported_at_to_users_table.php @@ -0,0 +1,21 @@ +timestamp('github_stars_imported_at')->nullable(); + }); + } +}; diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index b3762ab68..fdfbab470 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -140,6 +140,7 @@ 'organizations' => [], 'followed_organizations' => [], 'pending_follows' => [], + 'starred_github_owners' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -529,6 +530,23 @@ expect($data['pending_follows'])->toBe(['1', '958072']); }); +it('exports whose github repositories the user starred', function (): void { + $user = User::factory()->create(); + DB::table('starred_github_owners')->insert([ + ['user_id' => $user->id, 'github_id' => '583231', 'repositories_count' => 2, 'created_at' => now()], + ['user_id' => $user->id, 'github_id' => '1', 'repositories_count' => 1, 'created_at' => now()], + ['user_id' => User::factory()->create()->id, 'github_id' => '2', 'repositories_count' => 1, 'created_at' => now()], + ]); + + $response = $this->actingAs($user)->get(route('export')); + $data = json_decode($response->streamedContent(), true, flags: JSON_THROW_ON_ERROR); + + expect($data['starred_github_owners'])->toBe([ + ['github_id' => '1', 'repositories_count' => 1], + ['github_id' => '583231', 'repositories_count' => 2], + ]); +}); + it('exports the devices approved for the app, oldest first', function (): void { $user = User::factory()->create(); $older = Device::factory()->for($user)->create(['name' => 'devnull on iPad', 'approved_at' => now()->subWeek(), 'last_used_at' => now()->subDay()]); diff --git a/tests/Feature/Controllers/SessionControllerTest.php b/tests/Feature/Controllers/SessionControllerTest.php index 9fd947934..85c7c7d30 100644 --- a/tests/Feature/Controllers/SessionControllerTest.php +++ b/tests/Feature/Controllers/SessionControllerTest.php @@ -5,6 +5,7 @@ use App\Contracts\Services\GitHubProvider; use App\Enums\Log; use App\Jobs\ImportGitHubFollowsJob; +use App\Jobs\ImportGitHubStarsJob; use App\Jobs\LookUpUserOrganizations; use App\Jobs\SyncGitHubSshKeysJob; use App\Models\User; @@ -225,6 +226,38 @@ 'existing user' => [true, false], ]); +it('queues an import of the github stars of the user on login', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('hasSponsorsListing')->andReturnFalse(); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + ])); + + $this->get(route('login.store')); + + $user = User::query()->where('github_id', '583231')->sole(); + + Queue::assertPushed(ImportGitHubStarsJob::class, fn (ImportGitHubStarsJob $job): bool => $job->user->is($user)); +}); + +it('does not queue an import of the github stars once they are imported', function (): void { + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('hasSponsorsListing')->andReturnFalse(); + + User::factory()->create(['github_id' => '583231', 'github_stars_imported_at' => now()]); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => 'octocat', + ])); + + $this->get(route('login.store')); + + Queue::assertNotPushed(ImportGitHubStarsJob::class); +}); + it('redirects to the intended url after login', function (): void { $github = $this->mock(GitHubProvider::class); $github->shouldReceive('hasSponsorsListing')->andReturnFalse(); diff --git a/tests/Unit/Actions/CreateUserGitHubStarsTest.php b/tests/Unit/Actions/CreateUserGitHubStarsTest.php new file mode 100644 index 000000000..bb560688b --- /dev/null +++ b/tests/Unit/Actions/CreateUserGitHubStarsTest.php @@ -0,0 +1,90 @@ +where('user_id', $user->id) + ->orderBy('github_id') + ->get(['github_id', 'repositories_count']) + ->map(fn (object $row): array => (array) $row) + ->all(); +} + +it('remembers whose repositories the user starred, and how many of each', function (): void { + $user = User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->with($user->handle, 3)->andReturn(['1', '2', '1', '1']); + + resolve(CreateUserGitHubStars::class)->handle($user); + + expect(starredGitHubOwnersOf($user))->toBe([ + ['github_id' => '1', 'repositories_count' => 3], + ['github_id' => '2', 'repositories_count' => 1], + ])->and($user->refresh()->github_stars_imported_at)->not->toBeNull(); +}); + +it('leaves out the repositories the user starred of their own', function (): void { + $user = User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->with($user->handle, 3)->andReturn([$user->github_id, '2']); + + resolve(CreateUserGitHubStars::class)->handle($user); + + expect(starredGitHubOwnersOf($user))->toBe([['github_id' => '2', 'repositories_count' => 1]]); +}); + +it('follows nobody', function (): void { + $user = User::factory()->create(); + $owner = User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->with($user->handle, 3)->andReturn([$owner->github_id]); + + resolve(CreateUserGitHubStars::class)->handle($user); + + expect($user->following()->count())->toBe(0); +}); + +it('imports the stars of a user only once', function (): void { + $user = User::factory()->create(['github_stars_imported_at' => now()]); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->never(); + + resolve(CreateUserGitHubStars::class)->handle($user); +}); + +it('marks the stars as imported when the github account no longer exists', function (): void { + $user = User::factory()->create(); + + resolve(CreateUserGitHubStars::class)->handle($user); + + expect(starredGitHubOwnersOf($user))->toBe([]) + ->and($user->refresh()->github_stars_imported_at)->not->toBeNull(); +}); + +it('leaves the stars to import again when github fails', function (): void { + $user = User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->andThrow(new RequestException(new Response(new Psr7Response(500)))); + + expect(fn () => resolve(CreateUserGitHubStars::class)->handle($user))->toThrow(RequestException::class) + ->and($user->refresh()->github_stars_imported_at)->toBeNull(); +}); diff --git a/tests/Unit/Actions/DeleteUserTest.php b/tests/Unit/Actions/DeleteUserTest.php index cc7cf39fb..89f00bd3c 100644 --- a/tests/Unit/Actions/DeleteUserTest.php +++ b/tests/Unit/Actions/DeleteUserTest.php @@ -275,6 +275,19 @@ expect(DB::table('pending_follows')->pluck('user_id')->all())->toBe([$other->id]); }); +it('erases whose github repositories a deleted user starred', function (): void { + $user = User::factory()->create(); + $other = User::factory()->create(); + DB::table('starred_github_owners')->insert([ + ['user_id' => $user->id, 'github_id' => '583231', 'repositories_count' => 1, 'created_at' => now()], + ['user_id' => $other->id, 'github_id' => '583231', 'repositories_count' => 1, 'created_at' => now()], + ]); + + resolve(DeleteUser::class)->handle($user); + + expect(DB::table('starred_github_owners')->pluck('user_id')->all())->toBe([$other->id]); +}); + it('erases the devices of a deleted user', function (): void { $user = User::factory()->create(); Device::factory()->for($user)->create(); diff --git a/tests/Unit/Jobs/ImportGitHubStarsJobTest.php b/tests/Unit/Jobs/ImportGitHubStarsJobTest.php new file mode 100644 index 000000000..bee6a8640 --- /dev/null +++ b/tests/Unit/Jobs/ImportGitHubStarsJobTest.php @@ -0,0 +1,20 @@ +create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->with($user->handle, 3)->andReturn(['1']); + + dispatch_sync(new ImportGitHubStarsJob($user)); + + expect(DB::table('starred_github_owners')->where('user_id', $user->id)->pluck('github_id')->all())->toBe(['1']); +}); From 2c584d55d91e59168900963bc5aca4c660895f8b Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 19:56:12 -0800 Subject: [PATCH 03/10] feat: suggest developers whose repositories you starred on GitHub They get their own pool next to the people your follows follow, taken in turns so a big network doesn't bury them, and they're kept out of the popular picks so nobody shows up twice. Every suggestion now carries how many of their repositories you starred, which the next commit uses to say why. --- app/Queries/SuggestedUsersQuery.php | 74 +++++++++++++++++-- .../Unit/Queries/SuggestedUsersQueryTest.php | 71 ++++++++++++++++++ 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 716639226..d44af3b07 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -42,9 +42,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 people their follows already follow, people whose repositories + * they starred on GitHub, 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,7 +86,7 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, 'users.id', ) ->select(['users.*', 'network.followed_by_following_count']) - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user), 'starred_repositories_count' => $this->starredRepositoriesCount($user)]) ->whereIn('users.id', $this->committers($user)) ->orderByDesc('followed_by_following_count') ->orderByDesc('followers_count') @@ -94,13 +94,26 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, ->limit($limit * 10) ->get(); + $starred = $this->candidates($user, $followingIds) + ->joinSub($this->starredOwners($user)->select(['github_id', 'repositories_count']), 'starred', 'starred.github_id', '=', 'users.github_id') + ->select(['users.*', 'starred.repositories_count as starred_repositories_count']) + ->selectRaw('0 as followed_by_following_count') + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->whereIn('users.id', $this->committers($user)) + ->orderByDesc('starred.repositories_count') + ->orderByDesc('followers_count') + ->latest('users.id') + ->limit($limit * 10) + ->get(); + // Ranked before looking for commits, so only the most followed are looked up rather than every candidate. $popularPool = User::query() ->fromSub($this->candidates($user, $followingIds) ->whereNotExists($this->followedByFollowing($user, $following)) + ->whereNotIn('users.github_id', $this->starredOwners($user)->select('github_id')) ->select('users.*') ->selectRaw('0 as followed_by_following_count') - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user), 'starred_repositories_count' => $this->starredRepositoriesCount($user)]) ->orderByDesc('followers_count') ->latest('users.id'), 'users') ->whereIn('users.id', $this->committers($user)) @@ -109,7 +122,7 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, ->limit(20) ->get(); - $pages = $this->pages($randomizer, $network, $popularPool, $limit); + $pages = $this->pages($randomizer, $this->alternate($network, $starred), $popularPool, $limit); if ($contextual->isNotEmpty()) { /** @var Collection $general */ @@ -165,7 +178,7 @@ private function contextual(User $viewer, User|Commit $context, QueryBuilder $fo ) ->select('users.*') ->selectRaw('0 as followed_by_following_count') - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($viewer)]) + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($viewer), 'starred_repositories_count' => $this->starredRepositoriesCount($viewer)]) ->orderByDesc('followers_count') ->latest('users.id') ->limit($limit * 5) @@ -209,6 +222,34 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ return array_map(fn (array $page): Collection => new Collection($page), $pages); } + /** + * One from each in turn, starting with the people whose repositories the user starred, so a page holding a single + * person from the network still has one of them and a large network doesn't push them past the first pages. + * Someone in both stays where the network has them. + * + * @param Collection $network + * @param Collection $starred + * @return Collection + */ + private function alternate(Collection $network, Collection $starred): Collection + { + $starred = array_values($starred->whereNotIn('id', $network->pluck('id'))->all()); + $network = array_values($network->all()); + $alternated = []; + + for ($index = 0; $index < max(count($network), count($starred)); $index++) { + if (isset($starred[$index])) { + $alternated[] = $starred[$index]; + } + + if (isset($network[$index])) { + $alternated[] = $network[$index]; + } + } + + return new Collection($alternated); + } + /** * @return Builder */ @@ -259,6 +300,25 @@ private function followsUser(User $user): QueryBuilder ->whereColumn($followers->getQualifiedRelatedPivotKeyName(), 'users.id'); } + /** + * The people whose repositories the user starred on GitHub, by GitHub ID. + */ + private function starredOwners(User $user): QueryBuilder + { + return DB::table('starred_github_owners')->where('user_id', $user->id); + } + + /** + * How many of the candidate's repositories the user starred, 0 for none. There is a row at most, so the sum is + * its count. + */ + private function starredRepositoriesCount(User $user): QueryBuilder + { + return $this->starredOwners($user) + ->selectRaw('coalesce(sum(repositories_count), 0)') + ->whereColumn('starred_github_owners.github_id', 'users.github_id'); + } + private function committers(User $user): CommitBuilder { return Commit::query()->visibleTo($user)->topLevel()->select('author_id'); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 4877aa442..842300dd0 100644 --- a/tests/Unit/Queries/SuggestedUsersQueryTest.php +++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Queries\SuggestedUsersQuery; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; /** * @return Collection @@ -493,3 +494,73 @@ function popularUsers(int $count): Collection expect($suggestions)->toHaveCount(3) ->and(array_diff($suggestions, $visible->modelKeys()))->toBeEmpty(); }); + +it('suggests the people whose repositories the user starred on github, the most starred first', function (): void { + $user = User::factory()->create(); + $starredOnce = User::factory()->has(Commit::factory())->create(); + $starredTwice = User::factory()->has(Commit::factory())->create(); + + starGitHubRepositoriesOf($user, [$starredOnce, 1], [$starredTwice, 2]); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($user, limit: 1); + + expect($suggestions->pluck('id')->all())->toBe([$starredTwice->id]) + ->and($suggestions->first()->starred_repositories_count)->toBe(2); +}); + +it('keeps the people whose repositories the user starred among the first suggestions, however large their network', function (): void { + $user = User::factory()->create(); + vouchedFor($user, 30); + popularUsers(5); + $starred = User::factory()->has(Commit::factory())->create(); + + starGitHubRepositoriesOf($user, [$starred, 1]); + + $this->travelTo(today()); + + foreach (range(1, 7) as $day) { + expect(resolve(SuggestedUsersQuery::class)->get($user)->pluck('id')->all())->toContain($starred->id); + + $this->travel(1)->day(); + } +}); + +it('says how many repositories the user starred of the people suggested for other reasons', function (): void { + $user = User::factory()->create(); + [$vouched] = vouchedFor($user, 1); + [$popular] = popularUsers(1); + + starGitHubRepositoriesOf($user, [$vouched, 3]); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($user)->keyBy('id'); + + expect($suggestions)->toHaveCount(2) + ->and($suggestions[$vouched->id]->starred_repositories_count)->toBe(3) + ->and($suggestions[$vouched->id]->followed_by_following_count)->toBe(1) + ->and($suggestions[$popular->id]->starred_repositories_count)->toBe(0); +}); + +it('does not suggest the people whose repositories the user starred once they follow, mute, or dismissed them', function (): void { + $user = User::factory()->create(); + [$followed, $muted, $dismissed] = User::factory()->count(3)->has(Commit::factory())->create(); + + $user->following()->attach($followed); + $user->muting()->attach($muted); + $user->dismissedSuggestions()->attach($dismissed); + starGitHubRepositoriesOf($user, [$followed, 1], [$muted, 1], [$dismissed, 1]); + + expect(resolve(SuggestedUsersQuery::class)->get($user))->toBeEmpty(); +}); + +/** + * @param array{User, int} ...$owners + */ +function starGitHubRepositoriesOf(User $user, array ...$owners): void +{ + DB::table('starred_github_owners')->insert(array_map(fn (array $owner): array => [ + 'user_id' => $user->id, + 'github_id' => $owner[0]->github_id, + 'repositories_count' => $owner[1], + 'created_at' => now(), + ], $owners)); +} From 7e2fe29a6fbc289203e43d9b7b2d8cf8865b9502 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 19:57:47 -0800 Subject: [PATCH 04/10] feat: say when a suggestion is someone whose repos you starred "You starred their repo" or "You starred 3 of their repos" comes before the network and follower reasons, since it's the most personal one. The count is in the API response too. --- app/Http/Resources/UserResource.php | 1 + resources/js/components/who-to-follow.tsx | 11 +++++++++- resources/js/types/auth.ts | 1 + .../Api/SuggestedUserControllerTest.php | 19 ++++++++++++++++ tests/Unit/FollowState.test.tsx | 22 ++++++++++++++++++- 5 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 1f1d0b638..4eb5f17ec 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -49,6 +49,7 @@ public function toArray(Request $request): array 'is_followed' => $this->whenHas('is_followed'), 'follows_you' => $this->whenHas('follows_you'), 'followed_by_following_count' => $this->whenHas('followed_by_following_count'), + 'starred_repositories_count' => $this->whenHas('starred_repositories_count'), ]; } diff --git a/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index d55628979..4893acfba 100644 --- a/resources/js/components/who-to-follow.tsx +++ b/resources/js/components/who-to-follow.tsx @@ -31,7 +31,16 @@ import { edit } from '@/routes/appearance'; import { show } from '@/routes/user'; import type { SuggestedUser } from '@/types'; -function reasonFor(user: SuggestedUser, format = formatCount): string | null { +export function reasonFor( + user: SuggestedUser, + format = formatCount, +): string | null { + if (user.starred_repositories_count > 0) { + return user.starred_repositories_count === 1 + ? 'You starred their repo' + : `You starred ${format(user.starred_repositories_count)} of their repos`; + } + if (user.followed_by_following_count > 0) { return `Followed by ${format(user.followed_by_following_count)} you follow`; } diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 65de0f640..61db7acfe 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -70,6 +70,7 @@ export type Auth = { export type SuggestedUser = User & { followed_by_following_count: number; + starred_repositories_count: number; followers_count: number; follows_you: boolean; }; diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index 83b70e4ab..c76fc70f6 100644 --- a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -6,6 +6,7 @@ use App\Models\Profile; use App\Models\User; use App\Queries\SuggestedUsersQuery; +use Illuminate\Support\Facades\DB; beforeEach(function (): void { $this->user = User::factory()->create(); @@ -42,11 +43,29 @@ ->assertOk() ->assertJsonPath('data.0.id', $popular->id) ->assertJsonPath('data.0.followed_by_following_count', 0) + ->assertJsonPath('data.0.starred_repositories_count', 0) ->assertJsonPath('data.0.followers_count', 2) ->assertJsonPath('data.0.follows_you', false) ->assertJsonPath('data.0.is_followed', false); }); +it('suggests people whose github repositories the user starred, with how many', function (): void { + $starred = User::factory()->has(Commit::factory())->create(); + + DB::table('starred_github_owners')->insert([ + 'user_id' => $this->user->id, + 'github_id' => $starred->github_id, + 'repositories_count' => 2, + 'created_at' => now(), + ]); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')) + ->assertOk() + ->assertJsonPath('data.0.id', $starred->id) + ->assertJsonPath('data.0.starred_repositories_count', 2) + ->assertJsonPath('data.0.followed_by_following_count', 0); +}); + it('sends three suggestions and three spares, as the web does', function (): void { User::factory()->count(8)->has(Commit::factory())->create(); diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index c8c024443..3d2eb9325 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -104,7 +104,7 @@ const { FollowButton } = await import('../../resources/js/components/follow-button'); const { UserPreview } = await import('../../resources/js/components/user-preview'); -const { SuggestedUserRow, WhoToFollow, WhoToFollowInLog } = +const { SuggestedUserRow, WhoToFollow, WhoToFollowInLog, reasonFor } = await import('../../resources/js/components/who-to-follow'); const { refreshSharedState } = await import('../../resources/js/hooks/use-shared-state'); @@ -172,6 +172,7 @@ function suggestion(handle: string): SuggestedUser { timezone: null, show_local_time: false, followed_by_following_count: 0, + starred_repositories_count: 0, followers_count: 0, follows_you: false, } as unknown as SuggestedUser; @@ -284,6 +285,25 @@ test('a suggestion who follows you offers to follow back, at a width that fits i assert.equal(suggestionLabel(suggestion('jess')), 'Follow @jess'); }); +test('a suggestion whose repositories you starred says so before anything else', () => { + const starredOnce = { + ...suggestion('taylor'), + starred_repositories_count: 1, + followed_by_following_count: 4, + }; + + assert.equal(reasonFor(starredOnce), 'You starred their repo'); + assert.equal( + reasonFor({ ...starredOnce, starred_repositories_count: 3 }), + 'You starred 3 of their repos', + ); + assert.equal( + reasonFor({ ...starredOnce, starred_repositories_count: 0 }), + 'Followed by 4 you follow', + ); + assert.equal(reasonFor(suggestion('jess')), null); +}); + test('a preview offers to follow back when they already follow you', () => { const user = { ...suggestion('preview-follower'), follows_you: true }; const button = previewFollowButton(user); From 60880bd5d83e4ebc4fcb1442b69538e6c8ad6489 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 19:58:47 -0800 Subject: [PATCH 05/10] docs: explain suggestions from GitHub stars Covers it in the knowledge base and adds the starred owners to what the privacy policy and the export say we keep. The privacy effective date moves forward with it, as it did for org claims. --- KNOWLEDGE_BASE.md | 7 +++++-- resources/js/lib/legal.ts | 2 +- resources/js/pages/privacy/show.tsx | 5 +++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index ba0625773..2bc53968a 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -181,9 +181,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. If someone already follows you and you do not follow them yet, their profile, hover card, and connections lists offer **Follow back**. 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. The organizations you follow on GitHub come along too: you follow the ones already on /dev/null, and each of the others as soon as it shows up here. This is not continuous synchronization, so unfollowing someone on /dev/null sticks, and following someone on GitHub later does not carry over. -**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 (how many of their repositories you starred on GitHub, 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). +Regular suggestions also include the developers whose repositories you starred on GitHub, taking turns with the people your follows follow so they show up early, the ones you starred most first. Who owns your 300 most recently starred repositories is read once, when you sign in; repositories of organizations are left out. Developers who join later are suggested once they post a commit. Starring never follows anyone for you, and starring more on GitHub later does not carry over. + +Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [star import](app/Actions/CreateUserGitHubStars.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 @@ -629,6 +631,7 @@ Choose **Download JSON** in Settings → Profile to export your account data. Th - the days you used /dev/null (dates, not times), oldest first, and the last of them; - your commits and replies (including pending, rejected, and deleted entries still stored), their cached translations, polls, and your votes; - stars, followers and people you follow, mutes, blocks, and dismissed **Not interested** suggestions; +- the GitHub IDs of the people whose repositories you starred on GitHub, and how many of each, as read for suggestions; - messages you sent, including links to your voice notes, and notifications; - SSH key fingerprints, approved devices, and your timeout history; - active-session metadata: IP address, browser information, and last activity. diff --git a/resources/js/lib/legal.ts b/resources/js/lib/legal.ts index 1b71cb077..60ca25b01 100644 --- a/resources/js/lib/legal.ts +++ b/resources/js/lib/legal.ts @@ -1,4 +1,4 @@ -export const PRIVACY_EFFECTIVE_DATE = '30 October 2026'; +export const PRIVACY_EFFECTIVE_DATE = '2 November 2026'; export const TERMS_EFFECTIVE_DATE = '1 November 2026'; diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index f16d29a6f..fe1dcfd1a 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -54,8 +54,9 @@ export default function Show() { your GitHub ID, username, name, avatar, bio, 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. + listing, your public SSH key fingerprints, who + you follow and who follows you, and whose + repositories you starred, to suggest them. , <> From e3d5bdaf5588c349517f0390870071f92d5c6fcf Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Mon, 2 Nov 2026 20:07:07 -0800 Subject: [PATCH 06/10] feat: add stars:import to bring in stars for people already signed in Sign-ins are remembered, so most current members would never hit the import otherwise. Works like follows:import: it stops before eating into the requests the rest of the app needs, and says when to run it again. --- .../Commands/ImportGitHubStarsCommand.php | 83 ++++++++++++++++ .../Commands/ImportGitHubStarsTest.php | 98 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 app/Console/Commands/ImportGitHubStarsCommand.php create mode 100644 tests/Feature/Commands/ImportGitHubStarsTest.php diff --git a/app/Console/Commands/ImportGitHubStarsCommand.php b/app/Console/Commands/ImportGitHubStarsCommand.php new file mode 100644 index 000000000..a42ded895 --- /dev/null +++ b/app/Console/Commands/ImportGitHubStarsCommand.php @@ -0,0 +1,83 @@ +users()->lazyById() as $user) { + $rateLimit = $github->rateLimit(); + + // A user takes one request for their login, and up to the maximum of pages of stars. + if ($rateLimit !== null && $rateLimit['remaining'] < self::RateLimitFloor + 1 + CreateUserGitHubStars::MaxPages) { + $this->components->warn("Stopped after {$imported} users, to keep ".self::RateLimitFloor." GitHub requests for the rest of the app: {$rateLimit['remaining']} left."); + $this->components->info("{$this->users()->count()} users left. Run the command again after {$rateLimit['resets_at']->utc()->toDateTimeString()} UTC ({$rateLimit['resets_at']->diffForHumans()}), when the GitHub rate limit resets."); + + return self::FAILURE; + } + + try { + $createUserGitHubStars->handle($user); + + $imported++; + } catch (RequestException $exception) { + if (in_array($exception->response->status(), [403, 429], true)) { + $this->components->error("GitHub rate limit reached after {$imported} users. Run the command again later."); + + return self::FAILURE; + } + + $this->failed($user, $exception); + + $failed++; + } catch (ConnectionException $exception) { + $this->failed($user, $exception); + + $failed++; + } + } + + $this->components->info("Imported the GitHub stars of {$imported} users."); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** + * @return Builder + */ + private function users(): Builder + { + return User::query()->whereNull('github_stars_imported_at'); + } + + private function failed(User $user, RequestException|ConnectionException $exception): void + { + report($exception); + + $this->components->warn("Could not import the GitHub stars of {$user->mention()}."); + } +} diff --git a/tests/Feature/Commands/ImportGitHubStarsTest.php b/tests/Feature/Commands/ImportGitHubStarsTest.php new file mode 100644 index 000000000..37bd2faab --- /dev/null +++ b/tests/Feature/Commands/ImportGitHubStarsTest.php @@ -0,0 +1,98 @@ +create(); + $bob = User::factory()->create(); + $imported = User::factory()->create(['github_stars_imported_at' => now()->subDay()]); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('login')->with($alice->github_id)->andReturn($alice->handle); + $github->shouldReceive('starredOwnerIds')->with($alice->handle, 3)->andReturn([$bob->github_id]); + $github->shouldReceive('login')->with($bob->github_id)->andReturn($bob->handle); + $github->shouldReceive('starredOwnerIds')->with($bob->handle, 3)->andReturn([]); + + $this->artisan('stars:import') + ->expectsOutputToContain('Imported the GitHub stars of 2 users.') + ->assertSuccessful(); + + expect(DB::table('starred_github_owners')->where('user_id', $alice->id)->pluck('github_id')->all())->toBe([$bob->github_id]) + ->and($bob->refresh()->github_stars_imported_at)->not->toBeNull(); + + $github->shouldNotHaveReceived('login', [$imported->github_id]); +}); + +it('keeps going when the stars of a user cannot be imported', function (string $exception, Closure $throwable): void { + Exceptions::fake(); + + $failing = User::factory()->create(['handle' => 'failing']); + $user = User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('login')->with($failing->github_id)->andThrow($throwable()); + $github->shouldReceive('login')->with($user->github_id)->andReturn($user->handle); + $github->shouldReceive('starredOwnerIds')->with($user->handle, 3)->andReturn([]); + + $this->artisan('stars:import') + ->expectsOutputToContain('Could not import the GitHub stars of @failing.') + ->expectsOutputToContain('Imported the GitHub stars of 1 users.') + ->assertFailed(); + + expect($failing->refresh()->github_stars_imported_at)->toBeNull() + ->and($user->refresh()->github_stars_imported_at)->not->toBeNull(); + + Exceptions::assertReported($exception); +})->with([ + 'server error' => [RequestException::class, fn (): RequestException => new RequestException(new Response(new Psr7Response(500)))], + 'connection error' => [ConnectionException::class, fn (): ConnectionException => new ConnectionException()], +]); + +it('stops when github rate limits the requests', function (int $status): void { + $user = User::factory()->create(); + User::factory()->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturnNull(); + $github->shouldReceive('login')->with($user->github_id)->once()->andThrow(new RequestException(new Response(new Psr7Response($status)))); + + $this->artisan('stars:import') + ->expectsOutputToContain('GitHub rate limit reached after 0 users. Run the command again later.') + ->assertFailed(); + + $github->shouldHaveReceived('login')->once(); +})->with([403, 429]); + +it('stops before using up the github requests the rest of the app needs, and says when to run it again', function (): void { + $this->travelTo(CarbonImmutable::parse('2026-11-01 10:00:00', 'UTC')); + + $imported = User::factory()->create(); + User::factory()->count(2)->create(); + + $github = $this->mock(GitHubProvider::class); + $github->shouldReceive('rateLimit')->andReturn( + ['remaining' => 1504, 'limit' => 5000, 'resets_at' => CarbonImmutable::parse('2026-11-01 10:42:00', 'UTC')], + ['remaining' => 1503, 'limit' => 5000, 'resets_at' => CarbonImmutable::parse('2026-11-01 10:42:00', 'UTC')], + ); + $github->shouldReceive('login')->with($imported->github_id)->once()->andReturn($imported->handle); + $github->shouldReceive('starredOwnerIds')->with($imported->handle, 3)->andReturn([]); + + $this->artisan('stars:import') + ->expectsOutputToContain('Stopped after 1 users, to keep 1500 GitHub requests for the rest of the app: 1503 left.') + ->expectsOutputToContain('2 users left. Run the command again after 2026-11-01 10:42:00 UTC (42 minutes from now), when the GitHub rate limit resets.') + ->assertFailed(); + + $github->shouldHaveReceived('login')->once(); +}); From 586e11fa743f73e42d89bafca2ddcde3b1fba147 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Tue, 3 Nov 2026 03:48:22 -0800 Subject: [PATCH 07/10] feat: put a star by suggestions from repos you starred --- resources/js/components/who-to-follow.tsx | 25 ++++++++++++++++++----- tests/Unit/FollowState.test.tsx | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index 4893acfba..31e48140a 100644 --- a/resources/js/components/who-to-follow.tsx +++ b/resources/js/components/who-to-follow.tsx @@ -1,5 +1,5 @@ import { Deferred, Link, router, usePage } from '@inertiajs/react'; -import { Ellipsis, EyeOff, RefreshCw, X } from 'lucide-react'; +import { Ellipsis, EyeOff, RefreshCw, Star, X } from 'lucide-react'; import { useRef, useState } from 'react'; import SuggestionDismissalController from '@/actions/App/Http/Controllers/SuggestionDismissalController'; import { @@ -68,13 +68,22 @@ function BioComment({ bio }: { bio: string }) { ); } -function Reason({ reason, title }: { reason: string; title: string }) { +function Reason({ + reason, + title, + isStarred, +}: { + reason: string; + title: string; + isStarred: boolean; +}) { return ( - {reason} + {isStarred && } + {reason} ); } @@ -139,7 +148,13 @@ export function SuggestedUserRow({ const details = (bio || reason) && ( <> {bio && } - {reason && } + {reason && ( + 0} + /> + )} ); const avatarRef = useRef(null); diff --git a/tests/Unit/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index 3d2eb9325..ecc5b3f67 100644 --- a/tests/Unit/FollowState.test.tsx +++ b/tests/Unit/FollowState.test.tsx @@ -1,5 +1,6 @@ /// import assert from 'node:assert/strict'; +import { Star } from 'lucide-react'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { afterAll, afterEach, test, vi } from 'vite-plus/test'; @@ -304,6 +305,25 @@ test('a suggestion whose repositories you starred says so before anything else', assert.equal(reasonFor(suggestion('jess')), null); }); +test('a suggestion whose repositories you starred has a star by the reason', () => { + const starOf = (user: SuggestedUser): ReactElement | null => { + const reason = find( + SuggestedUserRow({ user }), + (props) => 'reason' in props, + ); + assert.ok(reason); + + return find(render(reason), (props) => props['aria-hidden'] === true); + }; + + assert.equal( + starOf({ ...suggestion('taylor'), starred_repositories_count: 2 }) + ?.type, + Star, + ); + assert.equal(starOf({ ...suggestion('jess'), followers_count: 3 }), null); +}); + test('a preview offers to follow back when they already follow you', () => { const user = { ...suggestion('preview-follower'), follows_you: true }; const button = previewFollowButton(user); From 31f22a161c6a2d03ebd5170cf594ef6ea23a9ffa Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Tue, 3 Nov 2026 11:24:45 -0800 Subject: [PATCH 08/10] fix: keep someone you starred in the starred ranking when your network has them too --- app/Queries/SuggestedUsersQuery.php | 7 +++--- .../Unit/Queries/SuggestedUsersQueryTest.php | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index d44af3b07..6fc419094 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -225,7 +225,7 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ /** * One from each in turn, starting with the people whose repositories the user starred, so a page holding a single * person from the network still has one of them and a large network doesn't push them past the first pages. - * Someone in both stays where the network has them. + * Someone in both keeps their place among the starred, with how many the user follows who follow them. * * @param Collection $network * @param Collection $starred @@ -233,8 +233,9 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ */ private function alternate(Collection $network, Collection $starred): Collection { - $starred = array_values($starred->whereNotIn('id', $network->pluck('id'))->all()); - $network = array_values($network->all()); + $networkById = $network->keyBy('id'); + $network = array_values($network->whereNotIn('id', $starred->pluck('id'))->all()); + $starred = array_values($starred->map(fn (User $user): User => $networkById->get($user->id, $user))->all()); $alternated = []; for ($index = 0; $index < max(count($network), count($starred)); $index++) { diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 842300dd0..95f3fd475 100644 --- a/tests/Unit/Queries/SuggestedUsersQueryTest.php +++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php @@ -525,6 +525,31 @@ function popularUsers(int $count): Collection } }); +it('keeps someone the user starred among the first suggestions when their network has them far down', function (): void { + $user = User::factory()->create(); + $vouched = vouchedFor($user, 29); + $friend = User::factory()->create(); + $user->following()->attach($friend); + $friend->following()->attach($vouched); + popularUsers(5); + $starred = User::factory()->has(Commit::factory())->create(); + $friend->following()->attach($starred); + + starGitHubRepositoriesOf($user, [$starred, 1]); + + $this->travelTo(today()); + + foreach (range(1, 7) as $day) { + $suggestions = resolve(SuggestedUsersQuery::class)->get($user)->where('id', $starred->id); + + expect($suggestions)->toHaveCount(1) + ->and($suggestions->first()->followed_by_following_count)->toBe(1) + ->and($suggestions->first()->starred_repositories_count)->toBe(1); + + $this->travel(1)->day(); + } +}); + it('says how many repositories the user starred of the people suggested for other reasons', function (): void { $user = User::factory()->create(); [$vouched] = vouchedFor($user, 1); From 09e5356eb8d4b7070187cf86e71ecde2248c10ec Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Tue, 3 Nov 2026 11:54:03 -0800 Subject: [PATCH 09/10] fix: count who vouches for a starred suggestion even past where the network stops --- app/Queries/SuggestedUsersQuery.php | 9 ++++----- tests/Unit/Queries/SuggestedUsersQueryTest.php | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 6fc419094..86511b382 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -97,8 +97,8 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, $starred = $this->candidates($user, $followingIds) ->joinSub($this->starredOwners($user)->select(['github_id', 'repositories_count']), 'starred', 'starred.github_id', '=', 'users.github_id') ->select(['users.*', 'starred.repositories_count as starred_repositories_count']) - ->selectRaw('0 as followed_by_following_count') - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + // Counted here rather than taken from the network, which stops at so many, so it holds for anyone in both. + ->addSelect(['followed_by_following_count' => $this->followedByFollowing($user, $following)->selectRaw('count(*)'), 'followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) ->whereIn('users.id', $this->committers($user)) ->orderByDesc('starred.repositories_count') ->orderByDesc('followers_count') @@ -225,7 +225,7 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ /** * One from each in turn, starting with the people whose repositories the user starred, so a page holding a single * person from the network still has one of them and a large network doesn't push them past the first pages. - * Someone in both keeps their place among the starred, with how many the user follows who follow them. + * Someone in both keeps their place among the starred. * * @param Collection $network * @param Collection $starred @@ -233,9 +233,8 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ */ private function alternate(Collection $network, Collection $starred): Collection { - $networkById = $network->keyBy('id'); $network = array_values($network->whereNotIn('id', $starred->pluck('id'))->all()); - $starred = array_values($starred->map(fn (User $user): User => $networkById->get($user->id, $user))->all()); + $starred = array_values($starred->all()); $alternated = []; for ($index = 0; $index < max(count($network), count($starred)); $index++) { diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 95f3fd475..6a1e1ac4b 100644 --- a/tests/Unit/Queries/SuggestedUsersQueryTest.php +++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php @@ -527,7 +527,7 @@ function popularUsers(int $count): Collection it('keeps someone the user starred among the first suggestions when their network has them far down', function (): void { $user = User::factory()->create(); - $vouched = vouchedFor($user, 29); + $vouched = vouchedFor($user, 30); $friend = User::factory()->create(); $user->following()->attach($friend); $friend->following()->attach($vouched); From 8bc0e87941d3e2475194ad73fcac25af613daa04 Mon Sep 17 00:00:00 2001 From: algorithm99129 Date: Tue, 3 Nov 2026 11:54:03 -0800 Subject: [PATCH 10/10] refactor: don't look up stars for popular picks, who are never starred --- app/Queries/SuggestedUsersQuery.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 86511b382..e22c19702 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -112,8 +112,8 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, ->whereNotExists($this->followedByFollowing($user, $following)) ->whereNotIn('users.github_id', $this->starredOwners($user)->select('github_id')) ->select('users.*') - ->selectRaw('0 as followed_by_following_count') - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user), 'starred_repositories_count' => $this->starredRepositoriesCount($user)]) + ->selectRaw('0 as followed_by_following_count, 0 as starred_repositories_count') + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) ->orderByDesc('followers_count') ->latest('users.id'), 'users') ->whereIn('users.id', $this->committers($user))