d2d.emegrendeles.hu/app/Filament/Pages/DeploymentPackage.php
E98Developer 09eed3eeb3 ADD Deployment csomagoló phase1 védelem, git olvasó és read-only commit/diff felület
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>
2026-08-16 06:45:09 +02:00

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;
}
}