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/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/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/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/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/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/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/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 716639226..e22c19702 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 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/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/resources/js/components/who-to-follow.tsx b/resources/js/components/who-to-follow.tsx index d55628979..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 { @@ -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`; } @@ -59,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} ); } @@ -130,7 +148,13 @@ export function SuggestedUserRow({ const details = (bio || reason) && ( <> {bio && } - {reason && } + {reason && ( + 0} + /> + )} ); const avatarRef = useRef(null); 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. , <> 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/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(); +}); 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/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/FollowState.test.tsx b/tests/Unit/FollowState.test.tsx index c8c024443..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'; @@ -104,7 +105,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 +173,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 +286,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 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']); +}); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index 4877aa442..6a1e1ac4b 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 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()