• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eiriksm / cosy-composer / 24099077308

07 Apr 2026 06:58PM UTC coverage: 88.024% (+0.01%) from 88.01%
24099077308

Pull #452

github

eiriksm
phpstan fixes
Pull Request #452: Make sure we can do security updates, but also specific packages without it

8 of 11 new or added lines in 1 file covered. (72.73%)

28 existing lines in 1 file now uncovered.

2161 of 2455 relevant lines covered (88.02%)

55.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.35
/src/CosyComposer.php
1
<?php
2

3
namespace eiriksm\CosyComposer;
4

5
use eiriksm\CosyComposer\Exceptions\ChdirException;
6
use eiriksm\CosyComposer\Exceptions\GitCloneException;
7
use eiriksm\CosyComposer\Exceptions\OutsideProcessingHoursException;
8
use eiriksm\CosyComposer\ListFilterer\DevDepsOnlyFilterer;
9
use eiriksm\CosyComposer\ListFilterer\IndirectWithDirectFilterer;
10
use eiriksm\CosyComposer\Providers\Bitbucket;
11
use eiriksm\CosyComposer\Providers\NamedPrs;
12
use eiriksm\CosyComposer\Providers\PublicGithubWrapper;
13
use eiriksm\CosyComposer\Updater\IndividualUpdater;
14
use GuzzleHttp\Psr7\Request;
15
use Http\Adapter\Guzzle7\Client as GuzzleClient;
16
use Http\Client\HttpClient;
17
use League\Flysystem\FilesystemAdapter;
18
use Symfony\Component\Process\Process;
19
use Violinist\AllowListHandler\AllowListHandler;
20
use Violinist\ComposerLockData\ComposerLockData;
21
use Violinist\ComposerUpdater\Exception\NotUpdatedException;
22
use Violinist\Config\Config;
23
use eiriksm\ViolinistMessages\ViolinistMessages;
24
use Github\Client;
25
use Github\Exception\RuntimeException;
26
use Github\Exception\ValidationFailedException;
27
use League\Flysystem\Local\LocalFilesystemAdapter;
28
use Psr\Log\LoggerInterface;
29
use Violinist\RepoAndTokenToCloneUrl\ToCloneUrl;
30
use Violinist\Slug\Slug;
31
use Violinist\TimeFrameHandler\Handler;
32
use Wa72\SimpleLogger\ArrayLogger;
33

34
class CosyComposer
35
{
36
    use ComposerInstallTrait;
37
    use PrCounterTrait;
38
    use GitCommandsTrait;
39
    use SlugAwareTrait;
40
    use TokenAwareTrait;
41
    use AssigneesAllowedTrait;
42
    use TemporaryDirectoryAwareTrait;
43
    use ConfigOverrideLoggerTrait;
44

45
    const UPDATE_ALL = 'update_all';
46

47
    const UPDATE_INDIVIDUAL = 'update_individual';
48

49
    private $urlArray;
50

51
    /**
52
     * @var bool|string
53
     */
54
    private $lockFileContents;
55

56
    /**
57
     * @var ProviderFactory
58
     */
59
    protected $providerFactory;
60

61
    /**
62
     * @var \eiriksm\CosyComposer\CommandExecuter
63
     */
64
    protected $executer;
65

66
    /**
67
     * @var ComposerFileGetter
68
     */
69
    protected $composerGetter;
70

71
    /**
72
     * @var string
73
     */
74
    protected $cwd;
75

76
    /**
77
     * @var string
78
     */
79
    private $forkUser;
80

81
    /**
82
     * @var ViolinistMessages
83
     */
84
    private $messageFactory;
85

86
    /**
87
     * @var string
88
     */
89
    protected $composerJsonDir;
90

91
    /**
92
     * @var LoggerInterface
93
     */
94
    protected $logger;
95

96
    /**
97
     * @var null|\Violinist\ProjectData\ProjectData
98
     */
99
    protected $project;
100

101
    /**
102
     * @var HttpClient
103
     */
104
    protected $httpClient;
105

106
    /**
107
     * @var string
108
     */
109
    protected $tokenUrl;
110

111
    /**
112
     * @var bool
113
     */
114
    private $isPrivate = false;
115

116
    /**
117
     * @var SecurityCheckerFactory
118
     */
119
    private $checkerFactory;
120

121
    /**
122
     * @var ProviderInterface
123
     */
124
    private $client;
125

126
    /**
127
     * @var ProviderInterface
128
     */
129
    private $privateClient;
130

131
    /**
132
     * @var string
133
     */
134
    private $hostName;
135

136
    /**
137
     * @var PrParamsCreator
138
     */
139
    private $prParamsCreator;
140

141
    /**
142
     * @param array $tokens
143
     */
144
    public function setTokens(array $tokens)
145
    {
146
        $this->tokens = $tokens;
1✔
147
    }
148

149
    /**
150
     * @return SecurityCheckerFactory
151
     */
152
    public function getCheckerFactory()
153
    {
154
        return $this->checkerFactory;
224✔
155
    }
156

157
    /**
158
     * @param string $tokenUrl
159
     */
160
    public function setTokenUrl($tokenUrl)
161
    {
162
        $this->tokenUrl = $tokenUrl;
224✔
163
    }
164

165
    /**
166
     * @param \Violinist\ProjectData\ProjectData|null $project
167
     */
168
    public function setProject($project)
169
    {
170
        $this->project = $project;
224✔
171
    }
172

173
    /**
174
     * @return LoggerInterface
175
     */
176
    public function getLogger()
177
    {
178
        if (!$this->logger instanceof LoggerInterface) {
202✔
179
            $this->logger = new ArrayLogger();
201✔
180
        }
181
        return $this->logger;
202✔
182
    }
183

184
    /**
185
     * @param LoggerInterface $logger
186
     */
187
    public function setLogger(LoggerInterface $logger)
188
    {
189
        $this->logger = $logger;
1✔
190
    }
191

192
    /**
193
     * @return HttpClient
194
     */
195
    public function getHttpClient()
196
    {
197
        if (!$this->httpClient instanceof HttpClient) {
37✔
198
            $this->httpClient = new GuzzleClient();
×
199
        }
200
        return $this->httpClient;
37✔
201
    }
202

203
    /**
204
     * @param HttpClient $httpClient
205
     */
206
    public function setHttpClient(HttpClient $httpClient)
207
    {
208
        $this->httpClient = $httpClient;
224✔
209
    }
210

211
    /**
212
     * @return string
213
     */
214
    public function getCwd()
215
    {
216
        return $this->cwd;
201✔
217
    }
218

219
    /**
220
     * @param \eiriksm\CosyComposer\CommandExecuter $executer
221
     */
222
    public function setExecuter($executer)
223
    {
224
        $this->executer = $executer;
211✔
225
    }
226

227
    /**
228
     * @param ProviderFactory $providerFactory
229
     */
230
    public function setProviderFactory(ProviderFactory $providerFactory)
231
    {
232
        $this->providerFactory = $providerFactory;
224✔
233
    }
234

235

236
    /**
237
     * CosyComposer constructor.
238
     */
239
    public function __construct(CommandExecuter $executer)
240
    {
241
        $tmpdir_name = uniqid();
225✔
242
        $this->setTmpDir(sprintf('/tmp/%s', $tmpdir_name));
225✔
243
        $this->messageFactory = new ViolinistMessages();
225✔
244
        $this->executer = $executer;
225✔
245
        $this->checkerFactory = new SecurityCheckerFactory();
225✔
246
    }
247

248
    public function setUrl($url = null)
249
    {
250
        if (!empty($url)) {
224✔
251
            $url = Helpers::stripGitSuffix($url);
224✔
252
        }
253
        $slug_url_obj = parse_url($url);
224✔
254
        if (empty($slug_url_obj['port']) && !empty($slug_url_obj['scheme'])) {
224✔
255
            // Set it based on scheme.
256
            switch ($slug_url_obj['scheme']) {
224✔
257
                case 'http':
224✔
258
                    $slug_url_obj['port'] = 80;
1✔
259
                    break;
1✔
260

261
                case 'https':
224✔
262
                    $slug_url_obj['port'] = 443;
224✔
263
                    break;
224✔
264
            }
265
        }
266
        $this->urlArray = $slug_url_obj;
224✔
267
        $providers = Slug::getSupportedProviders();
224✔
268
        if (!empty($slug_url_obj['host'])) {
224✔
269
            $providers = array_merge($providers, [$slug_url_obj['host']]);
224✔
270
        }
271
        $this->setSlug(Slug::createFromUrlAndSupportedProviders($url, $providers));
224✔
272
    }
273

274
    /**
275
     * @deprecated Use ::setAuthentication instead.
276
     *
277
     * @see CosyComposer::setAuthentication
278
     */
279
    public function setGithubAuth($user, $pass)
280
    {
281
        $this->setAuthentication($user);
1✔
282
    }
283

284
    /**
285
     * @deprecated use ::setAuthentication instead.
286
     */
287
    public function setUserToken($user_token)
288
    {
289
        $this->setAuthentication($user_token);
1✔
290
    }
291

292
  /**
293
   * Set a user to fork to.
294
   *
295
   * @param string $user
296
   */
297
    public function setForkUser($user)
298
    {
299
        $this->forkUser = $user;
1✔
300
    }
301

302
    protected function handleTimeIntervalSetting(Config $config)
303
    {
304
        if (Handler::isAllowed($config)) {
197✔
305
            return;
195✔
306
        }
307
        throw new OutsideProcessingHoursException('Current hour is inside timeframe disallowed');
1✔
308
    }
309

310
    /**
311
     * Apply the ignore_platform_requirements setting from config to the command executer.
312
     *
313
     * This sets the COMPOSER_IGNORE_PLATFORM_REQS environment variable which
314
     * propagates to all composer commands.
315
     */
316
    protected function applyIgnorePlatformRequirements(Config $config): void
317
    {
318
        if ($config->shouldIgnorePlatformRequirements()) {
198✔
319
            $this->log('Ignoring platform requirements for composer commands (COMPOSER_IGNORE_PLATFORM_REQS=1)');
1✔
320
        }
321
        $this->executer->setIgnorePlatformRequirements($config->shouldIgnorePlatformRequirements());
198✔
322
    }
323

324
    public function handleDrupalContribSa($cdata)
325
    {
326
        if (!getenv('DRUPAL_CONTRIB_SA_PATH')) {
197✔
327
            return;
197✔
328
        }
329
        $symfony_dir = sprintf('%s/.symfony/cache/security-advisories/drupal', getenv('HOME'));
×
330
        if (!file_exists($symfony_dir)) {
×
331
            $mkdir = $this->execCommand(['mkdir', '-p', $symfony_dir]);
×
332
            if ($mkdir) {
×
333
                return;
×
334
            }
335
        }
336
        $contrib_sa_dir = getenv('DRUPAL_CONTRIB_SA_PATH');
×
337
        if (empty($cdata->repositories)) {
×
338
            return;
×
339
        }
340
        foreach ($cdata->repositories as $repository) {
×
341
            if (empty($repository->url)) {
×
342
                continue;
×
343
            }
344
            if ($repository->url === 'https://packages.drupal.org/8') {
×
345
                $process = Process::fromShellCommandline('rsync -aq ' . sprintf('%s/sa_yaml/8/drupal/*', $contrib_sa_dir) .  " $symfony_dir/");
×
346
                $process->run();
×
347
            }
348
            if ($repository->url === 'https://packages.drupal.org/7') {
×
349
                $process = Process::fromShellCommandline('rsync -aq ' . sprintf('%s/sa_yaml/7/drupal/*', $contrib_sa_dir) .  " $symfony_dir/");
×
350
                $process->run();
×
351
            }
352
        }
353
    }
354

355
    /**
356
     * Export things.
357
     */
358
    protected function exportEnvVars()
359
    {
360
        if (!$this->project) {
201✔
361
            return;
×
362
        }
363
        $env = $this->project->getEnvString();
201✔
364
        if (empty($env)) {
201✔
365
            return;
195✔
366
        }
367
        // One per line.
368
        $env_array = preg_split("/\r\n|\n|\r/", $env);
6✔
369
        if (empty($env_array)) {
6✔
370
            return;
×
371
        }
372
        foreach ($env_array as $env_string) {
6✔
373
            if (empty($env_string)) {
6✔
374
                continue;
2✔
375
            }
376
            $env_parts = explode('=', $env_string, 2);
6✔
377
            if (count($env_parts) != 2) {
6✔
378
                continue;
×
379
            }
380
            // We do not allow to override ENV vars.
381
            $key = $env_parts[0];
6✔
382
            $existing_env = getenv($key);
6✔
383
            if ($existing_env) {
6✔
384
                $this->getLogger()->log('info', new Message("The ENV variable $key was skipped because it exists and can not be overwritten"));
3✔
385
                continue;
3✔
386
            }
387
            $value = $env_parts[1];
6✔
388
            $this->getLogger()->log('info', new Message("Exporting ENV variable $key: $value"));
6✔
389
            putenv($env_string);
6✔
390
            $_ENV[$key] = $value;
6✔
391
        }
392
    }
393

394
    protected function closeOutdatedPrsForPackage($package_name, $current_version, Config $config, $pr_id, NamedPrs $prs_named_obj, $default_branch)
395
    {
396
        $prs_for_package = $prs_named_obj->getPrsFromPackage($package_name);
7✔
397
        foreach ($prs_for_package as $pr) {
7✔
398
            if (!empty($pr["base"]["ref"])) {
4✔
399
                // The base ref should be what we are actually using for merge requests.
400
                if ($pr["base"]["ref"] !== $default_branch) {
1✔
401
                    continue;
×
402
                }
403
            }
404
            // We don't want to close this exact PR do we?
405
            if ((string) $pr['number'] === (string) $pr_id) {
4✔
406
                continue;
4✔
407
            }
408
            $comment = $this->messageFactory->getPullRequestClosedMessage($pr_id);
3✔
409
            $pr_number = $pr['number'];
3✔
410
            $this->getLogger()->log('info', new Message("Trying to close PR number $pr_number since it has been superseded by $pr_id"));
3✔
411
            try {
412
                $this->getPrClient()->closePullRequestWithComment($this->slug, $pr_number, $comment);
3✔
413
                $this->getLogger()->log('info', new Message("Successfully closed PR $pr_number"));
3✔
414
            } catch (\Throwable $e) {
×
415
                $msg = $e->getMessage();
×
416
                $this->getLogger()->log('error', new Message("Caught an exception trying to close pr $pr_number. The message was '$msg'"));
×
417
            }
418
        }
419
    }
420

421
    protected function closePrsForNoLongerRelevantPackages(NamedPrs $prs_named, array $all_outdated_package_names, $composer_lock_data, $default_branch)
422
    {
423
        $is_enabled = self::shouldEnableCloseNoLongerRelevant();
176✔
424
        if (!$is_enabled) {
176✔
425
            return;
158✔
426
        }
427
        $this->log('USE_CLOSE_NO_LONGER_RELEVANT flag is enabled, attempting to close no longer relevant PRs');
18✔
428
        $lock_package_names = [];
18✔
429
        foreach (['packages', 'packages-dev'] as $key) {
18✔
430
            if (!empty($composer_lock_data->{$key})) {
18✔
431
                foreach ($composer_lock_data->{$key} as $package) {
18✔
432
                    $lock_package_names[] = $package->name;
18✔
433
                }
434
            }
435
        }
436
        foreach ($prs_named->getKnownPackageNames() as $package_name) {
18✔
437
            if (in_array($package_name, $all_outdated_package_names)) {
11✔
438
                continue;
6✔
439
            }
440
            $prs_for_package = $prs_named->getPrsFromPackage($package_name);
5✔
441
            $is_removed = !in_array($package_name, $lock_package_names);
5✔
442
            foreach ($prs_for_package as $pr) {
5✔
443
                if (!empty($pr["base"]["ref"]) && $pr["base"]["ref"] !== $default_branch) {
5✔
444
                    continue;
×
445
                }
446
                $pr_number = $pr['number'];
5✔
447
                if ($is_removed) {
5✔
448
                    $comment = "Closing this pull request because the package $package_name has been removed from the project dependencies.";
1✔
449
                } else {
450
                    $comment = "Closing this pull request because the package $package_name has been updated outside of this pull request, or the config changed in a way that makes this PR no longer relevant";
4✔
451
                }
452
                $this->getLogger()->log('info', new Message("Closing PR number $pr_number for $package_name since the package is no longer outdated"));
5✔
453
                try {
454
                    $this->getPrClient()->closePullRequestWithComment($this->slug, $pr_number, $comment);
5✔
455
                    $this->getLogger()->log('info', new Message("Successfully closed PR $pr_number"));
5✔
456
                } catch (\Throwable $e) {
×
457
                    $msg = $e->getMessage();
×
458
                    $this->getLogger()->log('error', new Message("Caught an exception trying to close pr $pr_number. The message was '$msg'"));
×
459
                }
460
            }
461
        }
462
    }
463

464
    public function setViolinistHostname(string $hostname)
465
    {
466
        $this->hostName = $hostname;
224✔
467
    }
468

469
    public function getLocalAdapterForTempDir(string $directory) : FilesystemAdapter
470
    {
471
        $this->setTmpDir($directory);
200✔
472
        $composer_json_dir = $this->tmpDir;
200✔
473
        if ($this->project && $this->project->getComposerJsonDir()) {
200✔
474
            $composer_json_dir = sprintf('%s/%s', $this->tmpDir, $this->project->getComposerJsonDir());
×
475
        }
476
        $this->composerJsonDir = $composer_json_dir;
200✔
477
        return new LocalFilesystemAdapter($this->composerJsonDir);
200✔
478
    }
479

480
    /**
481
     * @throws \eiriksm\CosyComposer\Exceptions\ChdirException
482
     * @throws \eiriksm\CosyComposer\Exceptions\GitCloneException
483
     * @throws \InvalidArgumentException
484
     * @throws \Exception
485
     * @throws \Throwable
486
     */
487
    public function run()
488
    {
489
        // Always start by making sure the .ssh directory exists.
490
        $directory = sprintf('%s/.ssh', getenv('HOME'));
201✔
491
        if (!file_exists($directory)) {
201✔
492
            if (!@mkdir($directory, 0700) && !is_dir($directory)) {
1✔
493
                throw new \RuntimeException(sprintf('Directory "%s" was not created', $directory));
×
494
            }
495
        }
496
        // Export the environment variables if needed.
497
        $this->exportEnvVars();
201✔
498
        if ($this->hostName) {
201✔
499
            $this->log(sprintf('Running update check on %s', $this->hostName));
201✔
500
        }
501
        if (!empty($_SERVER['violinist_revision'])) {
201✔
502
            $this->log(sprintf('Queue starter revision %s', $_SERVER['violinist_revision']));
×
503
        }
504
        if (!empty($_SERVER['queue_runner_revision'])) {
201✔
505
            $this->log(sprintf('Queue runner revision %s', $_SERVER['queue_runner_revision']));
×
506
        }
507
        // Support an alternate composer version based on env var.
508
        if (!empty($_ENV['ALTERNATE_COMPOSER_PATH'])) {
201✔
509
            $allow_list = [
×
510
                '/usr/local/bin/composer22',
×
511
            ];
×
512
            if (!in_array($_ENV['ALTERNATE_COMPOSER_PATH'], $allow_list)) {
×
513
                throw new \InvalidArgumentException('The alternate composer path is not allowed');
×
514
            }
515
            $this->log('Trying to use composer from ' . $_ENV['ALTERNATE_COMPOSER_PATH']);
×
516
            if (file_exists('/usr/local/bin/composer')) {
×
517
                rename('/usr/local/bin/composer', '/usr/local/bin/composer.bak');
×
518
            }
519
            copy($_ENV['ALTERNATE_COMPOSER_PATH'], '/usr/local/bin/composer');
×
520
            chmod('/usr/local/bin/composer', 0755);
×
521
        }
522
        // Try to get the php version as well.
523
        $this->execCommand(['php', '--version']);
201✔
524
        $this->log($this->getLastStdOut());
201✔
525
        // Try to get the composer version as well.
526
        $this->execCommand(['composer', '--version']);
201✔
527
        $this->log($this->getLastStdOut());
201✔
528
        $this->log(sprintf('Starting update check for %s', $this->slug->getSlug()));
201✔
529
        $user_name = $this->slug->getUserName();
201✔
530
        $user_repo = $this->slug->getUserRepo();
201✔
531
        $hostname = $this->slug->getProvider();
201✔
532
        $url = null;
201✔
533
        // Make sure we accept the fingerprint of whatever we are cloning.
534
        $known_hosts_file = "$directory/known_hosts";
201✔
535
        $this->execCommand(['ssh-keyscan', '-t', 'rsa', $hostname], false);
201✔
536
        $keyscan_output = $this->getLastStdOut();
201✔
537
        if (!empty($keyscan_output)) {
201✔
538
            file_put_contents($known_hosts_file, $keyscan_output, FILE_APPEND);
×
539
        }
540
        if (!empty($_SERVER['private_key'])) {
201✔
541
            $this->log('Checking for existing private key');
×
542
            $filename = "$directory/id_rsa";
×
543
            if (!file_exists($filename)) {
×
544
                $this->log('Installing private key');
×
545
                file_put_contents($filename, $_SERVER['private_key']);
×
546
                $this->execCommand(['chmod', '600', $filename], false);
×
547
            }
548
        }
549
        $is_bitbucket = false;
201✔
550
        $bitbucket_user = null;
201✔
551
        $url = ToCloneUrl::fromRepoAndToken($this->slug->getUrl(), $this->userToken);
201✔
552
        switch ($hostname) {
553
            case 'bitbucket.org':
201✔
554
                $is_bitbucket = true;
2✔
555
                if (Bitbucket::tokenIndicatesUserAppPassword($this->userToken)) {
2✔
556
                    // The username will now be the thing before the colon.
557
                    [$bitbucket_user, $this->userToken] = explode(':', $this->userToken);
1✔
558
                }
559
                break;
2✔
560

561
            default:
562
                // Use the upstream package for this.
563
                break;
199✔
564
        }
565
        $urls = [
201✔
566
            $url,
201✔
567
        ];
201✔
568
        // We also want to check what happens if we append .git to the URL. This can be a problem in newer
569
        // versions of git, that git does not accept redirects.
570
        $length = strlen('.git');
201✔
571
        $ends_with_git = substr($url, -$length) === '.git';
201✔
572
        if (!$ends_with_git) {
201✔
573
            $urls[] = "$url.git";
199✔
574
        }
575
        $this->log('Cloning repository');
201✔
576
        foreach ($urls as $url) {
201✔
577
            $clone_result = $this->execCommand(['git', 'clone', '--depth=1', $url, $this->tmpDir], false, 240);
201✔
578
            if (!$clone_result) {
201✔
579
                break;
200✔
580
            }
581
        }
582
        if ($clone_result) {
201✔
583
            // We had a problem.
584
            $this->log($this->getLastStdOut());
1✔
585
            $this->log($this->getLastStdErr());
1✔
586
            throw new GitCloneException('Problem with the execCommand git clone. Exit code was ' . $clone_result);
1✔
587
        }
588
        $this->log('Repository cloned');
200✔
589
        $local_adapter = $this->getLocalAdapterForTempDir($this->tmpDir);
200✔
590
        $this->chdir($this->composerJsonDir);
200✔
591
        $uses_config_branch = false;
200✔
592
        if (!empty($_ENV['config_branch'])) {
200✔
593
            $uses_config_branch = true;
8✔
594
            $config_branch = $_ENV['config_branch'];
8✔
595
            $this->log('Changing to config branch: ' . $config_branch);
8✔
596
            $tmpdir = sprintf('/tmp/%s', uniqid('', true));
8✔
597
            $clone_result = $this->execCommand(['git', 'clone', '--depth=1', $url, $tmpdir, '-b', $config_branch], false, 120);
8✔
598
            if (!$clone_result) {
8✔
599
                $local_adapter = $this->getLocalAdapterForTempDir($tmpdir);
8✔
600
            } else {
601
                $this->log($this->getLastStdOut());
×
602
                $this->log($this->getLastStdErr());
×
603
                throw new GitCloneException('Problem with git clone of the config branch. Exit code was ' . $clone_result);
×
604
            }
605
            if (!$this->chdir($this->composerJsonDir)) {
8✔
606
                throw new ChdirException('Problem with changing dir to the clone dir of the config branch.');
×
607
            }
608
        }
609
        $this->composerGetter = new ComposerFileGetter($local_adapter);
200✔
610
        if (!$this->composerGetter->hasComposerFile()) {
200✔
611
            throw new \InvalidArgumentException('No composer.json file found.');
1✔
612
        }
613
        $composer_json_data = $this->composerGetter->getComposerJsonData();
199✔
614
        if ($composer_json_data === false) {
199✔
615
            throw new \InvalidArgumentException('Invalid composer.json file');
1✔
616
        }
617
        $config = $this->ensureFreshConfig($composer_json_data);
198✔
618
        $this->applyIgnorePlatformRequirements($config);
198✔
619
        $this->runAuthExport($hostname);
198✔
620
        $this->doComposerInstall($config);
198✔
621
        $config = $this->ensureFreshConfig($composer_json_data);
198✔
622
        $this->client = $this->getClient($this->slug);
198✔
623
        $this->privateClient = $this->getClient($this->slug);
198✔
624
        $this->privateClient->authenticate($this->userToken, null);
198✔
625
        if ($is_bitbucket && $bitbucket_user) {
197✔
626
            $this->privateClient->authenticate($bitbucket_user, $this->userToken);
1✔
627
        }
628

629
        $this->logger->log('info', new Message('Checking private status of repo', Message::COMMAND));
197✔
630
        $this->isPrivate = $this->checkPrivateStatus();
197✔
631
        $this->logger->log('info', new Message('Checking default branch of repo', Message::COMMAND));
197✔
632
        $default_branch = $this->checkDefaultBranch();
197✔
633

634
        if ($default_branch) {
197✔
635
            $this->log('Default branch set in project is ' . $default_branch);
193✔
636
        }
637
        // We also allow the project to override this for violinist.
638
        if ($config->getDefaultBranch()) {
197✔
639
            // @todo: Would be better to make sure this can actually be set, based on the branches available. Either
640
            // way, if a person configures this wrong, several parts will fail spectacularly anyway.
641
            $default_branch = $config->getDefaultBranch();
4✔
642
            $this->log('Default branch overridden by config and set to ' . $default_branch);
4✔
643
        }
644
        // Now make sure we are actually on that branch.
645
        if ($this->execCommand(['git', 'remote', 'set-branches', 'origin', "*"])) {
197✔
646
            // We had a problem.
647
            $this->log($this->getLastStdOut());
×
648
            $this->log($this->getLastStdErr());
×
649
            throw new \Exception('There was an error trying to configure default branch');
×
650
        }
651
        if ($this->execCommand(['git', 'fetch', 'origin', $default_branch])) {
197✔
652
            // We had a problem.
653
            $this->log($this->getLastStdOut());
×
654
            $this->log($this->getLastStdErr());
×
655
            throw new \Exception('There was an error trying to fetch default branch');
×
656
        }
657
        if ($this->execCommand(['git', 'checkout', $default_branch])) {
197✔
658
            // We had a problem.
659
            $this->log($this->getLastStdOut());
×
660
            $this->log($this->getLastStdErr());
×
661
            throw new \Exception('There was an error trying to switch to default branch');
×
662
        }
663
        // Re-read the composer.json file, since it can be different on the default branch.
664
        $this->doComposerInstall($config);
197✔
665
        if (!$uses_config_branch) {
197✔
666
            $composer_json_data = $this->composerGetter->getComposerJsonData();
189✔
667
            if ($composer_json_data === false) {
189✔
668
                throw new \InvalidArgumentException('Invalid composer.json file');
×
669
            }
670
            $config = $this->ensureFreshConfig($composer_json_data);
189✔
671
            $this->applyIgnorePlatformRequirements($config);
189✔
672
        }
673
        $this->runAuthExport($hostname);
197✔
674
        $this->handleDrupalContribSa($composer_json_data);
197✔
675
        $this->handleTimeIntervalSetting($config);
197✔
676
        $lock_file = $this->composerJsonDir . '/composer.lock';
195✔
677
        $initial_composer_lock_data = false;
195✔
678
        $security_alerts = [];
195✔
679
        if (@file_exists($lock_file)) {
195✔
680
            // We might want to know whats in here.
681
            $initial_composer_lock_data = json_decode(file_get_contents($lock_file));
174✔
682
        }
683
        $this->lockFileContents = $initial_composer_lock_data;
195✔
684
        if ($config->shouldAlwaysUpdateAll() && !$initial_composer_lock_data) {
195✔
685
            $this->log('Update all enabled, but no lock file present. This is not supported');
1✔
686
            $this->cleanUp();
1✔
687
            return;
1✔
688
        }
689
        $this->doComposerInstall($config);
194✔
690
        // Now read the lockfile.
691
        $composer_lock_after_installing = json_decode(@file_get_contents($this->composerJsonDir . '/composer.lock'));
194✔
692
        // And do a quick security check in there as well.
693
        try {
694
            $this->log('Checking for security issues in project.');
194✔
695
            $checker = $this->checkerFactory->getChecker();
194✔
696
            $result = $checker->checkDirectory($this->composerJsonDir);
194✔
697
            // Make sure this is an array now.
698
            if (!$result) {
192✔
699
                $result = [];
180✔
700
            }
701
            $this->log('Found ' . count($result) . ' security advisories for packages installed', 'message', [
192✔
702
                'result' => $result,
192✔
703
            ]);
192✔
704
            foreach ($result as $name => $value) {
192✔
705
                $this->log("Security update available for $name");
12✔
706
            }
707
            if (count($result)) {
192✔
708
                $security_alerts = $result;
192✔
709
            }
710
        } catch (\Exception $e) {
2✔
711
            $this->log('Caught exception while looking for security updates:');
2✔
712
            $this->log($e->getMessage());
2✔
713
        }
714
        // We also want to consult the Drupal security advisories, since the FriendsOfPHP/security-advisories
715
        // repo is a manual job merging and maintaining. On top of that, it requires the built container to be
716
        // up to date. So here could be several hours of delay on critical stuff.
717
        $this->attachDrupalAdvisories($security_alerts);
194✔
718
        $direct_only = false;
194✔
719
        if ($config->shouldCheckDirectOnly()) {
194✔
720
            $this->log('Checking only direct dependencies since config option check_only_direct_dependencies is enabled');
186✔
721
            $this->logConfigOverride($config, 'check_only_direct_dependencies');
186✔
722
            $direct_only = true;
186✔
723
        }
724
        // If we should always update all, then of course we should not only check direct dependencies outdated.
725
        // Regardless of the option above actually.
726
        if ($config->shouldAlwaysUpdateAll()) {
194✔
727
            $this->log('Checking all (not only direct dependencies) since config option always_update_all is enabled');
21✔
728
            $direct_only = false;
21✔
729
        }
730
        // If we should allow indirect packages to updated via running composer update my/direct, then we need to
731
        // uncover which indirect are actually out of date. Meaning direct is required to be false.
732
        if ($config->shouldUpdateIndirectWithDirect()) {
194✔
733
            $this->log('Checking all (not only direct dependencies) since config option allow_update_indirect_with_direct is enabled');
7✔
734
            $direct_only = false;
7✔
735
        }
736
        $composer_outdated_command = Helpers::createComposerOutdatedCommandFromConfig($config, $direct_only);
194✔
737
        $this->execCommand($composer_outdated_command);
194✔
738
        $raw_data = $this->getLastStdOut();
194✔
739
        $json_update = @json_decode($raw_data);
194✔
740
        if (!$json_update) {
194✔
741
            // We had a problem.
742
            $this->log($this->getLastStdOut());
×
743
            $this->log($this->getLastStdErr());
×
744
            throw new \Exception('The output for available updates could not be parsed as JSON');
×
745
        }
746
        if (!isset($json_update->installed)) {
194✔
747
            // We had a problem.
748
            $this->log($this->getLastStdOut());
2✔
749
            $this->log($this->getLastStdErr());
2✔
750
            throw new \Exception(
2✔
751
                'JSON output from composer was not looking as expected after checking updates'
2✔
752
            );
2✔
753
        }
754
        $data = $json_update->installed;
192✔
755
        if (!is_array($data)) {
192✔
756
            $this->log('Update data was in wrong format or missing. This is an error in violinist and should be reported');
1✔
757
            $this->log(print_r($raw_data, true), Message::COMMAND, [
1✔
758
              'data' => $raw_data,
1✔
759
              'data_guessed' => $data,
1✔
760
            ]);
1✔
761
            $this->cleanUp();
1✔
762
            return;
1✔
763
        }
764
        // Only update the ones in the allow list, if indicated.
765
        $handler = AllowListHandler::createFromConfig($config);
191✔
766
        // If we have an allow list, we should also make sure to include the
767
        // direct ones in it, if indicated.
768
        if ($config->getAllowList() && $config->shouldAlwaysAllowDirect()) {
191✔
769
            $require_list = [];
3✔
770
            if (!empty($composer_json_data->require)) {
3✔
771
                $require_list = array_keys(get_object_vars($composer_json_data->require));
2✔
772
            }
773
            if (!empty($composer_json_data->{'require-dev'})) {
3✔
774
                $require_list = array_merge($require_list, array_keys(get_object_vars($composer_json_data->{'require-dev'})));
1✔
775
            }
776
            $handler = AllowListHandler::createFromArray(array_merge($require_list, $config->getAllowList()));
3✔
777
        }
778
        $handler->setLogger($this->getLogger());
191✔
779
        foreach (['allow_list', 'always_allow_direct_dependencies'] as $item) {
191✔
780
            $this->logConfigOverride($config, $item);
191✔
781
        }
782
        $data = $handler->applyToItems($data);
191✔
783
        // Remove non-security packages, if indicated.
784
        if ($config->shouldOnlyUpdateSecurityUpdates()) {
191✔
785
            $this->log('Project indicated that it should only receive security updates. Removing non-security related updates from queue');
3✔
786
            foreach ($data as $delta => $item) {
3✔
787
                $package_config = $config->getConfigForPackage($item->name);
3✔
788
                if (!$package_config->shouldOnlyUpdateSecurityUpdates()) {
3✔
789
                    continue;
1✔
790
                }
791
                try {
792
                    $package_name_in_composer_json = Helpers::getComposerJsonName($composer_json_data, $item->name, $this->composerJsonDir);
2✔
793
                    if (isset($security_alerts[$package_name_in_composer_json])) {
2✔
794
                        continue;
2✔
795
                    }
NEW
796
                } catch (\Exception $e) {
×
797
                    // Totally fine. Let's check if it's there just by the name we have right here.
NEW
798
                    if (isset($security_alerts[$item->name])) {
×
NEW
799
                        continue;
×
800
                    }
801
                }
802
                unset($data[$delta]);
1✔
803
                $this->log(sprintf('Skipping update of %s because it is not indicated as a security update', $item->name));
1✔
804
            }
805
        }
806
        // Remove block listed packages.
807
        $block_list = $config->getBlockList();
191✔
808
        if (!is_array($block_list)) {
191✔
UNCOV
809
                $this->log('The format for the package block list was not correct. Expected an array, got ' . gettype($composer_json_data->extra->violinist->blacklist), Message::VIOLINIST_ERROR);
×
810
        } else {
811
            foreach ($data as $delta => $item) {
191✔
812
                if (in_array($item->name, $block_list)) {
187✔
813
                    $this->log(sprintf('Skipping update of %s because it is on the block list', $item->name), Message::BLACKLISTED, [
4✔
814
                        'package' => $item->name,
4✔
815
                    ]);
4✔
816
                    unset($data[$delta]);
4✔
817
                    continue;
4✔
818
                }
819
                // Also try to match on wildcards.
820
                foreach ($block_list as $block_list_item) {
183✔
821
                    if (fnmatch($block_list_item, $item->name)) {
6✔
822
                        $this->log(sprintf('Skipping update of %s because it is on the block list by pattern %s', $item->name, $block_list_item), Message::BLACKLISTED, [
4✔
823
                            'package' => $item->name,
4✔
824
                        ]);
4✔
825
                        unset($data[$delta]);
4✔
826
                        continue 2;
4✔
827
                    }
828
                }
829
            }
830
        }
831
        // Remove dev dependencies, if indicated.
832
        if (!$config->shouldUpdateDevDependencies()) {
191✔
833
            $this->log('Removing dev dependencies from updates since the option update_dev_dependencies is disabled');
5✔
834
            $filterer = DevDepsOnlyFilterer::create($composer_lock_after_installing, $composer_json_data);
5✔
835
            $data = $filterer->filter($data);
5✔
836
        }
837
        foreach ($data as $delta => $item) {
191✔
838
            // Also unset those that are in an unexpected format. A new thing seen in the wild has been this:
839
            // {
840
            //    "name": "symfony/css-selector",
841
            //    "version": "v2.8.49",
842
            //    "description": "Symfony CssSelector Component"
843
            // }
844
            // They should ideally include a latest version and latest status.
845
            if (!isset($item->latest) || !isset($item->{'latest-status'})) {
177✔
UNCOV
846
                unset($data[$delta]);
×
847
            } else {
848
                // If a package is abandoned, we do not really want to know. Since we can't update it anyway.
849
                if (isset($item->version) && ($item->latest === $item->version || $item->{'latest-status'} === 'up-to-date')) {
177✔
850
                    unset($data[$delta]);
4✔
851
                }
852
            }
853
        }
854
        // Build the list of truly outdated package names after all cleanup
855
        // (missing latest/latest-status, abandoned, allowlist, blocklist, etc.)
856
        // so that closePrsForNoLongerRelevantPackages() doesn't skip packages
857
        // that were in the raw composer outdated output but aren't actually
858
        // outdated.
859
        $all_outdated_package_names = array_map(function ($item) {
191✔
860
            return $item->name;
175✔
861
        }, $data);
191✔
862
        if (empty($data) && !self::shouldEnableCloseNoLongerRelevant()) {
191✔
863
            $this->log('No updates found.');
15✔
864
            $this->cleanUp();
15✔
865
            return;
15✔
866
        }
867
        if (empty($data)) {
176✔
868
            $this->log('No updates found, but USE_CLOSE_NO_LONGER_RELEVANT flag is set, so continuing to attempt closing no longer relevant PRs');
1✔
869
        }
870
        // Try to see if we have already dealt with this (i.e already have a branch for all the updates.
871
        $branch_user = $this->forkUser;
176✔
872
        if ($this->isPrivate) {
176✔
873
            $branch_user = $user_name;
175✔
874
        }
875
        $branch_slug = new Slug();
176✔
876
        $branch_slug->setProvider('github.com');
176✔
877
        $branch_slug->setUserName($branch_user);
176✔
878
        $branch_slug->setUserRepo($user_repo);
176✔
879
        $branches_flattened = [];
176✔
880
        $prs_named = NamedPrs::createFromArray([]);
176✔
881
        $default_base = null;
176✔
882
        try {
883
            if ($default_base_upstream = $this->privateClient->getDefaultBase($this->slug, $default_branch)) {
176✔
884
                $default_base = $default_base_upstream;
173✔
885
            }
886
            $prs_named = $this->privateClient->getPrsNamed($this->slug);
176✔
887
            // These can fail if we have not yet created a fork, and the repo is public. That is why we have them at the
888
            // end of this try/catch, so we can still know the default base for the original repo, and its pull
889
            // requests.
890
            if (!$default_base) {
176✔
891
                $default_base = $this->getPrClient()->getDefaultBase($branch_slug, $default_branch);
3✔
892
            }
893
            $branches_flattened = $this->getPrClient()->getBranchesFlattened($branch_slug);
176✔
UNCOV
894
        } catch (RuntimeException $e) {
×
895
            // Safe to ignore.
UNCOV
896
            $this->log('Had a runtime exception with the fetching of branches and Prs: ' . $e->getMessage());
×
897
        }
898
        if (empty($data)) {
176✔
899
            $this->log('No updates found');
1✔
900
            // First attempt at cleaning up. If there are no updates, but we do
901
            // actually have open violinist PRs, then we should for sure close
902
            // them all.
903
            $this->closePrsForNoLongerRelevantPackages($prs_named, $all_outdated_package_names, $composer_lock_after_installing, $default_branch);
1✔
904
            $this->cleanUp();
1✔
905
            return;
1✔
906
        }
907
        // Try to log what updates are found.
908
        $this->log('The following updates were found:');
175✔
909
        $updates_string = '';
175✔
910
        foreach ($data as $delta => $item) {
175✔
911
            $updates_string .= sprintf(
175✔
912
                "%s: %s installed, %s available (type %s)\n",
175✔
913
                $item->name,
175✔
914
                $item->version,
175✔
915
                $item->latest,
175✔
916
                $item->{'latest-status'}
175✔
917
            );
175✔
918
        }
919
        $this->log($updates_string, Message::UPDATE, [
175✔
920
            'packages' => $data,
175✔
921
        ]);
175✔
922
        if ($default_base && $default_branch) {
175✔
923
            $this->log(sprintf('Current commit SHA for %s is %s', $default_branch, $default_base));
172✔
924
        }
925
        if ($config->shouldUpdateIndirectWithDirect()) {
175✔
926
            $filterer = IndirectWithDirectFilterer::create($composer_lock_after_installing, $composer_json_data);
7✔
927
            $filtered_data = $filterer->filter($data);
7✔
928
            foreach ($filtered_data as $item) {
7✔
929
                $all_outdated_package_names[] = $item->name;
7✔
930
            }
931
            $all_outdated_package_names = array_unique($all_outdated_package_names);
7✔
932
        }
933
        // Now we are closing the ones that are no longer relevant.
934
        $this->closePrsForNoLongerRelevantPackages($prs_named, $all_outdated_package_names, $composer_lock_after_installing, $default_branch);
175✔
935
        $is_allowed_out_of_date_pr = [];
175✔
936
        $one_pr_per_dependency = $config->shouldUseOnePullRequestPerPackage();
175✔
937
        foreach ($data as $delta => $item) {
175✔
938
            $branch_name = Helpers::createBranchName($item, $one_pr_per_dependency, $config);
175✔
939
            if (in_array($branch_name, $branches_flattened)) {
175✔
940
                // Is there a PR for this?
941
                $prs_named_array = $prs_named->getAllPrsNamed();
36✔
942
                if (array_key_exists($branch_name, $prs_named_array)) {
36✔
943
                    $this->countPR($item->name);
33✔
944
                    if (!$default_base && !$one_pr_per_dependency) {
33✔
945
                        $this->log(sprintf('Skipping %s because a pull request already exists', $item->name), Message::PR_EXISTS, [
1✔
946
                            'package' => $item->name,
1✔
947
                        ]);
1✔
948
                        unset($data[$delta]);
1✔
949
                        $this->closeOutdatedPrsForPackage($item->name, $item->version, $config, $prs_named_array[$branch_name]['number'], $prs_named, $default_branch);
1✔
950
                    }
951
                    // Is the pr up to date?
952
                    if ($prs_named_array[$branch_name]['base']['sha'] == $default_base) {
33✔
953
                        // Create a fake "post-update-data" object.
954
                        $fake_post_update = (object) [
17✔
955
                            'version' => $item->latest,
17✔
956
                        ];
17✔
957
                        $security_update = false;
17✔
958
                        $package_name_in_composer_json = $item->name;
17✔
959
                        try {
960
                            $package_name_in_composer_json = Helpers::getComposerJsonName($composer_json_data, $item->name, $this->composerJsonDir);
17✔
961
                        } catch (\Exception $e) {
1✔
962
                            // If this was a package that we somehow got because we have allowed to update other than direct
963
                            // dependencies we can avoid re-throwing this.
964
                            if ($config->shouldCheckDirectOnly()) {
1✔
UNCOV
965
                                throw $e;
×
966
                            }
967
                            // Taking a risk :o.
968
                            $package_name_in_composer_json = $item->name;
1✔
969
                        }
970
                        if (isset($security_alerts[$package_name_in_composer_json])) {
17✔
971
                            $security_update = true;
3✔
972
                        }
973
                        // If the title does not match, it means either has there arrived a security issue for the
974
                        // update (new title), or we are doing "one-per-dependency", and the title should be something
975
                        // else with this new update. Either way, we want to continue this. Continue in this context
976
                        // would mean, we want to keep this for update checking still, and not unset it from the update
977
                        // array. This will mean it will probably get an updated title later.
978
                        if ($prs_named_array[$branch_name]['title'] != $this->getPrParamsCreator()->createTitle($item, $fake_post_update, $security_update)) {
17✔
979
                            $this->log(sprintf('Updating the PR of %s since the computed title does not match the title.', $item->name), Message::MESSAGE);
11✔
980
                            continue;
11✔
981
                        }
982
                        $context = [
6✔
983
                            'package' => $item->name,
6✔
984
                        ];
6✔
985
                        if (!empty($prs_named_array[$branch_name]['html_url'])) {
6✔
UNCOV
986
                            $context['url'] = $prs_named_array[$branch_name]['html_url'];
×
987
                        }
988
                        $this->log(sprintf('Skipping %s because a pull request already exists', $item->name), Message::PR_EXISTS, $context);
6✔
989
                        $this->closeOutdatedPrsForPackage($item->name, $item->version, $config, $prs_named_array[$branch_name]['number'], $prs_named, $default_branch);
6✔
990
                        unset($data[$delta]);
6✔
991
                    } else {
992
                        $is_allowed_out_of_date_pr[] = $item->name;
16✔
993
                    }
994
                }
995
            }
996
        }
997
        if ($config->shouldUpdateIndirectWithDirect()) {
175✔
998
            $this->log('Config suggested we should update indirect with direct. Altering the update data based on this');
7✔
999
            $filterer = IndirectWithDirectFilterer::create($composer_lock_after_installing, $composer_json_data);
7✔
1000
            $data = $filterer->filter($data);
7✔
1001
        }
1002
        if (!count($data)) {
175✔
1003
            $this->log('No updates that have not already been pushed.');
3✔
1004
            $this->cleanUp();
3✔
1005
            return;
3✔
1006
        }
1007

1008
        // Unshallow the repo, for syncing it.
1009
        $this->execCommand(['git', 'pull', '--unshallow'], false, 600);
172✔
1010
        // If the repo is private, we need to push directly to the repo.
1011
        if (!$this->isPrivate) {
172✔
1012
            $this->preparePrClient();
1✔
1013
            $this->log('Creating fork to ' . $this->forkUser);
1✔
1014
            $this->client->createFork($user_name, $user_repo, $this->forkUser);
1✔
1015
        }
1016
        $update_type = self::UPDATE_INDIVIDUAL;
172✔
1017
        if ($config->shouldAlwaysUpdateAll()) {
172✔
1018
            $update_type = self::UPDATE_ALL;
20✔
1019
        }
1020
        $this->log('Config suggested update type ' . $update_type);
172✔
1021
        if ($this->project && $this->project->shouldUpdateAll()) {
172✔
1022
            // Only log this if this might end up being surprising. I mean override all with all. So what?
UNCOV
1023
            if ($update_type === self::UPDATE_INDIVIDUAL) {
×
1024
                $this->log('Override of update type from project data. Probably meaning first run, allowed update all');
×
1025
            }
UNCOV
1026
            $update_type = self::UPDATE_ALL;
×
1027
        }
1028
        switch ($update_type) {
1029
            case self::UPDATE_INDIVIDUAL:
172✔
1030
                $updater = new IndividualUpdater();
152✔
1031
                $updater->setLogger($this->logger);
152✔
1032
                $updater->setCWD($this->getCwd());
152✔
1033
                $updater->setExecuter($this->executer);
152✔
1034
                $updater->setPrCounter($this->getPrCounter());
152✔
1035
                $updater->setComposerJsonDir($this->composerJsonDir);
152✔
1036
                $updater->setMessageFactory($this->messageFactory);
152✔
1037
                $updater->setClient($this->getPrClient());
152✔
1038
                $updater->setIsPrivate($this->isPrivate);
152✔
1039
                $updater->setSlug($this->slug);
152✔
1040
                $updater->setAuthentication($this->untouchedUserToken);
152✔
1041
                $updater->setAssigneesAllowed($this->assigneesAllowed);
152✔
1042
                if ($this->forkUser) {
152✔
1043
                    $updater->setForkUser($this->forkUser);
1✔
1044
                }
1045
                $updater->setTmpDir($this->tmpDir);
152✔
1046
                if ($this->project) {
152✔
1047
                    $updater->setProjectData($this->project);
152✔
1048
                }
1049
                $updater->handleUpdate($data, $composer_lock_after_installing, $composer_json_data, $one_pr_per_dependency, $initial_composer_lock_data, $prs_named, $default_base, $hostname, $default_branch, $security_alerts, $is_allowed_out_of_date_pr, $config);
152✔
1050
                break;
152✔
1051

1052
            case self::UPDATE_ALL:
20✔
1053
                $this->handleUpdateAll($initial_composer_lock_data, $composer_lock_after_installing, $security_alerts, $config, $default_base, $default_branch, $prs_named);
20✔
1054
                break;
20✔
1055
        }
1056
        // Clean up.
1057
        $this->cleanUp();
172✔
1058
    }
1059

1060
    protected function getPrParamsCreator()
1061
    {
1062
        if (!$this->prParamsCreator instanceof PrParamsCreator) {
37✔
1063
            $this->prParamsCreator = new PrParamsCreator($this->messageFactory, $this->project);
37✔
1064
        }
1065
        return $this->prParamsCreator;
37✔
1066
    }
1067

1068
    protected function ensureFreshConfig(\stdClass $composer_json_data) : Config
1069
    {
1070
        return Config::createFromComposerDataInPath($composer_json_data, sprintf('%s/%s', $this->composerJsonDir, 'composer.json'));
198✔
1071
    }
1072

1073
    protected function handleUpdateAll($initial_composer_lock_data, $composer_lock_after_installing, $alerts, Config $config, $default_base, $default_branch, NamedPrs $prs_named)
1074
    {
1075
        // We are going to hack an item here. We want the package to be "all" and the versions to be blank.
1076
        $item = (object) [
20✔
1077
            'name' => 'violinist-all',
20✔
1078
            'version' => '',
20✔
1079
            'latest' => '',
20✔
1080
        ];
20✔
1081
        $branch_name = Helpers::createBranchName($item, false, $config);
20✔
1082
        $pr_params = [];
20✔
1083
        $security_update = false;
20✔
1084
        try {
1085
            $this->switchBranch($branch_name);
20✔
1086
            $status = $this->execCommand(['composer', 'update']);
20✔
1087
            if ($status) {
20✔
UNCOV
1088
                throw new NotUpdatedException('Composer update command exited with status code ' . $status);
×
1089
            }
1090
            // Now let's find out what has actually been updated.
1091
            $new_lock_contents = json_decode(file_get_contents($this->composerJsonDir . '/composer.lock'));
20✔
1092
            $comparer = new LockDataComparer($composer_lock_after_installing, $new_lock_contents);
20✔
1093
            $list = $comparer->getUpdateList();
20✔
1094
            if (empty($list)) {
20✔
1095
                // That's too bad. Let's throw an exception for this.
UNCOV
1096
                throw new NotUpdatedException('No updates detected after running composer update');
×
1097
            }
1098
            // Now see if any of the packages updated was in the alerts.
1099
            foreach ($list as $value) {
20✔
1100
                if (empty($alerts[$value->getPackageName()])) {
20✔
1101
                    continue;
16✔
1102
                }
1103
                $security_update = true;
4✔
1104
            }
1105
            $this->log('Successfully ran command composer update for all packages');
20✔
1106
            $title = 'Update all composer dependencies';
20✔
1107
            if ($security_update) {
20✔
1108
                // @todo: Use message factory and package.
1109
                $title = sprintf('[SECURITY] %s', $title);
4✔
1110
            }
1111
            // We can do this, since the body creates a title, which it does not use. This is only used for the title.
1112
            // Which, again, we do not use.
1113
            $fake_item = $fake_post = (object) [
20✔
1114
                'name' => 'all',
20✔
1115
                'version' => '0.0.0',
20✔
1116
            ];
20✔
1117
            $body = $this->getPrParamsCreator()->createBody($fake_item, $fake_post, null, $security_update, $list);
20✔
1118
            $pr_params = $this->getPrParamsCreator()->getPrParams($this->forkUser, $this->isPrivate, $this->getSlug(), $branch_name, $body, $title, $default_branch, $config);
20✔
1119
            // OK, so... If we already have a branch named the name we are about to use. Is that one a branch
1120
            // containing all the updates we now got? And is it actually up to date with the target branch? Of course,
1121
            // if there is no such branch, then we will happily push it.
1122
            $prs_named_array = $prs_named->getAllPrsNamed();
20✔
1123
            if (!empty($prs_named_array[$branch_name])) {
20✔
1124
                $up_to_date = false;
10✔
1125
                if (!empty($prs_named_array[$branch_name]['base']['sha']) && $prs_named_array[$branch_name]['base']['sha'] == $default_base) {
10✔
1126
                    $up_to_date = true;
3✔
1127
                }
1128
                $should_update = Helpers::shouldUpdatePr($branch_name, $pr_params, $prs_named);
10✔
1129
                if (!$should_update && $up_to_date) {
10✔
1130
                    // Well well well. Let's not push this branch over and over, shall we?
1131
                    $this->log(sprintf('The branch %s with all updates is already up to date. Aborting the PR update', $branch_name));
1✔
1132
                    return;
1✔
1133
                }
1134
            }
1135
            $this->commitFilesForAll($config);
19✔
1136
            $this->pushCode($branch_name, $default_base, $initial_composer_lock_data, $default_branch);
19✔
1137
            $pullRequest = $this->createPullrequest($pr_params);
19✔
1138
            if (!empty($pullRequest['html_url'])) {
14✔
1139
                $this->log($pullRequest['html_url'], Message::PR_URL, [
4✔
1140
                    'package' => 'all',
4✔
1141
                ]);
4✔
1142
                $this->handleAutomerge($config, $pullRequest, $security_update);
14✔
1143
            }
1144
        } catch (ValidationFailedException $e) {
5✔
1145
            // @todo: Do some better checking. Could be several things, this.
1146
            $this->handlePossibleUpdatePrScenario($e, $branch_name, $pr_params, $prs_named, $config, $security_update);
4✔
1147
        } catch (\Gitlab\Exception\RuntimeException $e) {
1✔
1148
            $this->handlePossibleUpdatePrScenario($e, $branch_name, $pr_params, $prs_named, $config, $security_update);
1✔
UNCOV
1149
        } catch (NotUpdatedException $e) {
×
1150
            $this->log($this->getLastStdOut());
×
1151
            $this->log($this->getLastStdErr());
×
1152
            $not_updated_context = [
×
1153
                'package' => sprintf('all:%s', $default_base),
×
1154
            ];
×
1155
            $this->log("Could not update all dependencies with composer update", Message::NOT_UPDATED, $not_updated_context);
×
1156
        } catch (\Throwable $e) {
×
1157
            $this->log('Caught exception while running update all: ' . $e->getMessage());
×
1158
        }
1159
    }
1160

1161
    protected function commitFilesForAll(Config $config)
1162
    {
1163
        $this->cleanRepoForCommit();
19✔
1164
        $creator = $this->getCommitCreator($config);
19✔
1165
        $msg = $creator->generateMessageFromString('Update all dependencies');
19✔
1166
        $this->commitFiles($msg, null, 'all');
19✔
1167
    }
1168

1169
    protected function handlePossibleUpdatePrScenario(\Exception $e, $branch_name, $pr_params, NamedPrs $prs_named, Config $config, $security_update = false)
1170
    {
1171
        $prs_named_array = $prs_named->getAllPrsNamed();
5✔
1172
        $this->log('Had a problem with creating the pull request: ' . $e->getMessage(), 'error');
5✔
1173
        if (Helpers::shouldUpdatePr($branch_name, $pr_params, $prs_named)) {
5✔
1174
            $this->log('Will try to update the PR based on settings.');
5✔
1175
            $this->getPrClient()->updatePullRequest($this->slug, $prs_named_array[$branch_name]['number'], $pr_params);
5✔
1176
        }
1177
        if (!empty($prs_named_array[$branch_name])) {
5✔
1178
            $this->handleAutoMerge($config, $prs_named_array[$branch_name], $security_update);
5✔
1179
            $this->handleLabels($config, $prs_named_array[$branch_name], $security_update);
5✔
1180
        }
1181
    }
1182

1183
    protected function handleLabels(Config $config, $pullRequest, $security_update = false) : void
1184
    {
1185
        if (!Helpers::hasAgencyOrEnterpriseRole($this->project)) {
5✔
1186
            return;
3✔
1187
        }
1188
        Helpers::handleLabels($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
2✔
1189
    }
1190

1191
    protected function handleAutoMerge(Config $config, $pullRequest, $security_update = false) : void
1192
    {
1193
        Helpers::handleAutoMerge($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
9✔
1194
    }
1195

1196
    /**
1197
     * Get the messages that are logged.
1198
     *
1199
     * @return \eiriksm\CosyComposer\Message[]
1200
     *   The logged messages.
1201
     */
1202
    public function getOutput()
1203
    {
1204
        $msgs = [];
125✔
1205
        if (!$this->logger instanceof ArrayLogger) {
125✔
UNCOV
1206
            return $msgs;
×
1207
        }
1208
        /** @var ArrayLogger $my_logger */
1209
        $my_logger = $this->logger;
125✔
1210
        foreach ($my_logger->get() as $message) {
125✔
1211
            $msg = $message['message'];
125✔
1212
            if (!$msg instanceof Message && is_string($msg)) {
125✔
1213
                $msg = new Message($msg);
9✔
1214
            }
1215
            $msg->setContext($message['context']);
125✔
1216
            if (isset($message['context']['command'])) {
125✔
1217
                $msg = new Message($msg->getMessage(), Message::COMMAND);
98✔
1218
                $msg->setContext($message['context']);
98✔
1219
            }
1220
            $msgs[] = $msg;
125✔
1221
        }
1222
        return $msgs;
125✔
1223
    }
1224

1225
    /**
1226
     * Cleans up after the run.
1227
     */
1228
    private function cleanUp()
1229
    {
1230
        // Run composer install again, so we can get rid of newly installed updates for next run.
1231
        $this->execCommand(['composer', 'install', '--no-ansi', '-n'], false, 1200);
193✔
1232
        $this->chdir('/tmp');
193✔
1233
        $this->log('Cleaning up after update check.');
193✔
1234
        $this->execCommand(['rm', '-rf', $this->tmpDir], false, 300);
193✔
1235
        if (file_exists('/usr/local/bin/composer.bak')) {
193✔
UNCOV
1236
            rename('/usr/local/bin/composer.bak', '/usr/local/bin/composer');
×
1237
        }
1238
    }
1239

1240
    /**
1241
     * Executes a command.
1242
     */
1243
    protected function execCommand(array $command, $log = true, $timeout = 120, $env = [])
1244
    {
1245
        $this->executer->setCwd($this->getCwd());
201✔
1246
        return $this->executer->executeCommand($command, $log, $timeout, $env);
201✔
1247
    }
1248

1249
    /**
1250
     * Log a message.
1251
     *
1252
     * @param string $message
1253
     */
1254
    protected function log($message, $type = 'message', $context = [])
1255
    {
1256

1257
        $this->getLogger()->log('info', new Message($message, $type), $context);
201✔
1258
    }
1259

1260
    protected function attachDrupalAdvisories(array &$alerts)
1261
    {
1262
        // Also though. If the only alert we have is for the package with
1263
        // literally "drupal/core" we need to make sure it's attached to the
1264
        // other names as well.
1265
        $known_names = [
194✔
1266
            'drupal/core-recommended',
194✔
1267
            'drupal/core-composer-scaffold',
194✔
1268
            'drupal/core-project-message',
194✔
1269
            'drupal/core',
194✔
1270
            'drupal/drupal',
194✔
1271
        ];
194✔
1272
        if (!empty($alerts['drupal/core'])) {
194✔
1273
            foreach ($known_names as $known_name) {
4✔
1274
                if (!empty($alerts[$known_name])) {
4✔
1275
                    continue;
4✔
1276
                }
1277
                $alerts[$known_name] = $alerts['drupal/core'];
4✔
1278
            }
1279
        }
1280
        if (!$this->lockFileContents) {
194✔
1281
            return;
20✔
1282
        }
1283
        $data = ComposerLockData::createFromString(json_encode($this->lockFileContents));
174✔
1284
        try {
1285
            $drupal = $data->getPackageData('drupal/core');
174✔
1286
            // Now see if a newer version is available, and if it is a security update.
1287
            $endpoint = 'current';
37✔
1288
            $version_parts = explode('.', $drupal->version);
37✔
1289
            $major_version = $version_parts[0];
37✔
1290
            // Only 7.x and 8.x use their own endpoint,
1291
            if (in_array($major_version, ['7', '8'])) {
37✔
1292
                $endpoint = $major_version . '.x';
9✔
1293
            }
1294
            if ((int) $major_version < 7) {
37✔
UNCOV
1295
                throw new \Exception(sprintf('Drupal version %s is too old to check for security updates using drupal.org endpoint', $major_version));
×
1296
            }
1297
            $client = $this->getHttpClient();
37✔
1298
            $url = sprintf('https://updates.drupal.org/release-history/drupal/%s', $endpoint);
37✔
1299
            $request = new Request('GET', $url);
37✔
1300
            $response = $client->sendRequest($request);
37✔
1301
            $data = $response->getBody()->getContents();
37✔
1302
            $xml = @simplexml_load_string($data);
37✔
1303
            if (!$xml) {
37✔
UNCOV
1304
                return;
×
1305
            }
1306
            if (empty($xml->releases->release)) {
37✔
1307
                return;
2✔
1308
            }
1309
            $drupal_version_array = explode('.', $drupal->version);
35✔
1310
            $active_branch = sprintf('%s.%s', $drupal_version_array[0], $drupal_version_array[1]);
35✔
1311
            $supported_branches = explode(',', (string) $xml->supported_branches);
35✔
1312
            $is_supported = false;
35✔
1313
            foreach ($supported_branches as $branch) {
35✔
1314
                if (strpos($branch, $active_branch) === 0) {
35✔
1315
                    $is_supported = true;
12✔
1316
                }
1317
            }
1318
            foreach ($xml->releases->release as $release) {
35✔
1319
                if (empty($release->version)) {
35✔
UNCOV
1320
                    continue;
×
1321
                }
1322
                if (empty($release->terms) || empty($release->terms->term)) {
35✔
1323
                    continue;
3✔
1324
                }
1325
                $version = (string) $release->version;
35✔
1326
                // If they are not on the same branch, then let's skip it as well.
1327
                if ($endpoint !== '7.x') {
35✔
1328
                    if ($is_supported && strpos($version, $active_branch) !== 0) {
33✔
1329
                        continue;
9✔
1330
                    }
1331
                }
1332
                if (version_compare($version, $drupal->version) !== 1) {
35✔
1333
                    continue;
23✔
1334
                }
1335
                $is_sec = false;
23✔
1336
                foreach ($release->terms->term as $item) {
23✔
1337
                    $type = (string) $item->value;
23✔
1338
                    if ($type === 'Security update') {
23✔
1339
                        $is_sec = true;
9✔
1340
                    }
1341
                }
1342
                if (!$is_sec) {
23✔
1343
                    continue;
17✔
1344
                }
1345
                if (strpos($release->version, $major_version) !== 0) {
9✔
1346
                    // You know what. We must be checking version 10.x against
1347
                    // version 9.x. Not ideal, is it? Makes for some false
1348
                    // positives (or rather negatives, I guess).
1349
                    continue;
1✔
1350
                }
1351
                $this->log('Found a security update in the update XML. Will populate advisories from this, if not already set.');
9✔
1352
                foreach ($known_names as $known_name) {
9✔
1353
                    if (!empty($alerts[$known_name])) {
9✔
UNCOV
1354
                        continue;
×
1355
                    }
1356
                    $alerts[$known_name] = [
9✔
1357
                        'version' => $version,
9✔
1358
                    ];
9✔
1359
                }
1360
                break;
9✔
1361
            }
1362
        } catch (\Throwable $e) {
137✔
1363
            // Totally fine.
1364
        }
1365
    }
1366

1367
   /**
1368
    * Changes to a different directory.
1369
    */
1370
    private function chdir($dir)
1371
    {
1372
        if (!file_exists($dir)) {
200✔
UNCOV
1373
            return false;
×
1374
        }
1375
        $this->setCWD($dir);
200✔
1376
        return true;
200✔
1377
    }
1378

1379
    protected function setCWD($dir)
1380
    {
1381
        $this->cwd = $dir;
200✔
1382
    }
1383

1384

1385
    /**
1386
     * @return string
1387
     */
1388
    public function getTmpDir()
1389
    {
UNCOV
1390
        return $this->tmpDir;
×
1391
    }
1392

1393
    /**
1394
     * @param Slug $slug
1395
     *
1396
     * @return ProviderInterface
1397
     */
1398
    private function getClient(Slug $slug)
1399
    {
1400
        if (!$this->providerFactory instanceof ProviderFactory) {
198✔
UNCOV
1401
            $this->setProviderFactory(new ProviderFactory());
×
1402
        }
1403
        return $this->providerFactory->createFromHost($slug, $this->urlArray);
198✔
1404
    }
1405

1406
    /**
1407
     * Get the client we should use for the PRs we create.
1408
     */
1409
    private function getPrClient() : ProviderInterface
1410
    {
1411
        if ($this->isPrivate) {
176✔
1412
            return $this->privateClient;
175✔
1413
        }
1414
        $this->preparePrClient();
1✔
1415
        $this->client->authenticate($this->userToken, null);
1✔
1416
        return $this->client;
1✔
1417
    }
1418

1419
    private function preparePrClient() : void
1420
    {
1421
        // We are only allowed to use the public github wrapper if the magic env
1422
        // for this is set, which it will be in jobs coming from the SaaS
1423
        // offering, but not for self hosted.
1424
        $this->logger->log('info', new Message('Checking if we should enable the public github wrapper', Message::COMMAND));
1✔
1425
        if (!self::shouldEnablePublicGithubWrapper()) {
1✔
1426
            // The client should hopefully be fully prepared.
UNCOV
1427
            $this->logger->log('info', new Message('Public github wrapper not enabled', Message::COMMAND));
×
1428
            return;
×
1429
        }
1430
        if (!$this->isPrivate) {
1✔
1431
            $this->logger->log('info', new Message('Public github wrapper enabled', Message::COMMAND));
1✔
1432
            if (!$this->client instanceof PublicGithubWrapper) {
1✔
UNCOV
1433
                $this->client = new PublicGithubWrapper(new Client());
×
1434
            }
1435
            $this->client->setUserToken($this->userToken);
1✔
1436
            $this->client->setUrlFromTokenUrl($this->tokenUrl);
1✔
1437
            $this->client->setProject($this->project);
1✔
1438
        }
1439
    }
1440

1441
    private function checkDefaultBranch() : ?string
1442
    {
1443
        $default_branch = null;
197✔
1444
        try {
1445
            $default_branch = $this->privateClient->getDefaultBranch($this->slug);
197✔
UNCOV
1446
        } catch (\Throwable $e) {
×
1447
            // Could be a personal access token.
UNCOV
1448
            if (!method_exists($this->privateClient, 'authenticatePersonalAccessToken')) {
×
1449
                throw $e;
×
1450
            }
1451
            try {
UNCOV
1452
                $this->privateClient->authenticatePersonalAccessToken($this->userToken, null);
×
1453
                $default_branch = $this->privateClient->getDefaultBranch($this->slug);
×
1454
            } catch (\Throwable $other_exception) {
×
1455
                throw $e;
×
1456
            }
1457
        }
1458
        return $default_branch;
197✔
1459
    }
1460

1461
    private function checkPrivateStatus() : bool
1462
    {
1463
        if (!self::shouldEnablePublicGithubWrapper()) {
197✔
1464
            return true;
196✔
1465
        }
1466
        try {
1467
            return $this->privateClient->repoIsPrivate($this->slug);
1✔
UNCOV
1468
        } catch (\Throwable $e) {
×
1469
            // Could be a personal access token.
UNCOV
1470
            if (!method_exists($this->privateClient, 'authenticatePersonalAccessToken')) {
×
1471
                return true;
×
1472
            }
1473
            try {
UNCOV
1474
                $this->privateClient->authenticatePersonalAccessToken($this->userToken, null);
×
1475
                return $this->privateClient->repoIsPrivate($this->slug);
×
1476
            } catch (\Throwable $other_exception) {
×
1477
                throw $e;
×
1478
            }
1479
        }
1480
    }
1481

1482
    protected function getlockFileContents()
1483
    {
1484
        return $this->lockFileContents;
19✔
1485
    }
1486

1487
    public static function shouldEnablePublicGithubWrapper() : bool
1488
    {
1489
        return !empty(getenv('USE_GITHUB_PUBLIC_WRAPPER'));
202✔
1490
    }
1491

1492
    public static function shouldEnableCloseNoLongerRelevant() : bool
1493
    {
1494
        return !empty(getenv('USE_CLOSE_NO_LONGER_RELEVANT'));
196✔
1495
    }
1496
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc