Skip to content
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ TURNSTILE_SECRET_KEY=
# Leave empty to rely on the widget's domain list in the Cloudflare dashboard.
TURNSTILE_HOSTNAMES=

GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_TOKEN=
# When the legacy OAuth App stops working, e.g. 2026-12-31. Shown in the upgrade banner and email.
GITHUB_LEGACY_OAUTH_CUTOFF_DATE=

GITHUB_APP_ID=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
# The app's PEM private key, in double quotes. Either paste it across multiple lines or put it on one line with \n for each line break.
GITHUB_APP_PRIVATE_KEY=
GITHUB_APP_WEBHOOK_SECRET=

# TypeSafe's Jev model groups plugin marketplace searches that found nothing
# into plugin ideas (see ClassifyMissedPluginSearch).
TYPESAFE_API_KEY=
81 changes: 81 additions & 0 deletions app/Console/Commands/RetireLegacyGitHubOAuth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace App\Console\Commands;

use App\Enums\GitHubAuthType;
use App\Models\User;
use App\Services\GitHubAppService;
use Illuminate\Console\Command;

class RetireLegacyGitHubOAuth extends Command
{
protected $signature = 'github:retire-legacy-oauth
{--dry-run : Show what would change without changing anything}
{--force : Skip the cutoff date check and confirmation}';

protected $description = 'Clear the stored tokens from the legacy GitHub OAuth App once it has been switched off';

public function handle(GitHubAppService $appService): int
{
$dryRun = $this->option('dry-run');
$force = $this->option('force');

if (! $force && ! $appService->legacyOAuthHasBeenRetired()) {
$cutoffDate = $appService->legacyOAuthCutoffDate();

$this->error($cutoffDate
? "The cutoff date ({$cutoffDate->format('j F Y')}) hasn't passed yet. Use --force to run it anyway."
: 'GITHUB_LEGACY_OAUTH_CUTOFF_DATE isn\'t set. Set it, or use --force to run it anyway.');

return self::FAILURE;
}

$users = User::query()
->where('github_auth_type', GitHubAuthType::OAuth)
->whereNotNull('github_token')
->with('plugins')
->get();

$authors = $users->filter(fn (User $user): bool => $user->plugins->isNotEmpty());

$this->info("Found {$users->count()} user(s) with a legacy OAuth token, {$authors->count()} of them plugin authors");

if ($authors->isNotEmpty()) {
$this->newLine();
$this->warn('These plugin authors never connected the GitHub App. Their plugins will only sync if the repo is public:');
$this->table(
['User', 'Email', 'Plugins'],
$authors->map(fn (User $user): array => [
$user->id,
$user->email,
$user->plugins->pluck('name')->filter()->implode(', '),
])
);
}

if ($dryRun) {
$this->info('DRY RUN - No tokens were cleared');

return self::SUCCESS;
}

if (! $force && ! $this->confirm("Clear {$users->count()} legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.")) {
$this->info('Nothing changed.');

return self::SUCCESS;
}

$cleared = User::query()
->whereKey($users->modelKeys())
->update([
'github_token' => null,
'github_refresh_token' => null,
'github_token_expires_at' => null,
]);

$this->info("Cleared {$cleared} legacy token(s).");
$this->line('If you haven\'t already, delete the old OAuth App on GitHub and remove GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.');

return self::SUCCESS;
}
}
69 changes: 69 additions & 0 deletions app/Console/Commands/SendGitHubAppMigrationNotice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace App\Console\Commands;

use App\Enums\GitHubAuthType;
use App\Models\User;
use App\Notifications\GitHubAppMigrationRequired;
use App\Services\GitHubAppService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;

class SendGitHubAppMigrationNotice extends Command
{
protected $signature = 'github:send-app-migration-notice
{--dry-run : Show who would be emailed without sending anything}
{--preview= : Send a single copy to this address instead of to users}';

protected $description = 'Email users still on the legacy GitHub OAuth App about the move to the GitHub App';

public function handle(GitHubAppService $appService): int
{
if ($preview = $this->option('preview')) {
Notification::route('mail', $preview)->notifyNow(new GitHubAppMigrationRequired);

$this->info("Sent a preview to {$preview}");

return self::SUCCESS;
}

$dryRun = $this->option('dry-run');

if (! $dryRun && ! $appService->legacyOAuthCutoffDate()) {
$this->error('Set GITHUB_LEGACY_OAUTH_CUTOFF_DATE first, the email tells plugin authors when the old connection stops working.');

return self::FAILURE;
}

if ($dryRun) {
$this->info('DRY RUN - No emails will be sent');
}

$users = User::query()
->where('github_auth_type', GitHubAuthType::OAuth)
->whereNull('github_app_migration_notified_at')
->whereNotNull('email_verified_at')
->withCount('plugins')
->get();

$this->info("Found {$users->count()} user(s) still on the legacy OAuth App who haven't been emailed, {$users->where('plugins_count', '>', 0)->count()} of them plugin authors");

foreach ($users as $user) {
if ($dryRun) {
$this->line("Would send to: {$user->email} ({$user->plugins_count} plugin(s))");

continue;
}

$user->notify(new GitHubAppMigrationRequired);
$user->update(['github_app_migration_notified_at' => now()]);

$this->line("Sent to: {$user->email}");
}

$this->newLine();
$this->info($dryRun ? "Would send: {$users->count()} email(s)" : "Sent: {$users->count()} email(s)");

return self::SUCCESS;
}
}
44 changes: 44 additions & 0 deletions app/Console/Commands/SyncGitHubInstallations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Console\Commands;

use App\Models\GitHubInstallation;
use App\Services\GitHubAppService;
use App\Services\GitHubUserService;
use Illuminate\Console\Command;

class SyncGitHubInstallations extends Command
{
protected $signature = 'github:sync-installations';

protected $description = 'Refresh every GitHub App installation from GitHub, catching any installation webhooks we missed';

public function handle(GitHubAppService $appService): int
{
$synced = 0;
$removed = 0;
$failed = 0;

GitHubInstallation::query()->with('user')->chunkById(100, function ($installations) use ($appService, &$synced, &$removed, &$failed): void {
foreach ($installations as $installation) {
if ($appService->syncInstallation($installation)) {
$synced++;
} elseif (! $installation->exists) {
$removed++;
$this->line("Removed installation {$installation->installation_id} ({$installation->account_login}), GitHub no longer has it");
} else {
$failed++;
$this->error("Failed to sync installation {$installation->installation_id} ({$installation->account_login})");
}

if ($installation->user) {
GitHubUserService::for($installation->user)->clearRepositoryCache();
}
}
});

$this->info("Synced: {$synced}, removed: {$removed}, failed: {$failed}");

return self::SUCCESS;
}
}
6 changes: 6 additions & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ protected function schedule(Schedule $schedule): void
->onOneServer()
->runInBackground();

// Reconcile GitHub App installations in case we missed any installation webhooks
$schedule->command('github:sync-installations')
->dailyAt('09:30')
->onOneServer()
->runInBackground();

// Remove Discord Max role for users with expired Max licenses
$schedule->command('discord:remove-expired-roles')
->dailyAt('10:30')
Expand Down
17 changes: 17 additions & 0 deletions app/Enums/GitHubAuthType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Enums;

enum GitHubAuthType: string
{
case OAuth = 'oauth';
case App = 'app';

public function label(): string
{
return match ($this) {
self::OAuth => 'OAuth App',
self::App => 'GitHub App',
};
}
}
22 changes: 21 additions & 1 deletion app/Filament/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Filament\Resources;

use App\Enums\GitHubAuthType;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
Expand Down Expand Up @@ -147,6 +148,18 @@ public static function table(Table $table): Table
->label('Developer')
->boolean()
->getStateUsing(fn (User $record) => $record->developerAccount !== null),
Tables\Columns\TextColumn::make('github_auth_type')
->label('GitHub')
->badge()
->formatStateUsing(fn (GitHubAuthType $state) => $state->label())
->color(fn (GitHubAuthType $state) => $state === GitHubAuthType::App ? 'success' : 'warning')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('github_app_migration_notified_at')
->label('GitHub App email sent')
->dateTime()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Expand All @@ -155,7 +168,14 @@ public static function table(Table $table): Table
->sortable(),
])
->filters([
//
Tables\Filters\SelectFilter::make('github_auth_type')
->label('GitHub connection')
->options(collect(GitHubAuthType::cases())->mapWithKeys(
fn (GitHubAuthType $type) => [$type->value => $type->label()]
)),
Tables\Filters\Filter::make('plugin_authors_on_legacy_oauth')
->label('Plugin authors still on the OAuth App')
->query(fn ($query) => $query->where('github_auth_type', GitHubAuthType::OAuth)->has('plugins')),
])
->actions([
Impersonate::make(),
Expand Down
Loading
Loading