diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d29acd2c7b..67957c8a61 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -194,9 +194,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. Follow buttons and dismissed suggestions also update in your other open tabs, keeping the other suggestions in 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. Follow buttons and dismissed suggestions also update in your other open tabs, keeping the other suggestions in 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. If that finishes after your suggestions are on screen, in onboarding or the side column, they are picked again right away. 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 @@ -650,6 +652,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/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index c129d6dd32..d0c242a3ca 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, @@ -129,6 +130,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, @@ -413,6 +415,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 0000000000..1a4775a6a2 --- /dev/null +++ b/app/Actions/CreateUserGitHubStars.php @@ -0,0 +1,61 @@ +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 { + $saved = DB::table('starred_github_owners')->insertOrIgnore($rows); + + $user->update(['github_stars_imported_at' => now()]); + + // The suggestions on screen were picked without them, and are kept across visits, so they're picked again. + if ($saved > 0) { + event(new UsersUpdated([$user->id], UserUpdate::GitHubStars)); + } + }); + } +} diff --git a/app/Console/Commands/ImportGitHubStarsCommand.php b/app/Console/Commands/ImportGitHubStarsCommand.php new file mode 100644 index 0000000000..a42ded8954 --- /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/app/Contracts/Services/GitHubProvider.php b/app/Contracts/Services/GitHubProvider.php index f1f7fdaba3..90edb90b88 100644 --- a/app/Contracts/Services/GitHubProvider.php +++ b/app/Contracts/Services/GitHubProvider.php @@ -77,6 +77,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/Enums/UserUpdate.php b/app/Enums/UserUpdate.php index 1203334593..d9c972864f 100644 --- a/app/Enums/UserUpdate.php +++ b/app/Enums/UserUpdate.php @@ -21,6 +21,7 @@ enum UserUpdate: string case Account = 'account'; case Relationships = 'relationships'; case Suggestions = 'suggestions'; + case GitHubStars = 'github-stars'; case Commits = 'commits'; case Moderation = 'moderation'; case Live = 'live'; diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index ec65855b41..531d433e05 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -25,7 +25,7 @@ public function show(ShowOnboardingRequest $request, #[CurrentUser] User $user, return Inertia::render('onboarding/show', [ 'preferredLane' => $user->preferredLane(), 'automaticLane' => $user->automaticLane(), - 'suggestions' => fn (): Collection => $suggested->get($user, limit: 5), + 'suggestions' => Inertia::live(fn (): Collection => $suggested->get($user, limit: 5), on: UserUpdate::GitHubStars->value, channel: new PrivateChannel($user)), 'githubImport' => Inertia::live(fn (): ?array => $this->githubImport($request, $user), on: UserUpdate::Onboarding->value, channel: new PrivateChannel($user)), 'commitScriptUrl' => route('cli-script.show'), ]); diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index f9e01efced..a33ab4fbd5 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; @@ -68,6 +69,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/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 1f1d0b6383..4eb5f17ec3 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/app/Jobs/ImportGitHubStarsJob.php b/app/Jobs/ImportGitHubStarsJob.php new file mode 100644 index 0000000000..df543d227a --- /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 719ed131ea..7c761711f4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -66,6 +66,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 @@ -122,6 +123,7 @@ 'onboarded_at', 'github_follows_imported_at', 'github_organization_follows_imported_at', + 'github_stars_imported_at', 'live_platform', 'live_url', 'live_ends_at', @@ -228,6 +230,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/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 716639226f..e22c19702c 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,12 +94,25 @@ 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']) + // 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') + ->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') + ->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') @@ -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 keeps their place among the starred. + * + * @param Collection $network + * @param Collection $starred + * @return Collection + */ + private function alternate(Collection $network, Collection $starred): Collection + { + $network = array_values($network->whereNotIn('id', $starred->pluck('id'))->all()); + $starred = array_values($starred->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/app/Services/GitHubProviders/HttpGitHubProvider.php b/app/Services/GitHubProviders/HttpGitHubProvider.php index 390d7fdf99..8909308b17 100644 --- a/app/Services/GitHubProviders/HttpGitHubProvider.php +++ b/app/Services/GitHubProviders/HttpGitHubProvider.php @@ -136,6 +136,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 */ @@ -318,20 +329,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 2d6c888f58..0aac980c63 100644 --- a/app/Services/GitHubProviders/NullGitHubProvider.php +++ b/app/Services/GitHubProviders/NullGitHubProvider.php @@ -56,6 +56,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/database/factories/UserFactory.php b/database/factories/UserFactory.php index 698b1db43b..f7023f8dfc 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 0000000000..f82dad9de0 --- /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 0000000000..6d013c86a2 --- /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/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index d926c8dbf4..b38c4b891d 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 { useEffect, useEffectEvent, useRef, useState } from 'react'; import { index as suggestionStates } from '@/actions/App/Http/Controllers/SuggestedUserStateController'; import SuggestionDismissalController from '@/actions/App/Http/Controllers/SuggestionDismissalController'; @@ -34,7 +34,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`; } @@ -62,13 +71,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} ); } @@ -163,6 +181,29 @@ function useSuggestedUserUpdates(dismissElsewhere: (ids: string[]) => void) { }, [viewerId, ids, showsWhoToFollow]); } +/** + * Stars imported after the suggestions were picked can bring someone new, even when there were no suggestions yet, so + * the suggestions are picked again, in the same round. + */ +function useSuggestionsFromStars() { + const { auth, showsWhoToFollow } = usePage().props; + const viewerId = auth.user?.id; + + useEffect(() => { + if (!viewerId || !showsWhoToFollow) { + return; + } + + return listen(userChannel(viewerId), { + 'github-stars': () => + router.reload({ + only: ['suggestedUsers'], + reset: ['suggestedUsers'], + }), + }); + }, [viewerId, showsWhoToFollow]); +} + function DismissButton({ user, onDismiss, @@ -221,7 +262,13 @@ export function SuggestedUserRow({ const details = (bio || reason) && ( <> {bio && } - {reason && } + {reason && ( + 0} + /> + )} ); const avatarRef = useRef(null); @@ -744,6 +791,7 @@ export function WhoToFollow() { } = useHideWhoToFollow(); useSuggestedUserUpdates(dismissElsewhere); + useSuggestionsFromStars(); if (!isVisible) { return null; diff --git a/resources/js/lib/legal.ts b/resources/js/lib/legal.ts index 1b71cb0773..60ca25b014 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 f16d29a6f9..fe1dcfd1ac 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. , <> diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index 55b21f3f71..57f41d659f 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -75,6 +75,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/Commands/ImportGitHubStarsTest.php b/tests/Feature/Commands/ImportGitHubStarsTest.php new file mode 100644 index 0000000000..37bd2faabb --- /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(); +}); diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index 83b70e4aba..c76fc70f65 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/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 241d6446cd..ba7ec50381 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -147,6 +147,7 @@ 'organizations' => [], 'followed_organizations' => [], 'pending_follows' => [], + 'starred_github_owners' => [], 'messages' => [], 'reports' => [], 'remote_mutes' => [], @@ -538,6 +539,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, with what kind of phone a push token is for but not the token', 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 9fd9479345..85c7c7d301 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/Feature/LiveUpdatesTest.php b/tests/Feature/LiveUpdatesTest.php index 63f0edd5ae..ffab8e6559 100644 --- a/tests/Feature/LiveUpdatesTest.php +++ b/tests/Feature/LiveUpdatesTest.php @@ -12,6 +12,7 @@ use App\Actions\CreateScheduledCommit; use App\Actions\CreateUserBlock; use App\Actions\CreateUserFollow; +use App\Actions\CreateUserGitHubStars; use App\Actions\CreateUserTimeout; use App\Actions\DeleteOrganization; use App\Actions\DeleteUser; @@ -25,6 +26,7 @@ use App\Actions\UpdateUserLog; use App\Actions\UpdateUserTermsAcceptance; use App\Actions\UpdateUserTranslationLanguage; +use App\Contracts\Services\GitHubProvider; use App\Contracts\Services\LinkPreviewProvider; use App\Dto\AuthorizationCode; use App\Enums\CommitStatus; @@ -474,6 +476,36 @@ function onUpdate(UserUpdate $update, Closure $read): Closure ->and($account())->toBe([[1, 0], [1, 1]]); }); +it('announces imported GitHub stars once they are stored, and only when they bring someone', function (): void { + Event::fake([CommitsUpdated::class, ProfilesUpdated::class]); + $user = User::factory()->create(); + $starless = User::factory()->create(); + $stars = onUpdate(UserUpdate::GitHubStars, fn (): int => DB::table('starred_github_owners')->count()); + + $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']); + $github->shouldReceive('login')->with($starless->github_id)->andReturn($starless->handle); + $github->shouldReceive('starredOwnerIds')->with($starless->handle, 3)->andReturn([]); + + resolve(CreateUserGitHubStars::class)->handle($user); + resolve(CreateUserGitHubStars::class)->handle($starless); + + expect($stars())->toBe([2]); +}); + +it('picks the onboarding suggestions again when GitHub stars are imported', function (): void { + $user = User::factory()->notOnboarded()->create(); + $headers = ['X-Inertia' => 'true', 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request())]; + + $live = $this->actingAs($user)->get(route('onboarding.show'), $headers)->json('liveProps'); + + expect($live['suggestions']['listeners'])->toBe([[ + 'channel' => ['name' => 'users.'.$user->id, 'type' => 'private'], + 'events' => ['github-stars'], + ]]); +}); + it('announces nothing when storing a scheduled commit fails', function (): void { Event::fake([UsersUpdated::class]); $user = User::factory()->create(); diff --git a/tests/Unit/Actions/CreateUserGitHubStarsTest.php b/tests/Unit/Actions/CreateUserGitHubStarsTest.php new file mode 100644 index 0000000000..659f9163f6 --- /dev/null +++ b/tests/Unit/Actions/CreateUserGitHubStarsTest.php @@ -0,0 +1,107 @@ +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('tells only the user that their suggestions can change, once the stars are in', function (): void { + Event::fake([UsersUpdated::class]); + $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']); + + resolve(CreateUserGitHubStars::class)->handle($user); + + Event::assertDispatchedTimes(UsersUpdated::class, 1); + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->update === UserUpdate::GitHubStars && $event->userIds === [$user->id]); +}); + +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 cc7cf39fb9..89f00bd3c4 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/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index 8b227931af..d5c5326fd3 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'; @@ -105,7 +106,7 @@ const { FollowButton, prepareFollowingUpdate } = 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'); @@ -173,6 +174,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; @@ -441,6 +443,44 @@ 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 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); diff --git a/tests/Unit/Jobs/ImportGitHubStarsJobTest.php b/tests/Unit/Jobs/ImportGitHubStarsJobTest.php new file mode 100644 index 0000000000..bee6a8640b --- /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']); +}); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 4877aa4421..6a1e1ac4bb 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,98 @@ 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('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, 30); + $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); + [$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)); +} diff --git a/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php b/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php index c8d32fb9d7..a2135d2404 100644 --- a/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php +++ b/tests/Unit/Services/GitHubProviders/HttpGitHubProviderTest.php @@ -194,6 +194,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 d13002a7fd..4cd2af095c 100644 --- a/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php +++ b/tests/Unit/Services/GitHubProviders/NullGitHubProviderTest.php @@ -22,6 +22,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() diff --git a/tests/Unit/WhoToFollowRealtime.test.tsx b/tests/Unit/WhoToFollowRealtime.test.tsx index b1484587b2..9a67cc7c5a 100644 --- a/tests/Unit/WhoToFollowRealtime.test.tsx +++ b/tests/Unit/WhoToFollowRealtime.test.tsx @@ -12,6 +12,7 @@ let states: { id: string; is_following: boolean; is_dismissed: boolean }[]; let visibility: EventTarget & { hidden: boolean }; let reads: string[][]; let topUps: number; +let reloads: { only?: string[]; reset?: string[] }[]; let fake: ReturnType; vi.doMock('react', async () => ({ @@ -35,10 +36,13 @@ vi.doMock('@inertiajs/react', async () => ({ suggestedUsers = update(suggestedUsers); }, reload: (options: { + only?: string[]; + reset?: string[]; onBeforeUpdate?: () => void; onFinish?: () => void; }) => { topUps++; + reloads.push({ only: options.only, reset: options.reset }); options.onBeforeUpdate?.(); options.onFinish?.(); }, @@ -79,6 +83,7 @@ beforeEach(() => { ); reads = []; topUps = 0; + reloads = []; visibility = Object.assign(new EventTarget(), { hidden: false, cookie: '', @@ -294,3 +299,30 @@ test('restoring an old list removes a dismissed suggestion again', async () => { false, ); }); + +test('stars imported after the suggestions loaded pick them again, replacing the rows', async () => { + render(); + joinAll(fake); + await settle(); + + fake.joined.get('private-users.viewer')?.emit('github-stars'); + await settle(); + + assert.deepEqual(reloads, [ + { only: ['suggestedUsers'], reset: ['suggestedUsers'] }, + ]); +}); + +test('stars imported with no suggestions shown yet pick some', async () => { + suggestedUsers = []; + render(); + joinAll(fake); + await settle(); + + fake.joined.get('private-users.viewer')?.emit('github-stars'); + await settle(); + + assert.deepEqual(reloads, [ + { only: ['suggestedUsers'], reset: ['suggestedUsers'] }, + ]); +});