Két commit közötti fájlok összegyűjtését előkészítő felület első fázisa: célkörnyezet választás (e2e/d2d), commitlista, diff-előnézet figyelmeztetésekkel. Ez a fázis még semmit nem ír a fájlrendszerre, a csomagolás a phase2-ben érkezik. - DeploymentGuard: .env kapcsoló + hardkódolt stage whitelist + developer szerep + feature flag; a flag szándékosan nem biztonsági réteg, csak láthatóság-vezérlés - GitRepository: csak olvasó wrapper, argumentum-tömbös Symfony Process (nincs shell), hash- és referencia-validáció, core.quotePath=false az ékezetes útvonalakhoz - ChangeSetAnalyzer: kizárási lista, törlendő/másolandó szétválasztás (átnevezésnél mindkettő), figyelmeztetések migrációra, composer.lock-ra és a nem verziókövetett fordított assetekre Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
304 lines
9.7 KiB
PHP
304 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Pages;
|
|
|
|
use App\Services\Deployment\ChangeSetAnalyzer;
|
|
use App\Services\Deployment\DeploymentGuard;
|
|
use App\Services\Deployment\DeploymentTargets;
|
|
use App\Services\Deployment\GitRepository;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Concerns\InteractsWithForms;
|
|
use Filament\Forms\Contracts\HasForms;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Grid;
|
|
use Filament\Schemas\Schema;
|
|
use Livewire\Attributes\Computed;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Deployment csomagoló - fázis 1: célkörnyezet, commitlista és diff-előnézet.
|
|
*
|
|
* Ez az oldal (a fázis 1-ben) semmit nem ír a fájlrendszerre, csak olvassa a git
|
|
* történetet. A tényleges csomagolás a fázis 2-ben érkezik.
|
|
*
|
|
* A hozzáférést a DeploymentGuard dönti el (env kapcsoló + stage whitelist +
|
|
* developer szerep + feature flag), és a mount()-on kívül minden akció elején
|
|
* újra lefut - a felület elrejtése önmagában nem védelem.
|
|
*/
|
|
class DeploymentPackage extends Page implements HasForms
|
|
{
|
|
use InteractsWithForms;
|
|
|
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rocket-launch';
|
|
|
|
protected string $view = 'filament.pages.deployment-package';
|
|
|
|
protected static ?string $title = 'Deployment csomagoló';
|
|
|
|
protected static ?string $navigationLabel = 'Deployment csomagoló';
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
/** @var array<string, mixed>|null */
|
|
public ?array $data = [];
|
|
|
|
public ?string $fromCommit = null;
|
|
|
|
public ?string $toCommit = null;
|
|
|
|
public ?string $gitError = null;
|
|
|
|
public static function canAccess(): bool
|
|
{
|
|
return app(DeploymentGuard::class)->isAllowed(auth()->user());
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
/** @var array<int, int> $limits */
|
|
$limits = (array) config('deployment.commit_limits', [50]);
|
|
|
|
$this->form->fill([
|
|
'target' => app(DeploymentTargets::class)->defaultKey(),
|
|
'branch' => $this->defaultBranch(),
|
|
'limit' => $limits[0] ?? 50,
|
|
'search' => null,
|
|
]);
|
|
}
|
|
|
|
public function form(Schema $form): Schema
|
|
{
|
|
/** @var array<int, int> $limits */
|
|
$limits = (array) config('deployment.commit_limits', [50]);
|
|
|
|
return $form
|
|
->schema([
|
|
Grid::make(4)
|
|
->schema([
|
|
Select::make('target')
|
|
->label('Célkörnyezet')
|
|
->options(app(DeploymentTargets::class)->options())
|
|
->required()
|
|
->live()
|
|
->helperText(fn (): ?string => $this->target()['remote_path'] ?? null),
|
|
Select::make('branch')
|
|
->label('Branch')
|
|
->options($this->branchOptions())
|
|
->required()
|
|
->searchable()
|
|
->live()
|
|
->afterStateUpdated(fn () => $this->clearRange()),
|
|
Select::make('limit')
|
|
->label('Commitok száma')
|
|
->options(array_combine($limits, $limits))
|
|
->required()
|
|
->live(),
|
|
TextInput::make('search')
|
|
->label('Keresés')
|
|
->placeholder('hash, üzenet vagy szerző')
|
|
->helperText('A betöltött listán belül szűr.')
|
|
->live(debounce: 400),
|
|
]),
|
|
])
|
|
->statePath('data');
|
|
}
|
|
|
|
public function selectFrom(string $hash): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$this->fromCommit = $this->resolveOrWarn($hash);
|
|
}
|
|
|
|
public function selectTo(string $hash): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
$this->toCommit = $this->resolveOrWarn($hash);
|
|
}
|
|
|
|
public function swapRange(): void
|
|
{
|
|
app(DeploymentGuard::class)->ensureAllowed(auth()->user());
|
|
|
|
[$this->fromCommit, $this->toCommit] = [$this->toCommit, $this->fromCommit];
|
|
}
|
|
|
|
public function clearRange(): void
|
|
{
|
|
$this->fromCommit = null;
|
|
$this->toCommit = null;
|
|
}
|
|
|
|
/**
|
|
* @return array{key:string, label:string, domain:string, remote_path:string, expected_branch:?string}|null
|
|
*/
|
|
public function target(): ?array
|
|
{
|
|
return app(DeploymentTargets::class)->find($this->data['target'] ?? null);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{hash:string, short:string, author:string, date:string, subject:string, file_count:?int}>
|
|
*/
|
|
#[Computed]
|
|
public function commits(): array
|
|
{
|
|
$branch = $this->data['branch'] ?? null;
|
|
|
|
if (! $branch) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$commits = app(GitRepository::class)->commits($branch, (int) ($this->data['limit'] ?? 50));
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return [];
|
|
}
|
|
|
|
$search = trim((string) ($this->data['search'] ?? ''));
|
|
|
|
if ($search === '') {
|
|
return $commits;
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$commits,
|
|
fn (array $commit): bool => str_contains(mb_strtolower($commit['subject'].' '.$commit['author'].' '.$commit['hash']), mb_strtolower($search)),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* A kijelölt tartomány elemzése, vagy null, ha még nincs két commit kiválasztva.
|
|
*
|
|
* @return array{
|
|
* files: array<int, array{status:string, path:string, old_path:?string, excluded:bool, action:string}>,
|
|
* copied: array<int, string>,
|
|
* deleted: array<int, string>,
|
|
* counts: array{total:int, copied:int, deleted:int, excluded:int},
|
|
* warnings: array<int, array{level:string, title:string, body:string}>
|
|
* }|null
|
|
*/
|
|
#[Computed]
|
|
public function changeSet(): ?array
|
|
{
|
|
if (! $this->fromCommit || ! $this->toCommit) {
|
|
return null;
|
|
}
|
|
|
|
$repository = app(GitRepository::class);
|
|
|
|
try {
|
|
$changedFiles = $repository->changedFiles($this->fromCommit, $this->toCommit);
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return null;
|
|
}
|
|
|
|
$changeSet = app(ChangeSetAnalyzer::class)->analyze(
|
|
$changedFiles,
|
|
$this->target(),
|
|
$this->data['branch'] ?? null,
|
|
);
|
|
|
|
if ($this->fromCommit === $this->toCommit) {
|
|
array_unshift($changeSet['warnings'], [
|
|
'level' => 'warning',
|
|
'title' => 'Azonos commitok',
|
|
'body' => 'A kezdő és a záró commit ugyanaz, így a tartomány üres.',
|
|
]);
|
|
} elseif (! $repository->isAncestor($this->fromCommit, $this->toCommit)) {
|
|
array_unshift($changeSet['warnings'], [
|
|
'level' => 'danger',
|
|
'title' => 'Fordított vagy szétágazó tartomány',
|
|
'body' => 'A kezdő commit nem őse a zárónak, ezért a lista nem a "mi került bele azóta" kérdésre válaszol. Ellenőrizd a sorrendet (Csere gomb), vagy azt, hogy ugyanazon az ágon vagy-e.',
|
|
]);
|
|
}
|
|
|
|
return $changeSet;
|
|
}
|
|
|
|
/**
|
|
* A kijelölt tartományba eső commitok - csak a lista kiemeléséhez.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
#[Computed]
|
|
public function highlightedHashes(): array
|
|
{
|
|
if (! $this->fromCommit || ! $this->toCommit) {
|
|
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
|
|
}
|
|
|
|
$hashes = array_column($this->commits(), 'hash');
|
|
$fromIndex = array_search($this->fromCommit, $hashes, true);
|
|
$toIndex = array_search($this->toCommit, $hashes, true);
|
|
|
|
if ($fromIndex === false || $toIndex === false) {
|
|
return array_values(array_filter([$this->fromCommit, $this->toCommit]));
|
|
}
|
|
|
|
// A lista fentről lefelé a legfrissebbtől halad, tehát a záró commit van előrébb.
|
|
[$start, $end] = $fromIndex <= $toIndex ? [$fromIndex, $toIndex] : [$toIndex, $fromIndex];
|
|
|
|
return array_slice($hashes, $start, $end - $start + 1);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function branchOptions(): array
|
|
{
|
|
try {
|
|
$branches = app(GitRepository::class)->branches();
|
|
} catch (RuntimeException $exception) {
|
|
$this->gitError = $exception->getMessage();
|
|
|
|
return [];
|
|
}
|
|
|
|
return array_combine($branches, $branches) ?: [];
|
|
}
|
|
|
|
public function isRepositoryAvailable(): bool
|
|
{
|
|
return app(GitRepository::class)->isAvailable();
|
|
}
|
|
|
|
private function defaultBranch(): ?string
|
|
{
|
|
$repository = app(GitRepository::class);
|
|
$current = $repository->currentBranch();
|
|
|
|
if ($current) {
|
|
return $current;
|
|
}
|
|
|
|
return $this->branchOptions() === [] ? null : array_key_first($this->branchOptions());
|
|
}
|
|
|
|
private function resolveOrWarn(string $hash): ?string
|
|
{
|
|
$resolved = app(GitRepository::class)->resolveCommit($hash);
|
|
|
|
if (! $resolved) {
|
|
Notification::make()
|
|
->title('Ismeretlen commit')
|
|
->body('A kiválasztott commit nem található a repóban.')
|
|
->danger()
|
|
->send();
|
|
|
|
return null;
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
}
|