Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 5 additions & 81 deletions app/Actions/UpdateCommitTopics.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,25 @@

namespace App\Actions;

use App\Enums\ProfileType;
use App\Models\Commit;
use App\Models\Profile;
use App\Queries\MentionedOrganizationsQuery;
use App\Services\GitHub;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class UpdateCommitTopics
{
/**
* GitHub is asked about this many handles per commit at most, so one commit can't spend the shared rate limit.
*/
private const int MaxLookups = 5;

/**
* Below this many requests left, lookups wait for the rate limit to reset, leaving the rest to SSH key syncs.
*/
private const int RateLimitFloor = 1000;

private const int NotFoundDays = 1;

public function __construct(
private GitHub $github,
private UpdateMentionedOrganizations $updateMentionedOrganizations,
private MentionedOrganizationsQuery $mentionedOrganizations,
private UpdateOrCreateOrganization $updateOrCreateOrganization,
private DeleteOrganization $deleteOrganization,
private CreateCommitTopics $createCommitTopics,
) {
//
}

/**
* Asks GitHub about the handles the commit mentions that are neither a user nor a known organization, and
* refreshes the known organizations it mentions once their profile is a day old, then links the commit to them.
* A handle that isn't an organization's isn't asked about again for a day, and an organization GitHub no longer
* has is deleted, with the links to it. The links come from the commit as it is after the lookups, locked, so a
* commit erased in the meantime gets none. Returns when to try again if the rate limit cut the lookups short.
* Looks the organizations the commit mentions up, then links the commit to them. The links come from the commit as
* it is after the lookups, locked, so a commit erased in the meantime gets none. Returns when to try again if the
* rate limit cut the lookups short.
*/
public function handle(Commit $commit): ?CarbonImmutable
{
Expand All @@ -54,61 +32,7 @@ public function handle(Commit $commit): ?CarbonImmutable
return null;
}

$known = Profile::query()
->where('type', ProfileType::Organization)
->whereIn(DB::raw('LOWER(handle)'), $handles)
->get();

/** @var list<array{string, Closure(): mixed}> $lookups */
$lookups = collect($handles)
->diff($known->map(fn (Profile $organization): string => Str::lower($organization->handle)))
->reject(fn (string $handle): bool => Cache::has("github-organizations:not-found:{$handle}"))
->map(fn (string $handle): array => [
"users/{$handle}",
fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)),
])
->merge($known
->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true)
->map(fn (Profile $organization): array => [
"user/{$organization->github_id}",
function () use ($organization): void {
$this->deleteOrganization->handle($organization);
},
]))
->take(self::MaxLookups)
->values()
->all();

$retryAt = null;

foreach ($lookups as [$path, $notFound]) {
$pausedUntil = Cache::integer('github-organizations:paused', 0);

if ($pausedUntil > 0) {
$retryAt = CarbonImmutable::createFromTimestamp($pausedUntil);

break;
}

$response = $this->github->request()->get($path);
$remaining = $response->header('X-RateLimit-Remaining');

if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) {
$resetsAt = CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset'));
Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt);
}

if ($response->notFound() || $response->throw()->json('type') !== 'Organization') {
$notFound();

continue;
}

/** @var array<array-key, mixed> $account */
$account = $response->json();

$this->updateOrCreateOrganization->handle($account);
}
$retryAt = $this->updateMentionedOrganizations->handle($handles);

DB::transaction(function () use ($commit): void {
$commit = Commit::withTrashed()->lockForUpdate()->find($commit->id);
Expand Down
105 changes: 105 additions & 0 deletions app/Actions/UpdateMentionedOrganizations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\ProfileType;
use App\Models\Profile;
use App\Services\GitHub;
use Carbon\CarbonImmutable;
use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final readonly class UpdateMentionedOrganizations
{
/**
* GitHub is asked about this many handles at a time at most, so one commit or bio can't spend the shared rate
* limit.
*/
private const int MaxLookups = 5;

/**
* Below this many requests left, lookups wait for the rate limit to reset, leaving the rest to SSH key syncs.
*/
private const int RateLimitFloor = 1000;

private const int NotFoundDays = 1;

public function __construct(
private GitHub $github,
private UpdateOrCreateOrganization $updateOrCreateOrganization,
private DeleteOrganization $deleteOrganization,
) {
//
}

/**
* Asks GitHub about the lowercase handles that are neither a user's nor a known organization's, and refreshes the
* known organizations among them once their profile is a day old. A handle that isn't an organization's isn't asked
* about again for a day, and an organization GitHub no longer has is deleted, with the links to it. Returns when to
* try again if the rate limit cut the lookups short.
*
* @param list<string> $handles
*/
public function handle(array $handles): ?CarbonImmutable
{
$profiles = Profile::query()->whereIn(DB::raw('LOWER(handle)'), $handles)->get();
$known = $profiles->where('type', ProfileType::Organization);

/** @var list<array{string, Closure(): mixed}> $lookups */
$lookups = collect($handles)
->diff($profiles->map(fn (Profile $profile): string => Str::lower($profile->handle)))
->reject(fn (string $handle): bool => Cache::has("github-organizations:not-found:{$handle}"))
->map(fn (string $handle): array => [
"users/{$handle}",
fn (): bool => Cache::put("github-organizations:not-found:{$handle}", true, now()->addDays(self::NotFoundDays)),
])
->merge($known
->filter(fn (Profile $organization): bool => $organization->github_synced_at?->addHours(Profile::FRESH_FOR_HOURS)->isPast() ?? true)
->map(fn (Profile $organization): array => [
"user/{$organization->github_id}",
function () use ($organization): void {
$this->deleteOrganization->handle($organization);
},
]))
->take(self::MaxLookups)
->values()
->all();

$retryAt = null;

foreach ($lookups as [$path, $notFound]) {
$pausedUntil = Cache::integer('github-organizations:paused', 0);

if ($pausedUntil > 0) {
$retryAt = CarbonImmutable::createFromTimestamp($pausedUntil);

break;
}

$response = $this->github->request()->get($path);
$remaining = $response->header('X-RateLimit-Remaining');

if ($remaining !== '' && (int) $remaining < self::RateLimitFloor) {
$resetsAt = CarbonImmutable::createFromTimestamp((int) $response->header('X-RateLimit-Reset'));
Cache::put('github-organizations:paused', $resetsAt->getTimestamp(), $resetsAt);
}

if ($response->notFound() || $response->throw()->json('type') !== 'Organization') {
$notFound();

continue;
}

/** @var array<array-key, mixed> $account */
$account = $response->json();

$this->updateOrCreateOrganization->handle($account);
}

return $retryAt;
}
}
6 changes: 5 additions & 1 deletion app/Actions/UpdateUserProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Actions;

use App\Dto\GitHubProfile;
use App\Jobs\LookUpUserOrganizations;
use App\Models\User;
use App\Services\GitHub;
use Illuminate\Database\UniqueConstraintViolationException;
Expand All @@ -13,7 +14,8 @@
use Illuminate\Support\Facades\DB;

/**
* Mirrors the GitHub profile and SSH keys of a user again, without them having to sign in.
* Mirrors the GitHub profile and SSH keys of a user again, without them having to sign in, and looks the organizations
* their bio and company mention up.
*/
final readonly class UpdateUserProfile
{
Expand Down Expand Up @@ -47,6 +49,8 @@ public function handle(User $user): bool
// The email is left alone: the public API only has the public one, sign-in also knows the private one.
$user->update(['company' => $profile->company]);
});

LookUpUserOrganizations::dispatchFor($user);
} catch (ConnectionException|RequestException|UniqueConstraintViolationException $exception) {
report($exception);

Expand Down
31 changes: 31 additions & 0 deletions app/Console/Commands/LookUpUserOrganizationsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Jobs\LookUpUserOrganizations;
use App\Models\User;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('users:look-up-organizations')]
#[Description('Queue a lookup of the GitHub organizations the bio or company of every user mentions')]
final class LookUpUserOrganizationsCommand extends Command
{
/**
* Safe to run again: handles already known, or already found not to be an organization, cost no request.
*/
public function handle(): int
{
$queued = User::query()
->lazyById()
->filter(LookUpUserOrganizations::dispatchFor(...))
->count();

$this->components->info("Queued the organizations of {$queued} users to be looked up.");

return self::SUCCESS;
}
}
2 changes: 2 additions & 0 deletions app/Http/Controllers/SessionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Actions\DeletePushSubscription;
use App\Actions\UpdateOrCreateUser;
use App\Jobs\ImportGitHubFollowsJob;
use App\Jobs\LookUpUserOrganizations;
use App\Jobs\SyncGitHubSshKeysJob;
use App\Models\User;
use App\Services\GitHubAccountScreening;
Expand Down Expand Up @@ -70,6 +71,7 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri

dispatch(new SyncGitHubSshKeysJob($user));
dispatch(new ImportGitHubFollowsJob($user, notify: $user->wasRecentlyCreated));
LookUpUserOrganizations::dispatchFor($user);

Auth::login($user, remember: true);

Expand Down
4 changes: 3 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Http\Requests\ShowUserRequest;
use App\Models\Commit;
use App\Models\User;
use App\Queries\BioProfilesQuery;
use App\Queries\FollowersYouKnowQuery;
use App\Queries\GitHubReadmeQuery;
use App\Queries\PinnedCommitQuery;
Expand All @@ -32,7 +33,7 @@
/**
* Profiles are public, so guests and crawlers read the same page, with the profile's link preview.
*/
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, PageMetadata $metadata): Response
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response
{
$blockerId = $currentUser?->blockerIdWith($user);

Expand All @@ -55,6 +56,7 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser

return Inertia::render('user/show', [
'user' => $getCountedUser,
'bioProfiles' => fn (): array => $bioProfiles->get($user),
'pasteisReceived' => $currentUser?->pasteis_mode ? $user->pasteis_received : null,
'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false,
'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser),
Expand Down
5 changes: 4 additions & 1 deletion app/Http/Controllers/UserPreviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
namespace App\Http\Controllers;

use App\Models\User;
use App\Queries\BioProfilesQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\JsonResponse;

final readonly class UserPreviewController
{
public function show(#[CurrentUser] ?User $currentUser, User $user): JsonResponse
public function show(#[CurrentUser] ?User $currentUser, User $user, BioProfilesQuery $bioProfiles): JsonResponse
{
if (! $currentUser instanceof User) {
return response()->json([
'user' => $user->loadPublicCounts(),
'bioProfiles' => $bioProfiles->get($user),
'isFollowing' => false,
'followsYou' => false,
'isMuting' => false,
Expand All @@ -27,6 +29,7 @@ public function show(#[CurrentUser] ?User $currentUser, User $user): JsonRespons

return response()->json([
'user' => $blockerId === null ? $user->loadCounts($currentUser) : $user,
'bioProfiles' => $bioProfiles->get($user),
'isFollowing' => $currentUser->isFollowing($user),
'followsYou' => $user->isFollowing($currentUser),
'isMuting' => $currentUser->isMuting($user),
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Http\Requests\ListNotificationsRequest;
use App\Models\Commit;
use App\Models\User;
use App\Queries\BioProfilesQuery;
use App\Queries\ConversationsQuery;
use App\Queries\DevelopersCountQuery;
use App\Queries\LiveFollowingQuery;
Expand Down Expand Up @@ -91,6 +92,7 @@ public function share(Request $request): array
'repository' => config('services.github.repository'),
'auth' => [
'user' => fn (): ?User => $request->user()?->loadCounts(),
'bioProfiles' => fn (): array => $user instanceof User ? resolve(BioProfilesQuery::class)->get($user) : [],
'isAdmin' => $isAdmin,
'automaticTranslation' => $user->automatic_translation ?? true,
'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null,
Expand Down
Loading
Loading