Scrutinizer GitHub App not installed

We could not synchronize checks via GitHub's checks API since Scrutinizer's GitHub App is not installed for this repository.

Install GitHub App

Passed
Push — add-update-command ( 5f9635...42178d )
by Pedro
10:53
created

UpgradeCommand::availableVersionDescriptors()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 42
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 24
c 0
b 0
f 0
nc 6
nop 0
dl 0
loc 42
rs 8.9137
1
<?php
2
3
namespace Backpack\CRUD\app\Console\Commands\Upgrade;
4
5
use Backpack\CRUD\app\Console\Commands\Traits\PrettyCommandOutput;
6
use Backpack\CRUD\app\Console\Commands\Upgrade\Concerns\ExtractsFirstInteger;
7
use Composer\InstalledVersions;
8
use Illuminate\Console\Command;
9
use Illuminate\Filesystem\Filesystem;
10
use OutOfBoundsException;
11
use RuntimeException;
12
13
class UpgradeCommand extends Command
14
{
15
    use PrettyCommandOutput;
0 ignored issues
show
introduced by
The trait Backpack\CRUD\app\Consol...its\PrettyCommandOutput requires some properties which are not provided by Backpack\CRUD\app\Consol...\Upgrade\UpgradeCommand: $progressBar, $statusColor, $status
Loading history...
16
    use ExtractsFirstInteger;
17
18
    protected $signature = 'backpack:upgrade
19
                                {version? : Target Backpack version to prepare for (e.g. v7, 7.1, v7-1).}
20
                                {--debug : Show debug information for executed processes.}';
21
22
    protected $description = 'Run opinionated upgrade checks to help you move between Backpack major versions.';
23
24
    protected ?array $availableConfigCache = null;
25
26
    protected array $resolvedConfigCache = [];
27
28
    protected array $renderedUpgradeDescriptions = [];
29
30
    protected array $descriptorChoiceSummaries = [];
31
32
    public function handle(): int
33
    {
34
        try {
35
            $descriptor = $this->determineTargetDescriptor($this->argument('version'));
36
            $config = $this->resolveConfigForDescriptor($descriptor);
37
        } catch (RuntimeException $exception) {
38
            $this->errorBlock($exception->getMessage());
39
40
            return Command::INVALID;
41
        }
42
43
        $stepClasses = $config->steps();
44
        if (empty($stepClasses)) {
45
            $this->errorBlock("No automated checks registered for Backpack {$descriptor['label']}.");
46
47
            return Command::INVALID;
48
        }
49
50
        $context = new UpgradeContext($descriptor['version'], addons: $config->addons());
51
52
        $this->infoBlock("Backpack {$descriptor['label']} upgrade assistant", 'upgrade');
53
54
        if ($this->displayDescriptorDescription($descriptor, $config)) {
55
            $this->newLine();
56
        }
57
58
        $results = [];
59
60
        foreach ($stepClasses as $stepClass) {
61
            /** @var Step $step */
62
            $step = new $stepClass($context);
63
64
            $this->progressBlock($step->title());
65
66
            try {
67
                $result = $step->run();
68
            } catch (\Throwable $exception) {
69
                $result = StepResult::failure(
70
                    $exception->getMessage(),
71
                    [
72
                        'Step: '.$stepClass,
73
                    ]
74
                );
75
            }
76
77
            $this->closeProgressBlock(strtoupper($result->status->label()), $result->status->color());
78
79
            $this->printResultDetails($result);
80
81
            if ($this->shouldOfferFix($step, $result)) {
82
                $question = trim($step->fixMessage($result));
83
                $question = $question !== '' ? $question : 'Apply automatic fix?';
84
                $applyFix = $this->confirm('  '.$question, false);
85
86
                if ($applyFix) {
87
                    $this->progressBlock('Applying automatic fix');
88
                    $fixResult = $step->fix($result);
89
                    $this->closeProgressBlock(strtoupper($fixResult->status->label()), $fixResult->status->color());
90
                    $this->printResultDetails($fixResult);
91
92
                    if (! $fixResult->status->isFailure()) {
93
                        $this->progressBlock('Re-running '.$step->title());
94
95
                        try {
96
                            $result = $step->run();
97
                        } catch (\Throwable $exception) {
98
                            $result = StepResult::failure(
99
                                $exception->getMessage(),
100
                                [
101
                                    'Step: '.$stepClass,
102
                                ]
103
                            );
104
                        }
105
106
                        $this->closeProgressBlock(strtoupper($result->status->label()), $result->status->color());
107
                        $this->printResultDetails($result);
108
                    }
109
                }
110
            }
111
112
            $results[] = [
113
                'step' => $stepClass,
114
                'result' => $result,
115
            ];
116
117
        }
118
119
        $expectedVersionInstalled = $this->hasExpectedBackpackVersion($context, $config);
120
121
        return $this->outputSummary($descriptor['label'], $results, $expectedVersionInstalled, $config);
122
    }
123
124
    protected function outputSummary(
125
        string $versionLabel,
126
        array $results,
127
        bool $expectedVersionInstalled = false,
128
        ?UpgradeConfigInterface $config = null
129
    ): int {
130
        $resultsCollection = collect($results);
0 ignored issues
show
Bug introduced by
$results of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

130
        $resultsCollection = collect(/** @scrutinizer ignore-type */ $results);
Loading history...
131
132
        $hasFailure = $resultsCollection->contains(function ($entry) {
133
            /** @var StepResult $result */
134
            $result = $entry['result'];
135
136
            return $result->status->isFailure();
137
        });
138
139
        $warnings = $resultsCollection->filter(function ($entry) {
140
            /** @var StepResult $result */
141
            $result = $entry['result'];
142
143
            return $result->status === StepStatus::Warning;
144
        });
145
146
        $this->newLine();
147
        $this->infoBlock('Summary', 'done');
148
149
        $this->note(sprintf('Checked %d upgrade steps.', count($results)), 'gray');
150
151
        if ($hasFailure) {
152
            $this->note('At least one step reported a failure. Review the messages above before continuing.', 'red', 'red');
153
        }
154
155
        if ($warnings->isNotEmpty()) {
156
            $this->note(sprintf('%d step(s) reported warnings.', $warnings->count()), 'yellow', 'yellow');
157
        }
158
159
        if (! $hasFailure && $warnings->isEmpty()) {
160
            $this->note('All checks passed, you are ready to continue with the manual steps from the upgrade guide.', 'green', 'green');
161
        }
162
163
        $postUpgradeCommands = [];
164
165
        if ($config !== null) {
166
            $postUpgradeCommands = ($config)::postUpgradeCommands();
167
        }
168
169
        if ($expectedVersionInstalled && ! $hasFailure && ! empty($postUpgradeCommands)) {
170
            $this->note("Now that you have {$versionLabel} installed, don't forget to run the following commands:", 'green', 'green');
171
172
            foreach ($postUpgradeCommands as $command) {
173
                $this->note($command);
174
            }
175
        }
176
177
        $this->newLine();
178
179
        return $hasFailure ? Command::FAILURE : Command::SUCCESS;
180
    }
181
182
    protected function printResultDetails(StepResult $result): void
183
    {
184
        $color = match ($result->status) {
185
            StepStatus::Passed => 'green',
186
            StepStatus::Warning => 'yellow',
187
            StepStatus::Failed => 'red',
188
            StepStatus::Skipped => 'gray',
189
        };
190
191
        if ($result->summary !== '') {
192
            $this->note($result->summary, $color, $color);
193
        }
194
195
        foreach ($result->details as $detail) {
196
            $this->note($detail, 'gray');
197
        }
198
199
        $this->newLine();
200
    }
201
202
    protected function shouldOfferFix(Step $step, StepResult $result): bool
203
    {
204
        if (! $this->input->isInteractive()) {
205
            return false;
206
        }
207
208
        if (! in_array($result->status, [StepStatus::Warning, StepStatus::Failed], true)) {
209
            return false;
210
        }
211
212
        return $step->canFix($result);
213
    }
214
215
    protected function resolveConfigForDescriptor(array $descriptor): UpgradeConfigInterface
216
    {
217
        $descriptorKey = $descriptor['key'] ?? null;
218
219
        if ($descriptorKey !== null && isset($this->resolvedConfigCache[$descriptorKey])) {
220
            return $this->resolvedConfigCache[$descriptorKey];
221
        }
222
223
        $configProviderClass = sprintf('%s\\%s\\UpgradeCommandConfig', __NAMESPACE__, $descriptor['namespace']);
224
225
        if (! class_exists($configProviderClass)) {
226
            $this->manuallyLoadConfigDirectory($descriptor);
227
        }
228
229
        if (! class_exists($configProviderClass)) {
230
            throw new RuntimeException(sprintf(
231
                'Missing upgrade config provider for Backpack %s. Please create %s.',
232
                $descriptor['label'],
233
                $configProviderClass
234
            ));
235
        }
236
237
        $provider = $this->laravel
238
            ? $this->laravel->make($configProviderClass)
239
            : new $configProviderClass();
240
241
        if (! $provider instanceof UpgradeConfigInterface) {
242
            throw new RuntimeException(sprintf(
243
                'Upgrade config provider [%s] must implement %s.',
244
                $configProviderClass,
245
                UpgradeConfigInterface::class
246
            ));
247
        }
248
249
        $steps = $provider->steps();
250
251
        if (! is_array($steps)) {
0 ignored issues
show
introduced by
The condition is_array($steps) is always true.
Loading history...
252
            throw new RuntimeException(sprintf(
253
                'Upgrade config provider [%s] must return an array of step class names.',
254
                $configProviderClass
255
            ));
256
        }
257
258
        if ($descriptorKey !== null) {
259
            $this->resolvedConfigCache[$descriptorKey] = $provider;
260
        }
261
262
        return $provider;
263
    }
264
265
    protected function determineTargetDescriptor(mixed $requestedVersion): array
266
    {
267
        $available = $this->availableVersionDescriptors();
268
269
        if (empty($available)) {
270
            throw new RuntimeException('No upgrade configurations were found. Please create one under '.basename(__DIR__).'.');
271
        }
272
273
        $normalizedRequested = $this->normalizeVersionKey(
274
            is_string($requestedVersion) ? $requestedVersion : null
275
        );
276
277
        if ($normalizedRequested !== null) {
278
            if (isset($available[$normalizedRequested])) {
279
                return $available[$normalizedRequested];
280
            }
281
282
            $knownTargets = implode(', ', array_map(
283
                fn (array $descriptor) => $descriptor['label'],
284
                $this->sortDescriptors($available)
285
            ));
286
287
            throw new RuntimeException(sprintf(
288
                'Unknown upgrade target [%s]. Available targets: %s.',
289
                (string) $requestedVersion,
290
                $knownTargets !== '' ? $knownTargets : 'none'
291
            ));
292
        }
293
294
        $currentKey = $this->detectCurrentVersionKey($available);
295
296
        if ($this->input->isInteractive()) {
297
            $sorted = $this->sortDescriptors($available);
298
299
            if (count($sorted) === 1) {
300
                $singleDescriptor = $sorted[0];
301
                $singleConfig = $this->resolveConfigForDescriptor($singleDescriptor);
302
303
                if (! $this->descriptorHasSteps($singleConfig)) {
304
                    if ($this->displayDescriptorDescription($singleDescriptor, $singleConfig)) {
305
                        $this->newLine();
306
                    }
307
                }
308
309
                return $singleDescriptor;
310
            }
311
312
            $choices = [];
313
            $defaultChoice = null;
314
            $summaries = [];
315
316
            foreach ($sorted as $descriptor) {
317
                $config = $this->resolveConfigForDescriptor($descriptor);
318
                $hasSteps = $this->descriptorHasSteps($config);
319
                $summary = $hasSteps ? $this->descriptorChoiceSummary($descriptor, $config) : null;
320
321
                if (! $hasSteps) {
322
                    if ($this->displayDescriptorDescription($descriptor, $config)) {
323
                        $this->newLine();
324
                    }
325
326
                    continue;
327
                }
328
329
                $isCurrent = $currentKey !== null && $descriptor['key'] === $currentKey;
330
                $label = $this->buildChoiceLabel($descriptor, $isCurrent);
331
332
                $choices[$label] = $descriptor['key'];
333
334
                if ($isCurrent && $defaultChoice === null) {
335
                    $defaultChoice = $label;
336
                }
337
338
                $summaries[] = [
339
                    'label' => $label,
340
                    'summary' => $summary ?? 'Automated checks are available for this version.',
341
                    'is_current' => $isCurrent,
342
                ];
343
            }
344
345
            if (empty($choices)) {
346
                throw new RuntimeException('No upgrade targets with automated checks are available.');
347
            }
348
349
            $this->outputDescriptorSummaryList($summaries);
350
351
            if ($defaultChoice === null) {
352
                $defaultChoice = array_key_first($choices);
353
            }
354
355
            $selectedLabel = $this->choice(
356
                'Select the Backpack upgrade path you want to run',
357
                array_keys($choices),
358
                $defaultChoice
359
            );
360
361
            $selectedKey = $choices[$selectedLabel] ?? null;
362
363
            if ($selectedKey !== null && isset($available[$selectedKey])) {
364
                return $available[$selectedKey];
365
            }
366
367
            throw new RuntimeException('Invalid upgrade target selection.');
368
        }
369
370
        if ($currentKey !== null && isset($available[$currentKey])) {
371
            return $available[$currentKey];
372
        }
373
374
        $sorted = $this->sortDescriptors($available, 'desc');
375
376
        return $sorted[0];
377
    }
378
379
    protected function displayDescriptorDescription(array $descriptor, ?UpgradeConfigInterface $config = null): bool
380
    {
381
        $key = $descriptor['key'] ?? null;
382
383
        if ($key === null) {
384
            return false;
385
        }
386
387
        if (isset($this->renderedUpgradeDescriptions[$key])) {
388
            return false;
389
        }
390
391
        try {
392
            $config ??= $this->resolveConfigForDescriptor($descriptor);
393
        } catch (\Throwable $exception) {
394
            return false;
395
        }
396
397
        $this->renderedUpgradeDescriptions[$key] = true;
398
399
        $description = $config->upgradeCommandDescription();
400
401
        if ($description === null) {
402
            return false;
403
        }
404
405
        $this->executeUpgradeCommandDescription($description);
406
407
        return true;
408
    }
409
410
    protected function descriptorHasSteps(UpgradeConfigInterface $config): bool
411
    {
412
        return count($config->steps()) > 0;
413
    }
414
415
    protected function descriptorChoiceSummary(array $descriptor, UpgradeConfigInterface $config): ?string
416
    {
417
        $key = $descriptor['key'] ?? null;
418
419
        if ($key === null) {
420
            return null;
421
        }
422
423
        if (array_key_exists($key, $this->descriptorChoiceSummaries)) {
424
            return $this->descriptorChoiceSummaries[$key];
425
        }
426
427
        if (! $config instanceof UpgradeConfigSummaryInterface) {
428
            return $this->descriptorChoiceSummaries[$key] = null;
429
        }
430
431
        $summary = $config->upgradeCommandSummary();
432
433
        if (! is_string($summary)) {
434
            return $this->descriptorChoiceSummaries[$key] = null;
435
        }
436
437
        $summary = trim($summary);
438
439
        if ($summary === '') {
440
            return $this->descriptorChoiceSummaries[$key] = null;
441
        }
442
443
        return $this->descriptorChoiceSummaries[$key] = $summary;
444
    }
445
446
    protected function outputDescriptorSummaryList(array $summaries): void
447
    {
448
        if (empty($summaries)) {
449
            return;
450
        }
451
452
        $this->newLine();
453
        $this->line('  <fg=blue>Available upgrade paths</>');
454
455
        foreach ($summaries as $entry) {
456
            $labelColor = $entry['is_current'] ? 'green' : 'yellow';
457
            $label = sprintf('<fg=%s>%s</>', $labelColor, $entry['label']);
458
            $this->line(sprintf('    %s <fg=gray>—</> %s', $label, $entry['summary']));
459
        }
460
461
        $this->newLine();
462
    }
463
464
    protected function executeUpgradeCommandDescription(?callable $description): void
465
    {
466
        if ($description === null) {
467
            return;
468
        }
469
470
        try {
471
            $description($this);
472
        } catch (\ArgumentCountError|\TypeError $exception) {
473
            if ($description instanceof \Closure) {
474
                $description->call($this);
475
476
                return;
477
            }
478
479
            $description();
480
        }
481
    }
482
483
    protected function availableVersionDescriptors(): array
484
    {
485
        if ($this->availableConfigCache !== null) {
486
            return $this->availableConfigCache;
487
        }
488
489
        $filesystem = new Filesystem();
490
491
        $descriptors = [];
492
493
        foreach ($filesystem->directories(__DIR__) as $directory) {
494
            $basename = basename($directory);
495
            $normalizedKey = $this->normalizeDirectoryKey($basename);
496
497
            if ($normalizedKey === null) {
498
                continue;
499
            }
500
501
            $configPath = $directory.DIRECTORY_SEPARATOR.'UpgradeCommandConfig.php';
502
503
            if (! $filesystem->exists($configPath)) {
504
                continue;
505
            }
506
507
            $segments = $this->versionKeySegments($normalizedKey);
508
509
            if (empty($segments)) {
510
                continue;
511
            }
512
513
            $descriptors[$normalizedKey] = [
514
                'key' => $normalizedKey,
515
                'directory' => $basename,
516
                'namespace' => str_replace('-', '_', $normalizedKey),
517
                'label' => $normalizedKey,
518
                'version' => ltrim($normalizedKey, 'v'),
519
                'segments' => $segments,
520
                'comparable' => $this->segmentsToComparable($segments),
521
            ];
522
        }
523
524
        return $this->availableConfigCache = $descriptors;
525
    }
526
527
    protected function normalizeDirectoryKey(string $directory): ?string
528
    {
529
        $trimmed = strtolower($directory);
530
531
        if (preg_match('/^v\d+(?:[-_]\d+)*$/', $trimmed) !== 1) {
532
            return null;
533
        }
534
535
        return str_replace('_', '-', $trimmed);
536
    }
537
538
    protected function normalizeVersionKey(?string $version): ?string
539
    {
540
        if ($version === null) {
541
            return null;
542
        }
543
544
        $trimmed = trim(strtolower($version));
545
546
        if ($trimmed === '') {
547
            return null;
548
        }
549
550
        preg_match_all('/\d+/', $trimmed, $matches);
551
552
        if (empty($matches[0])) {
553
            return null;
554
        }
555
556
        return 'v'.implode('-', $matches[0]);
557
    }
558
559
    protected function sortDescriptors(array $descriptors, string $direction = 'asc'): array
560
    {
561
        $list = array_values($descriptors);
562
563
        usort($list, function (array $a, array $b) use ($direction) {
564
            $comparison = version_compare($a['comparable'], $b['comparable']);
565
566
            return $direction === 'desc' ? -$comparison : $comparison;
567
        });
568
569
        return $list;
570
    }
571
572
    protected function buildChoiceLabel(array $descriptor, bool $isCurrent): string
573
    {
574
        $label = $descriptor['label'];
575
576
        if ($isCurrent) {
577
            $label .= ' (current)';
578
        }
579
580
        return $label;
581
    }
582
583
    protected function detectCurrentVersionKey(array $available): ?string
584
    {
585
        $installedPretty = $this->installedBackpackPrettyVersion();
586
587
        if ($installedPretty === null) {
588
            return null;
589
        }
590
591
        foreach ($this->possibleKeysForVersion($installedPretty) as $candidate) {
592
            if (isset($available[$candidate])) {
593
                return $candidate;
594
            }
595
        }
596
597
        return null;
598
    }
599
600
    protected function installedBackpackPrettyVersion(): ?string
601
    {
602
        try {
603
            if (! InstalledVersions::isInstalled('backpack/crud')) {
604
                return null;
605
            }
606
        } catch (OutOfBoundsException $exception) {
607
            return null;
608
        }
609
610
        try {
611
            $version = InstalledVersions::getPrettyVersion('backpack/crud');
612
613
            if ($version === null) {
614
                $version = InstalledVersions::getVersion('backpack/crud');
615
            }
616
617
            return $version ?: null;
618
        } catch (OutOfBoundsException $exception) {
619
            return null;
620
        }
621
    }
622
623
    protected function possibleKeysForVersion(string $version): array
624
    {
625
        preg_match_all('/\d+/', $version, $matches);
626
627
        $segments = $matches[0] ?? [];
628
629
        if (empty($segments)) {
630
            return [];
631
        }
632
633
        $keys = [];
634
635
        for ($length = count($segments); $length > 0; $length--) {
636
            $slice = array_slice($segments, 0, $length);
637
            $keys[] = 'v'.implode('-', $slice);
638
        }
639
640
        return $keys;
641
    }
642
643
    protected function versionKeySegments(string $key): array
644
    {
645
        preg_match_all('/\d+/', $key, $matches);
646
647
        if (empty($matches[0])) {
648
            return [];
649
        }
650
651
        return array_map('intval', $matches[0]);
652
    }
653
654
    protected function segmentsToComparable(array $segments): string
655
    {
656
        return implode('.', array_map(static function ($segment) {
657
            return (string) (int) $segment;
658
        }, $segments));
659
    }
660
661
    protected function manuallyLoadConfigDirectory(array $descriptor): void
662
    {
663
        $filesystem = new Filesystem();
664
        $basePath = __DIR__.DIRECTORY_SEPARATOR.$descriptor['directory'];
665
666
        $configPath = $basePath.DIRECTORY_SEPARATOR.'UpgradeCommandConfig.php';
667
668
        if ($filesystem->exists($configPath)) {
669
            require_once $configPath;
670
        }
671
672
        $stepsPath = $basePath.DIRECTORY_SEPARATOR.'Steps';
673
674
        if (! $filesystem->isDirectory($stepsPath)) {
675
            return;
676
        }
677
678
        foreach ($filesystem->allFiles($stepsPath) as $file) {
679
            /** @var \Symfony\Component\Finder\SplFileInfo $file */
680
            if (strtolower($file->getExtension()) !== 'php') {
681
                continue;
682
            }
683
684
            require_once $file->getRealPath();
685
        }
686
    }
687
688
    protected function hasExpectedBackpackVersion(UpgradeContext $context, UpgradeConfigInterface $config): bool
689
    {
690
        $targetConstraint = $config::backpackCrudRequirement();
691
        $targetMajor = $this->extractFirstInteger($targetConstraint);
692
693
        $composerConstraint = $context->composerRequirement('backpack/crud');
694
695
        if ($composerConstraint === null) {
696
            return false;
697
        }
698
699
        $composerMajor = $this->extractFirstInteger($composerConstraint);
700
701
        if ($targetMajor !== null && ($composerMajor === null || $composerMajor < $targetMajor)) {
702
            return false;
703
        }
704
705
        $installedMajor = $context->packageMajorVersion('backpack/crud');
706
707
        if ($installedMajor === null) {
708
            return false;
709
        }
710
711
        if ($targetMajor !== null && $installedMajor < $targetMajor) {
712
            return false;
713
        }
714
715
        return true;
716
    }
717
}
718