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 ( 1611a2...0fa6fa )
by Pedro
13:26
created

UpgradeCommand::displayDescriptorDescription()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 15
c 0
b 0
f 0
nc 5
nop 2
dl 0
loc 29
rs 9.4555
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 ($result->status->isFailure() && $step->isBlocking()) {
82
                $this->note(
83
                    sprintf(
84
                        'Please solve the issue above, then rerun `php artisan backpack:upgrade %s`.',
85
                        $descriptor['label']
86
                    ),
87
                    'red',
88
                    'red'
89
                );
90
91
                return Command::FAILURE;
92
            }
93
94
            if ($this->shouldOfferFix($step, $result)) {
95
                $question = trim($step->fixMessage($result));
96
                $question = $question !== '' ? $question : 'Apply automatic fix?';
97
                $applyFix = $this->confirm('  '.$question, true);
98
99
                if ($applyFix) {
100
                    $this->progressBlock('Applying automatic fix');
101
                    $fixResult = $step->fix($result);
102
                    $this->closeProgressBlock(strtoupper($fixResult->status->label()), $fixResult->status->color());
103
                    $this->printResultDetails($fixResult);
104
105
                    if (! $fixResult->status->isFailure()) {
106
                        $this->progressBlock('Re-running '.$step->title());
107
108
                        try {
109
                            $result = $step->run();
110
                        } catch (\Throwable $exception) {
111
                            $result = StepResult::failure(
112
                                $exception->getMessage(),
113
                                [
114
                                    'Step: '.$stepClass,
115
                                ]
116
                            );
117
                        }
118
119
                        $this->closeProgressBlock(strtoupper($result->status->label()), $result->status->color());
120
                        $this->printResultDetails($result);
121
                    }
122
                }
123
            }
124
125
            $results[] = [
126
                'step' => $stepClass,
127
                'title' => $step->title(),
128
                'result' => $result,
129
            ];
130
        }
131
132
        $expectedVersionInstalled = $this->hasExpectedBackpackVersion($context, $config);
133
134
        return $this->outputSummary($descriptor['label'], $results, $expectedVersionInstalled, $config);
135
    }
136
137
    protected function outputSummary(
138
        string $versionLabel,
139
        array $results,
140
        bool $expectedVersionInstalled = false,
141
        ?UpgradeConfigInterface $config = null
142
    ): int {
143
        $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

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