Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Spatie\Permission\Models\Role;
use function Laravel\Prompts\select;
/**
* Bootstraps administration.
*
* Local accounts only appear after their first OAuth login, so there is no
* super admin to promote anyone at install time. This command is the way in:
* sign in once through the panel, then run
*
* php artisan user:role you@example.com super_admin
*/
class AssignRole extends Command
{
protected $signature = 'user:role {email : Email of the user, as known to the identity server}
{role? : Role to grant; prompts when omitted}
{--revoke : Remove the role instead of granting it}
{--only : Replace all of the user\'s roles with this one}';
protected $description = 'Grant or revoke a local role for a user';
public function handle(): int
{
$user = User::query()->where('email', $this->argument('email'))->first();
if (! $user) {
$this->error("No local user with email [{$this->argument('email')}].");
$this->line('Local accounts are created on first login. Ask them to sign in once, then re-run this.');
return self::FAILURE;
}
$role = $this->argument('role') ?? select(
label: 'Which role?',
options: Role::query()->pluck('name', 'name')->all(),
);
if (! Role::query()->where('name', $role)->exists()) {
$this->error("No role named [{$role}]. Run `php artisan permissions:sync` first.");
return self::FAILURE;
}
match (true) {
(bool) $this->option('revoke') => $user->removeRole($role),
(bool) $this->option('only') => $user->syncRoles([$role]),
default => $user->assignRole($role),
};
$this->info(sprintf(
'%s now has roles: %s',
$user->email,
$user->fresh()->roles->pluck('name')->implode(', ') ?: '(none)',
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Yaml\Yaml;
/**
* TypeScript definitions for whoever writes the frontend.
*
* The specification is already the contract; this is the same contract in the
* form an editor can check while somebody types. It matters more than usual
* here: the frontend is a separate project in an undecided framework, so
* everything about these payloads that is not written down has to be
* remembered, and the one thing every candidate framework agrees on is
* TypeScript.
*
* Generated rather than written, and `--check` exists so it can be proved to
* be current: a hand-maintained type file drifts exactly like a
* hand-maintained specification, which is the problem this started from.
*/
class GenerateApiTypesCommand extends Command
{
protected $signature = 'api:types
{--output=public/api-types.d.ts : Where to write them}
{--check : Fail if the file on disk is out of date, and write nothing}';
protected $description = 'Generate TypeScript definitions for the public API from openapi.yaml';
/**
* @var array<string, mixed>
*/
protected array $spec = [];
public function handle(): int
{
$source = public_path('openapi.yaml');
if (! file_exists($source)) {
$this->components->error(__('console.types.no_spec'));
return self::FAILURE;
}
$this->spec = (array) Yaml::parseFile($source);
$output = base_path((string) $this->option('output'));
$generated = $this->render();
if ($this->option('check')) {
$current = file_exists($output) ? file_get_contents($output) : null;
if ($current === $generated) {
$this->components->info(__('console.types.current'));
return self::SUCCESS;
}
$this->components->error(__('console.types.stale'));
return self::FAILURE;
}
file_put_contents($output, $generated);
$this->components->info(__('console.types.written', [
'path' => $this->option('output'),
'count' => count($this->schemas()),
]));
return self::SUCCESS;
}
protected function render(): string
{
$lines = [
'/**',
// From the document, never from the configuration. This file is
// generated, committed and checked with `--check`, so anything in
// it that varies by environment turns that check into a failure
// that depends on where it ran.
' * '.($this->spec['info']['title'] ?? 'This application').' — the public payloads.',
' *',
' * Generated from openapi.yaml by `php artisan api:types`. Do not edit:',
' * change the specification instead, and the contract test will hold the',
' * API to it.',
' */',
'',
];
foreach ($this->schemas() as $name => $schema) {
$lines[] = $this->renderSchema((string) $name, (array) $schema);
}
$lines[] = $this->renderPaths();
return implode("\n", $lines);
}
/**
* Only the shapes the public API answers with. The administration schemas
* are in the same document, but they are read by callers holding a token
* and writing PHP or curl, not by the frontend.
*
* @return array<string, mixed>
*/
protected function schemas(): array
{
$schemas = (array) ($this->spec['components']['schemas'] ?? []);
$wanted = array_filter(
array_keys($schemas),
fn (string $name) => str_starts_with($name, 'Public')
|| in_array($name, ['Image', 'Alternates', 'TranslatedField'], true),
);
// Alphabetical, so a schema added upstream never reshuffles the file.
sort($wanted);
return array_replace(array_flip($wanted), array_intersect_key($schemas, array_flip($wanted)));
}
/**
* @param array<string, mixed> $schema
*/
protected function renderSchema(string $name, array $schema): string
{
$doc = $this->docBlock($schema['description'] ?? null, '');
$doc = $doc === '' ? '' : $doc."\n";
if (($schema['type'] ?? null) !== 'object' || ! isset($schema['properties'])) {
return "{$doc}export type {$name} = ".$this->type($schema).";\n";
}
$lines = ["{$doc}export interface {$name} {"];
foreach ((array) $schema['properties'] as $property => $definition) {
$definition = (array) $definition;
if ($comment = $this->docBlock($definition['description'] ?? null, ' ')) {
$lines[] = $comment;
}
// Nullable is this document's way of saying "may not be there",
// which is what the contract test holds the API to as well.
$optional = ($definition['nullable'] ?? false) === true ? '?' : '';
$lines[] = " {$property}{$optional}: ".$this->type($definition).';';
}
$lines[] = "}\n";
return implode("\n", $lines);
}
/**
* A map from every public path to what it answers with, so a typed client
* can be written without repeating any of this by hand.
*/
protected function renderPaths(): string
{
$lines = [
'/** What each public endpoint answers with, under `data`. */',
'export interface PublicApiPayloads {',
];
foreach ((array) ($this->spec['paths'] ?? []) as $path => $operations) {
if (! str_starts_with((string) $path, '/public/v1') || ! isset($operations['get'])) {
continue;
}
$schema = $operations['get']['responses'][200]['content']['application/json']['schema'] ?? null;
if ($schema === null) {
continue;
}
$data = (array) ($this->resolve((array) $schema)['properties']['data'] ?? []);
$lines[] = " '{$path}': ".($data === [] ? 'unknown' : $this->type($data)).';';
}
$lines[] = '}';
$lines[] = '';
return implode("\n", $lines);
}
/**
* One schema as a TypeScript type.
*
* @param array<string, mixed> $schema
*/
protected function type(array $schema): string
{
if (isset($schema['$ref'])) {
return basename((string) $schema['$ref']);
}
if (isset($schema['allOf'])) {
$parts = array_map(fn ($part) => $this->type((array) $part), (array) $schema['allOf']);
return implode(' & ', $parts);
}
if (isset($schema['enum'])) {
return implode(' | ', array_map(
fn ($value) => is_string($value) ? "'{$value}'" : json_encode($value),
(array) $schema['enum'],
));
}
return match ($schema['type'] ?? 'object') {
'array' => $this->arrayType((array) ($schema['items'] ?? [])),
'integer', 'number' => 'number',
'boolean' => 'boolean',
'string' => 'string',
'object' => $this->objectType($schema),
default => 'unknown',
};
}
/**
* @param array<string, mixed> $items
*/
protected function arrayType(array $items): string
{
$type = $items === [] ? 'unknown' : $this->type($items);
// `(A & B)[]` rather than `A & B[]`, which would mean something else.
return str_contains($type, ' ') ? "({$type})[]" : "{$type}[]";
}
/**
* @param array<string, mixed> $schema
*/
protected function objectType(array $schema): string
{
if (! isset($schema['properties'])) {
// The one shape this covers is `alternates`: a locale code to a
// path, with the locales known only at runtime.
$values = isset($schema['additionalProperties']) && is_array($schema['additionalProperties'])
? $this->type((array) $schema['additionalProperties'])
: 'string';
return "Record<string, {$values}>";
}
$fields = [];
foreach ((array) $schema['properties'] as $property => $definition) {
$definition = (array) $definition;
$optional = ($definition['nullable'] ?? false) === true ? '?' : '';
$fields[] = "{$property}{$optional}: ".$this->type($definition);
}
return '{ '.implode('; ', $fields).' }';
}
/**
* Descriptions carried across, because half of what this document knows is
* in its prose — why a field is only on the detail endpoint, what a null
* means — and none of that survives a type on its own.
*/
protected function docBlock(?string $text, string $indent): string
{
$text = trim((string) $text);
if ($text === '') {
return '';
}
$lines = array_map(
fn (string $line) => "{$indent} * ".rtrim($line),
explode("\n", $text),
);
return "{$indent}/**\n".implode("\n", $lines)."\n{$indent} */";
}
/**
* @param array<string, mixed> $schema
* @return array<string, mixed>
*/
protected function resolve(array $schema): array
{
if (isset($schema['$ref'])) {
$name = basename((string) $schema['$ref']);
return $this->resolve((array) ($this->spec['components']['schemas'][$name] ?? []));
}
return $schema;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Media;
use App\Services\Media\ImageProcessor;
use Illuminate\Console\Command;
/**
* The safety net, and the way back from a change of settings.
*
* Uploads queue their own processing, but a queue that is not running would
* leave images without dimensions and nobody would notice until the page
* reflowed. The scheduler runs this every ten minutes so a stalled worker
* costs a delay rather than a permanently half-done image.
*
* `--all` reprocesses everything, which is what you want after changing the
* widths or adding a format: the media table is derived, so it can always be
* rebuilt from the files themselves.
*/
class ProcessMediaCommand extends Command
{
protected $signature = 'media:process
{--all : Reprocess every image, not only the pending ones}
{--limit=100 : Maximum number of images to handle in one run}';
protected $description = 'Derive dimensions, responsive versions and placeholders for uploaded images';
public function handle(ImageProcessor $processor): int
{
$media = Media::query()
->unless($this->option('all'), fn ($q) => $q->pending())
->orderBy('id')
->limit(max(1, (int) $this->option('limit')))
->get();
if ($media->isEmpty()) {
$this->components->info('Nothing to process.');
return self::SUCCESS;
}
$failed = 0;
foreach ($media as $item) {
$processor->process($item->refresh());
if ($item->refresh()->error) {
$failed++;
$this->components->error("{$item->path} — {$item->error}");
continue;
}
$this->components->twoColumnDetail(
$item->path,
$item->width ? "{$item->width}×{$item->height} · ".count($item->variants ?? []).' versions' : 'no dimensions',
);
}
$this->newLine();
$this->components->info("Processed {$media->count()} images, {$failed} failed.");
// A failure here is usually a missing file, which is worth a non-zero
// exit in a deploy script.
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Filament\Pages\Trash;
use App\Models\ContentView;
use App\Models\Media;
use App\Models\MissingUrl;
use App\Models\Revision;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
/**
* Throws away what nothing points at any more.
*
* Five things now accumulate for ever and none of them is visible: the change
* history, the bin, image files whose record is gone, and the two things the
* frontend reports — the 404s and the daily reading figures. Individually they
* are rounding errors; over a couple of years they are most of the database
* and most of the disk, and by then nobody remembers what any of it was.
*
* The two the frontend writes are the ones that grow without anybody in the
* collective doing anything, which is why they are pruned as part of the
* routine rather than behind a flag.
*
* Deliberately conservative and deliberately explicit: nothing here runs
* without being asked, `--dry-run` says what would go, and the windows are
* generous. Deleting somebody's work by accident is much worse than a database
* that is larger than it needs to be.
*/
class PruneContentCommand extends Command
{
protected $signature = 'content:prune
{--history-days=365 : Keep revisions younger than this}
{--trash-days=90 : Erase soft-deleted records older than this}
{--missing-days=30 : Forget one-off 404s not seen for this long}
{--views-days=400 : Keep daily reading figures for this long}
{--media : Also delete image files nothing points at}
{--dry-run : Report what would go without deleting anything}';
protected $description = 'Prune old revisions, long-deleted records, stale telemetry and orphaned image files';
protected bool $pretend = false;
public function handle(): int
{
$this->pretend = (bool) $this->option('dry-run');
if ($this->pretend) {
$this->components->warn(__('console.prune.dry_run'));
}
$this->pruneRevisions();
$this->pruneTrash();
$this->pruneMissingUrls();
$this->pruneViews();
// Files are the one thing here that cannot be undone by restoring a
// row, so they are opt-in rather than part of the routine.
if ($this->option('media')) {
$this->pruneMedia();
}
return self::SUCCESS;
}
/**
* Old history, except the record's first and last entries.
*
* Keeping those two costs almost nothing and preserves the two facts
* anybody ever asks a history for years later: when it appeared, and who
* touched it last.
*/
protected function pruneRevisions(): void
{
$days = max(1, (int) $this->option('history-days'));
$cutoff = now()->subDays($days);
$keep = Revision::query()
->selectRaw('MIN(id) as id')
->groupBy('revisionable_type', 'revisionable_id')
->pluck('id')
->merge(Revision::query()
->selectRaw('MAX(id) as id')
->groupBy('revisionable_type', 'revisionable_id')
->pluck('id'));
$query = Revision::query()
->where('created_at', '<', $cutoff)
->whereNotIn('id', $keep);
$count = $query->count();
if (! $this->pretend && $count > 0) {
$query->delete();
}
$this->report(__('console.prune.revisions'), $count);
}
/**
* Records that have been in the bin long enough that nobody is coming back
* for them.
*/
protected function pruneTrash(): void
{
$days = max(1, (int) $this->option('trash-days'));
$cutoff = now()->subDays($days);
$total = 0;
foreach (array_keys(Trash::RECOVERABLE) as $model) {
/** @var class-string<Model> $model */
$records = $model::onlyTrashed()->where('deleted_at', '<', $cutoff)->get();
$total += $records->count();
if ($this->pretend) {
continue;
}
// One at a time rather than a mass delete: the models have events
// — the history records the erasure, the content version moves —
// and a bulk query would skip every one of them.
$records->each(fn (Model $record) => $record->forceDelete());
}
$this->report(__('console.prune.trash'), $total);
}
/**
* Addresses that were asked for once and never again.
*
* The table is written by strangers, so it is the one thing here that can
* grow without anybody in the collective doing anything: a scanner walking
* random paths leaves a row per path it invents. What survives is what a
* person could act on — anything hit more than once, and anything already
* resolved or deliberately ignored, because forgetting those would bring
* the same decision back next week as though it had never been made.
*/
protected function pruneMissingUrls(): void
{
$days = max(1, (int) $this->option('missing-days'));
$query = MissingUrl::query()
->pending()
->where('hits', '<', 2)
->where('last_seen_at', '<', now()->subDays($days));
$count = $query->count();
if (! $this->pretend && $count > 0) {
$query->delete();
}
$this->report(__('console.prune.missing_urls'), $count);
}
/**
* Reading figures old enough that nobody compares against them.
*
* A row per record, per language, per day adds up quietly: it is the only
* table here that grows every day the site is up, whatever anybody does.
* The window is over a year on purpose — "more than last spring" is a
* question people ask, and answering it costs a few thousand rows.
*/
protected function pruneViews(): void
{
$days = max(1, (int) $this->option('views-days'));
$query = ContentView::query()
->where('day', '<', now()->subDays($days)->toDateString());
$count = $query->count();
if (! $this->pretend && $count > 0) {
$query->delete();
}
$this->report(__('console.prune.views'), $count);
}
/**
* Image files no record mentions any more.
*
* Changing a project's cover leaves the old file on disk for ever, along
* with every derived version of it. Nothing breaks; the disk just fills up
* with images nobody can reach.
*/
protected function pruneMedia(): void
{
$referenced = Media::referencedPaths();
$freed = 0;
$count = 0;
foreach (Media::query()->get() as $media) {
if (in_array($media->path, $referenced, true)) {
continue;
}
$count++;
$freed += (int) $media->size;
if ($this->pretend) {
continue;
}
$media->deleteDerived();
Storage::disk($media->disk)->delete($media->path);
$media->delete();
}
$this->report(
__('console.prune.media'),
$count,
$count > 0 ? round($freed / 1024 / 1024, 1).' MB' : null,
);
}
protected function report(string $label, int $count, ?string $extra = null): void
{
$value = $count === 0 ? '<fg=gray>—</>' : "<fg=yellow>{$count}</>";
$this->components->twoColumnDetail($label, $extra ? "{$value} · {$extra}" : $value);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Enums\CallStatus;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use Illuminate\Console\Command;
/**
* Keeps the stored state honest about what the site is actually showing.
*
* Most of scheduling needs no command at all: the `published` scopes read the
* dates, so content appears and disappears on time whether or not anything is
* running. What a scope cannot do is change what the panel *says*, and a panel
* that reports "Open" for a call the site stopped offering weeks ago is how a
* counter stops being read.
*
* So this closes what has expired and leaves everything else alone. In
* particular it does not touch the status of pages or projects: their window
* already hides them, and rewriting a status would throw away the one somebody
* chose in order to record something the dates already say.
*/
class ScheduleContentCommand extends Command
{
protected $signature = 'content:schedule
{--dry-run : Report what would change without changing it}';
protected $description = 'Close calls past their date and report content entering or leaving the site';
public function handle(): int
{
$expired = Call::query()
->where('status', CallStatus::Open)
->whereNotNull('closes_at')
->where('closes_at', '<', now())
->get();
foreach ($expired as $call) {
$title = $call->getTranslation('title', (string) config('locales.default'), false);
$this->components->twoColumnDetail(
$title ?: "#{$call->id}",
'<fg=gray>'.__('console.schedule.closed').'</>',
);
if (! $this->option('dry-run')) {
$call->update(['status' => CallStatus::Closed]);
}
}
$this->report();
$this->components->info(
trans_choice('console.schedule.summary', $expired->count(), ['count' => $expired->count()])
);
return self::SUCCESS;
}
/**
* What the dates are about to do on their own. Nothing here needs acting
* on; it is here so that a scheduled release is visible in the logs before
* it happens rather than only afterwards.
*/
protected function report(): void
{
$pending = Page::query()->scheduled()->count() + Project::query()->scheduled()->count();
$expiring = Page::query()->expired()->count() + Project::query()->expired()->count();
if ($pending > 0) {
$this->components->twoColumnDetail(__('console.schedule.pending'), (string) $pending);
}
if ($expiring > 0) {
$this->components->twoColumnDetail(__('console.schedule.off_site'), (string) $expiring);
}
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\Health\Severity;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
/**
* The same checks as the panel page, for a deploy script.
*
* Exits non-zero when something is wrong, so a release can refuse to finish
* on a site that is already broken — or, with `--fail-on=error`, only on the
* faults that a visitor would actually hit.
*/
class SiteCheckCommand extends Command
{
protected $signature = 'site:check
{--fail-on=warning : Exit non-zero from this severity up (error|warning|never)}
{--json : Machine-readable output}';
protected $description = 'Check the public site for missing translations, broken URLs, broken images and other faults';
public function handle(HealthReport $report): int
{
// Every check: a deploy is not a person, so there is nobody here to
// hide a result from.
$findings = $report->runAll();
if ($this->option('json')) {
$this->line((string) json_encode($findings->map(fn (Finding $f) => [
'key' => $f->key,
'severity' => $f->severity->value,
'count' => $f->count,
'samples' => $f->samples,
])->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $this->exitCode($findings);
}
foreach ($findings as $finding) {
$this->components->twoColumnDetail(
$finding->title(),
$this->badge($finding),
);
foreach ($finding->samples as $sample) {
$this->line(" <fg=gray>· {$sample}</>");
}
}
$this->newLine();
$problems = $findings->filter(fn (Finding $f) => $f->severity->isProblem());
if ($problems->isEmpty()) {
$this->components->info(__('health.all_clear'));
} else {
$this->components->warn(
trans_choice('health.problem_summary', $problems->count(), ['count' => $problems->count()])
);
}
return $this->exitCode($findings);
}
/**
* @param Collection<int, Finding> $findings
*/
protected function exitCode($findings): int
{
$threshold = match ((string) $this->option('fail-on')) {
'error' => Severity::Error,
'never' => null,
default => Severity::Warning,
};
if ($threshold === null) {
return self::SUCCESS;
}
$failing = $findings->contains(
fn (Finding $f) => $f->severity->weight() <= $threshold->weight() && $f->severity->isProblem()
);
return $failing ? self::FAILURE : self::SUCCESS;
}
protected function badge(Finding $finding): string
{
return match ($finding->severity) {
Severity::Ok => '<fg=green>ok</>',
Severity::Warning => "<fg=yellow>{$finding->count}</>",
Severity::Error => "<fg=red>{$finding->count}</>",
};
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\Maintenance;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
/**
* Toggles maintenance for the public site from the CLI, for use in deploy
* scripts.
*
* Note this is *not* `artisan down`: the panel and the admin API stay up
* throughout, which is the point.
*/
class SiteMaintenanceCommand extends Command
{
protected $signature = 'site:maintenance
{--up : Bring the public site back}
{--message= : Notice shown to visitors, in the default locale}
{--until= : When it lifts automatically, e.g. "+2 hours"}
{--status : Only report the current state}';
protected $description = 'Put the public site into maintenance, or bring it back (the panel stays up)';
public function handle(): int
{
if ($this->option('status')) {
return $this->reportStatus();
}
if ($this->option('up')) {
Maintenance::disable();
$this->components->info('The public site is back.');
return self::SUCCESS;
}
$message = $this->option('message');
$until = $this->option('until');
Maintenance::enable(
message: filled($message) ? [config('locales.default') => $message] : null,
until: filled($until) ? CarbonImmutable::parse($until) : null,
);
$this->components->warn('The public site is now in maintenance.');
$this->components->bulletList(array_filter([
'Visitors get 503 with the notice; the panel and admin API are unaffected.',
filled($until) ? 'Lifts automatically at '.Maintenance::until()?->toDateTimeString() : null,
'The team can still see the real site via the preview token or a trusted IP.',
]));
return self::SUCCESS;
}
protected function reportStatus(): int
{
if (! Maintenance::isEnabled()) {
$this->components->info('The public site is up.');
return self::SUCCESS;
}
$this->components->warn('The public site is in maintenance.');
$this->components->twoColumnDetail('Since', Maintenance::startedAt()?->diffForHumans() ?? '—');
$this->components->twoColumnDetail('Lifts at', Maintenance::until()?->toDateTimeString() ?? 'manually');
$this->components->twoColumnDetail('Notice', Maintenance::message());
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Console\Command;
class SyncPermissions extends Command
{
protected $signature = 'permissions:sync';
protected $description = 'Sync roles and permissions in the database with App\\Support\\Permissions';
public function handle(): int
{
$this->call('db:seed', ['--class' => RolePermissionSeeder::class, '--force' => true]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\SyncRepository;
use App\Models\Repository;
use App\Services\Git\GitException;
use App\Services\Git\GitManager;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class SyncRepositories extends Command
{
protected $signature = 'repos:sync
{repository?* : Limit to these "owner/repo" names}
{--force : Sync even if refreshed recently}
{--sync : Run inline instead of dispatching to the queue}';
protected $description = 'Refresh repository stats and newcomer-friendly issues from their git provider';
public function handle(GitManager $git): int
{
$repositories = Repository::query()
->when(
$names = $this->argument('repository'),
fn ($q) => $q->whereIn('full_name', $names),
)
->unless(
$this->option('force') || $names,
// Skip anything refreshed recently so a manual sync is not
// immediately redone by the scheduled run.
fn ($q) => $q->where(fn ($sub) => $sub
->whereNull('last_synced_at')
->orWhere('last_synced_at', '<', now()->subMinutes((int) config('git.sync_ttl_minutes', 55)))),
)
->get();
if ($repositories->isEmpty()) {
$this->components->info('Nothing to sync.');
return self::SUCCESS;
}
// Running inline gives immediate, readable output — useful when
// setting things up or debugging a failing repository.
if ($this->option('sync')) {
return $this->runInline($git, $repositories);
}
$repositories->each(fn (Repository $repo) => SyncRepository::dispatch($repo));
$this->components->info("Queued {$repositories->count()} repositories for syncing.");
return self::SUCCESS;
}
/**
* @param Collection<int, Repository> $repositories
*/
protected function runInline(GitManager $git, $repositories): int
{
$failed = 0;
foreach ($repositories as $repository) {
try {
$synced = $git->sync($repository);
$this->components->twoColumnDetail(
$repository->full_name,
sprintf(
'<fg=green>%d★</> · %d issues · %s',
$synced->stars,
$synced->goodFirstIssues()->count(),
$synced->pushed_at?->diffForHumans() ?? 'never pushed',
),
);
} catch (GitException $e) {
$failed++;
$this->components->twoColumnDetail(
$repository->full_name,
'<fg=red>'.$e->getMessage().'</>',
);
}
}
if ($failed > 0) {
$this->newLine();
$this->components->warn("{$failed} repository(ies) failed to sync.");
return self::FAILURE;
}
return self::SUCCESS;
}
}