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

eiriksm / cosy-composer / 13715678822

07 Mar 2025 07:13AM UTC coverage: 86.43% (+0.2%) from 86.28%
13715678822

push

github

web-flow
Refactor individual update (#398)

* Refactor individual update

* Code style

* new.files

* Update GitCommandsTrait.php

* Update GitCommandsTrait.php

* Update PrParamsCreator.php

608 of 643 new or added lines in 14 files covered. (94.56%)

3 existing lines in 1 file now uncovered.

1777 of 2056 relevant lines covered (86.43%)

43.43 hits per line

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

84.13
/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\PublicGithubWrapper;
12
use eiriksm\CosyComposer\Updater\IndividualUpdater;
13
use GuzzleHttp\Psr7\Request;
14
use Http\Adapter\Guzzle7\Client as GuzzleClient;
15
use Http\Client\HttpClient;
16
use Symfony\Component\Process\Process;
17
use Violinist\AllowListHandler\AllowListHandler;
18
use Violinist\ComposerLockData\ComposerLockData;
19
use Violinist\ComposerUpdater\Exception\NotUpdatedException;
20
use Violinist\Config\Config;
21
use eiriksm\ViolinistMessages\ViolinistMessages;
22
use Github\Client;
23
use Github\Exception\RuntimeException;
24
use Github\Exception\ValidationFailedException;
25
use League\Flysystem\Local\LocalFilesystemAdapter;
26
use Psr\Log\LoggerInterface;
27
use Violinist\RepoAndTokenToCloneUrl\ToCloneUrl;
28
use Violinist\Slug\Slug;
29
use Violinist\TimeFrameHandler\Handler;
30
use Wa72\SimpleLogger\ArrayLogger;
31

32
class CosyComposer
33
{
34
    use ComposerInstallTrait;
35
    use PrCounterTrait;
36
    use GitCommandsTrait;
37
    use SlugAwareTrait;
38
    use TokenAwareTrait;
39
    use AssigneesAllowedTrait;
40
    use TemporaryDirectoryAwareTrait;
41

42
    const UPDATE_ALL = 'update_all';
43

44
    const UPDATE_INDIVIDUAL = 'update_individual';
45

46
    private $urlArray;
47

48
    /**
49
     * @var bool|string
50
     */
51
    private $lockFileContents;
52

53
    /**
54
     * @var ProviderFactory
55
     */
56
    protected $providerFactory;
57

58
    /**
59
     * @var \eiriksm\CosyComposer\CommandExecuter
60
     */
61
    protected $executer;
62

63
    /**
64
     * @var ComposerFileGetter
65
     */
66
    protected $composerGetter;
67

68
    /**
69
     * @var string
70
     */
71
    protected $cwd;
72

73
    /**
74
     * @var string
75
     */
76
    private $forkUser;
77

78
    /**
79
     * @var ViolinistMessages
80
     */
81
    private $messageFactory;
82

83
    /**
84
     * @var string
85
     */
86
    protected $composerJsonDir;
87

88
    /**
89
     * @var LoggerInterface
90
     */
91
    protected $logger;
92

93
    /**
94
     * @var null|\Violinist\ProjectData\ProjectData
95
     */
96
    protected $project;
97

98
    /**
99
     * @var HttpClient
100
     */
101
    protected $httpClient;
102

103
    /**
104
     * @var string
105
     */
106
    protected $tokenUrl;
107

108
    /**
109
     * @var bool
110
     */
111
    private $isPrivate = false;
112

113
    /**
114
     * @var SecurityCheckerFactory
115
     */
116
    private $checkerFactory;
117

118
    /**
119
     * @var ProviderInterface
120
     */
121
    private $client;
122

123
    /**
124
     * @var ProviderInterface
125
     */
126
    private $privateClient;
127

128
    /**
129
     * @var string
130
     */
131
    private $hostName;
132

133
    /**
134
     * @var PrParamsCreator
135
     */
136
    private $prParamsCreator;
137

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

146
    /**
147
     * @return SecurityCheckerFactory
148
     */
149
    public function getCheckerFactory()
150
    {
151
        return $this->checkerFactory;
175✔
152
    }
153

154
    /**
155
     * @param string $tokenUrl
156
     */
157
    public function setTokenUrl($tokenUrl)
158
    {
159
        $this->tokenUrl = $tokenUrl;
175✔
160
    }
161

162
    /**
163
     * @param \Violinist\ProjectData\ProjectData|null $project
164
     */
165
    public function setProject($project)
166
    {
167
        $this->project = $project;
175✔
168
    }
169

170
    /**
171
     * @return LoggerInterface
172
     */
173
    public function getLogger()
174
    {
175
        if (!$this->logger instanceof LoggerInterface) {
163✔
176
            $this->logger = new ArrayLogger();
162✔
177
        }
178
        return $this->logger;
163✔
179
    }
180

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

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

200
    /**
201
     * @param HttpClient $httpClient
202
     */
203
    public function setHttpClient(HttpClient $httpClient)
204
    {
205
        $this->httpClient = $httpClient;
175✔
206
    }
207

208
    /**
209
     * @return string
210
     */
211
    public function getCwd()
212
    {
213
        return $this->cwd;
162✔
214
    }
215

216
    /**
217
     * @param \eiriksm\CosyComposer\CommandExecuter $executer
218
     */
219
    public function setExecuter($executer)
220
    {
221
        $this->executer = $executer;
167✔
222
    }
223

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

232

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

245
    public function setUrl($url = null)
246
    {
247
        if (!empty($url)) {
175✔
248
            $url = preg_replace('/\.git$/', '', $url);
175✔
249
        }
250
        $slug_url_obj = parse_url($url);
175✔
251
        if (empty($slug_url_obj['port'])) {
175✔
252
            // Set it based on scheme.
253
            switch ($slug_url_obj['scheme']) {
175✔
254
                case 'http':
175✔
255
                    $slug_url_obj['port'] = 80;
1✔
256
                    break;
1✔
257

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

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

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

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

299
    protected function handleTimeIntervalSetting(Config $config)
300
    {
301
        if (Handler::isAllowed($config)) {
157✔
302
            return;
155✔
303
        }
304
        throw new OutsideProcessingHoursException('Current hour is inside timeframe disallowed');
1✔
305
    }
306

307
    public function handleDrupalContribSa($cdata)
308
    {
309
        if (!getenv('DRUPAL_CONTRIB_SA_PATH')) {
157✔
310
            return;
157✔
311
        }
312
        $symfony_dir = sprintf('%s/.symfony/cache/security-advisories/drupal', getenv('HOME'));
×
313
        if (!file_exists($symfony_dir)) {
×
314
            $mkdir = $this->execCommand(['mkdir', '-p', $symfony_dir]);
×
315
            if ($mkdir) {
×
316
                return;
×
317
            }
318
        }
319
        $contrib_sa_dir = getenv('DRUPAL_CONTRIB_SA_PATH');
×
320
        if (empty($cdata->repositories)) {
×
321
            return;
×
322
        }
323
        foreach ($cdata->repositories as $repository) {
×
324
            if (empty($repository->url)) {
×
325
                continue;
×
326
            }
327
            if ($repository->url === 'https://packages.drupal.org/8') {
×
328
                $process = Process::fromShellCommandline('rsync -aq ' . sprintf('%s/sa_yaml/8/drupal/*', $contrib_sa_dir) .  " $symfony_dir/");
×
329
                $process->run();
×
330
            }
331
            if ($repository->url === 'https://packages.drupal.org/7') {
×
332
                $process = Process::fromShellCommandline('rsync -aq ' . sprintf('%s/sa_yaml/7/drupal/*', $contrib_sa_dir) .  " $symfony_dir/");
×
333
                $process->run();
×
334
            }
335
        }
336
    }
337

338
    /**
339
     * Export things.
340
     */
341
    protected function exportEnvVars()
342
    {
343
        if (!$this->project) {
162✔
344
            return;
×
345
        }
346
        $env = $this->project->getEnvString();
162✔
347
        if (empty($env)) {
162✔
348
            return;
156✔
349
        }
350
        // One per line.
351
        $env_array = preg_split("/\r\n|\n|\r/", $env);
6✔
352
        if (empty($env_array)) {
6✔
353
            return;
×
354
        }
355
        foreach ($env_array as $env_string) {
6✔
356
            if (empty($env_string)) {
6✔
357
                continue;
2✔
358
            }
359
            $env_parts = explode('=', $env_string, 2);
6✔
360
            if (count($env_parts) != 2) {
6✔
361
                continue;
×
362
            }
363
            // We do not allow to override ENV vars.
364
            $key = $env_parts[0];
6✔
365
            $existing_env = getenv($key);
6✔
366
            if ($existing_env) {
6✔
367
                $this->getLogger()->log('info', new Message("The ENV variable $key was skipped because it exists and can not be overwritten"));
3✔
368
                continue;
3✔
369
            }
370
            $value = $env_parts[1];
6✔
371
            $this->getLogger()->log('info', new Message("Exporting ENV variable $key: $value"));
6✔
372
            putenv($env_string);
6✔
373
            $_ENV[$key] = $value;
6✔
374
        }
375
    }
376

377
    protected function closeOutdatedPrsForPackage($package_name, $current_version, Config $config, $pr_id, $prs_named, $default_branch)
378
    {
379
        $fake_item = (object) [
5✔
380
            'name' => $package_name,
5✔
381
            'version' => $current_version,
5✔
382
            'latest' => '',
5✔
383
        ];
5✔
384
        $branch_name_prefix = Helpers::createBranchName($fake_item, false, $config);
5✔
385
        foreach ($prs_named as $branch_name => $pr) {
5✔
386
            if (!empty($pr["base"]["ref"])) {
5✔
387
                // The base ref should be what we are actually using for merge requests.
UNCOV
388
                if ($pr["base"]["ref"] != $default_branch) {
×
UNCOV
389
                    continue;
×
390
                }
391
            }
392
            if ($pr["number"] == $pr_id) {
5✔
393
                // We really don't want to close the one we are considering as the latest one, do we?
394
                continue;
5✔
395
            }
396
            // We are just going to assume, if the number of the PR does not match. And the branch name does
397
            // indeed "match", well. Match as in it updates the exact package from the exact same version. Then
398
            // the current/recent PR will update to a newer version. Or it could also be that the branch was
399
            // created while the project was using one PR per version, and then they switched. Either way. These
400
            // two scenarios are both scenarios we want to handle in such a way that we are closing this PR that
401
            // is matching.
402
            if (strpos($branch_name, $branch_name_prefix) === false) {
3✔
UNCOV
403
                continue;
×
404
            }
405
            $comment = $this->messageFactory->getPullRequestClosedMessage($pr_id);
3✔
406
            $pr_number = $pr['number'];
3✔
407
            $this->getLogger()->log('info', new Message("Trying to close PR number $pr_number since it has been superseded by $pr_id"));
3✔
408
            try {
409
                $this->getPrClient()->closePullRequestWithComment($this->slug, $pr_number, $comment);
3✔
410
                $this->getLogger()->log('info', new Message("Successfully closed PR $pr_number"));
3✔
411
            } catch (\Throwable $e) {
×
412
                $msg = $e->getMessage();
×
413
                $this->getLogger()->log('error', new Message("Caught an exception trying to close pr $pr_number. The message was '$msg'"));
×
414
            }
415
        }
416
    }
417

418
    public function setViolinistHostname(string $hostname)
419
    {
420
        $this->hostName = $hostname;
175✔
421
    }
422

423
    /**
424
     * @throws \eiriksm\CosyComposer\Exceptions\ChdirException
425
     * @throws \eiriksm\CosyComposer\Exceptions\GitCloneException
426
     * @throws \InvalidArgumentException
427
     * @throws \Exception
428
     * @throws \Throwable
429
     */
430
    public function run()
431
    {
432
        // Always start by making sure the .ssh directory exists.
433
        $directory = sprintf('%s/.ssh', getenv('HOME'));
162✔
434
        if (!file_exists($directory)) {
162✔
435
            if (!@mkdir($directory, 0700) && !is_dir($directory)) {
1✔
436
                throw new \RuntimeException(sprintf('Directory "%s" was not created', $directory));
×
437
            }
438
        }
439
        // Export the environment variables if needed.
440
        $this->exportEnvVars();
162✔
441
        if ($this->hostName) {
162✔
442
            $this->log(sprintf('Running update check on %s', $this->hostName));
162✔
443
        }
444
        if (!empty($_SERVER['violinist_revision'])) {
162✔
445
            $this->log(sprintf('Queue starter revision %s', $_SERVER['violinist_revision']));
×
446
        }
447
        if (!empty($_SERVER['queue_runner_revision'])) {
162✔
448
            $this->log(sprintf('Queue runner revision %s', $_SERVER['queue_runner_revision']));
×
449
        }
450
        // Support an alternate composer version based on env var.
451
        if (!empty($_ENV['ALTERNATE_COMPOSER_PATH'])) {
162✔
452
            $allow_list = [
×
453
                '/usr/local/bin/composer22',
×
454
            ];
×
455
            if (!in_array($_ENV['ALTERNATE_COMPOSER_PATH'], $allow_list)) {
×
456
                throw new \InvalidArgumentException('The alternate composer path is not allowed');
×
457
            }
458
            $this->log('Trying to use composer from ' . $_ENV['ALTERNATE_COMPOSER_PATH']);
×
459
            if (file_exists('/usr/local/bin/composer')) {
×
460
                rename('/usr/local/bin/composer', '/usr/local/bin/composer.bak');
×
461
            }
462
            copy($_ENV['ALTERNATE_COMPOSER_PATH'], '/usr/local/bin/composer');
×
463
            chmod('/usr/local/bin/composer', 0755);
×
464
        }
465
        // Try to get the php version as well.
466
        $this->execCommand(['php', '--version']);
162✔
467
        $this->log($this->getLastStdOut());
162✔
468
        // Try to get the composer version as well.
469
        $this->execCommand(['composer', '--version']);
162✔
470
        $this->log($this->getLastStdOut());
162✔
471
        $this->log(sprintf('Starting update check for %s', $this->slug->getSlug()));
162✔
472
        $user_name = $this->slug->getUserName();
162✔
473
        $user_repo = $this->slug->getUserRepo();
162✔
474
        $hostname = $this->slug->getProvider();
162✔
475
        $url = null;
162✔
476
        // Make sure we accept the fingerprint of whatever we are cloning.
477
        $this->execCommand(['ssh-keyscan', '-t', 'rsa', $hostname, '>>', '~/.ssh/known_hosts']);
162✔
478
        if (!empty($_SERVER['private_key'])) {
162✔
479
            $this->log('Checking for existing private key');
×
480
            $filename = "$directory/id_rsa";
×
481
            if (!file_exists($filename)) {
×
482
                $this->log('Installing private key');
×
483
                file_put_contents($filename, $_SERVER['private_key']);
×
484
                $this->execCommand(['chmod', '600', $filename], false);
×
485
            }
486
        }
487
        $is_bitbucket = false;
162✔
488
        $bitbucket_user = null;
162✔
489
        $url = ToCloneUrl::fromRepoAndToken($this->slug->getUrl(), $this->userToken);
162✔
490
        switch ($hostname) {
491
            case 'bitbucket.org':
162✔
492
                $is_bitbucket = true;
2✔
493
                if (Bitbucket::tokenIndicatesUserAppPassword($this->userToken)) {
2✔
494
                    // The username will now be the thing before the colon.
495
                    [$bitbucket_user, $this->userToken] = explode(':', $this->userToken);
1✔
496
                }
497
                break;
2✔
498

499
            default:
500
                // Use the upstream package for this.
501
                break;
160✔
502
        }
503
        $urls = [
162✔
504
            $url,
162✔
505
        ];
162✔
506
        // We also want to check what happens if we append .git to the URL. This can be a problem in newer
507
        // versions of git, that git does not accept redirects.
508
        $length = strlen('.git');
162✔
509
        $ends_with_git = substr($url, -$length) === '.git';
162✔
510
        if (!$ends_with_git) {
162✔
511
            $urls[] = "$url.git";
160✔
512
        }
513
        $this->log('Cloning repository');
162✔
514
        foreach ($urls as $url) {
162✔
515
            $clone_result = $this->execCommand(['git', 'clone', '--depth=1', $url, $this->tmpDir], false, 240);
162✔
516
            if (!$clone_result) {
162✔
517
                break;
161✔
518
            }
519
        }
520
        if ($clone_result) {
162✔
521
            // We had a problem.
522
            $this->log($this->getLastStdOut());
1✔
523
            $this->log($this->getLastStdErr());
1✔
524
            throw new GitCloneException('Problem with the execCommand git clone. Exit code was ' . $clone_result);
1✔
525
        }
526
        $this->log('Repository cloned');
161✔
527
        $composer_json_dir = $this->tmpDir;
161✔
528
        if ($this->project && $this->project->getComposerJsonDir()) {
161✔
529
            $composer_json_dir = sprintf('%s/%s', $this->tmpDir, $this->project->getComposerJsonDir());
×
530
        }
531
        $this->composerJsonDir = $composer_json_dir;
161✔
532
        if (!$this->chdir($this->composerJsonDir)) {
161✔
533
            throw new ChdirException('Problem with changing dir to the clone dir.');
1✔
534
        }
535
        $local_adapter = new LocalFilesystemAdapter($this->composerJsonDir);
160✔
536
        if (!empty($_ENV['config_branch'])) {
160✔
537
            $config_branch = $_ENV['config_branch'];
6✔
538
            $this->log('Changing to config branch: ' . $config_branch);
6✔
539
            $tmpdir = sprintf('/tmp/%s', uniqid('', true));
6✔
540
            $clone_result = $this->execCommand(['git', 'clone', '--depth=1', $url, $tmpdir, '-b', $config_branch], false, 120);
6✔
541
            if (!$clone_result) {
6✔
542
                $local_adapter = new LocalFilesystemAdapter($tmpdir);
6✔
543
            }
544
        }
545
        $this->composerGetter = new ComposerFileGetter($local_adapter);
160✔
546
        if (!$this->composerGetter->hasComposerFile()) {
160✔
547
            throw new \InvalidArgumentException('No composer.json file found.');
1✔
548
        }
549
        $composer_json_data = $this->composerGetter->getComposerJsonData();
159✔
550
        if (false == $composer_json_data) {
159✔
551
            throw new \InvalidArgumentException('Invalid composer.json file');
1✔
552
        }
553
        $config = $this->ensureFreshConfig($composer_json_data);
158✔
554
        $this->client = $this->getClient($this->slug);
158✔
555
        $this->privateClient = $this->getClient($this->slug);
158✔
556
        $this->privateClient->authenticate($this->userToken, null);
158✔
557
        if ($is_bitbucket && $bitbucket_user) {
157✔
558
            $this->privateClient->authenticate($bitbucket_user, $this->userToken);
1✔
559
        }
560

561
        $this->logger->log('info', new Message('Checking private status of repo', Message::COMMAND));
157✔
562
        $this->isPrivate = $this->checkPrivateStatus();
157✔
563
        $this->logger->log('info', new Message('Checking default branch of repo', Message::COMMAND));
157✔
564
        $default_branch = $this->checkDefaultBranch();
157✔
565

566
        if ($default_branch) {
157✔
567
            $this->log('Default branch set in project is ' . $default_branch);
153✔
568
        }
569
        // We also allow the project to override this for violinist.
570
        if ($config->getDefaultBranch()) {
157✔
571
            // @todo: Would be better to make sure this can actually be set, based on the branches available. Either
572
            // way, if a person configures this wrong, several parts will fail spectacularly anyway.
573
            $default_branch = $config->getDefaultBranch();
2✔
574
            $this->log('Default branch overridden by config and set to ' . $default_branch);
2✔
575
        }
576
        // Now make sure we are actually on that branch.
577
        if ($this->execCommand(['git', 'remote', 'set-branches', 'origin', "*"])) {
157✔
578
            // We had a problem.
579
            $this->log($this->getLastStdOut());
×
580
            $this->log($this->getLastStdErr());
×
581
            throw new \Exception('There was an error trying to configure default branch');
×
582
        }
583
        if ($this->execCommand(['git', 'fetch', 'origin', $default_branch])) {
157✔
584
            // We had a problem.
585
            $this->log($this->getLastStdOut());
×
586
            $this->log($this->getLastStdErr());
×
587
            throw new \Exception('There was an error trying to fetch default branch');
×
588
        }
589
        if ($this->execCommand(['git', 'checkout', $default_branch])) {
157✔
590
            // We had a problem.
591
            $this->log($this->getLastStdOut());
×
592
            $this->log($this->getLastStdErr());
×
593
            throw new \Exception('There was an error trying to switch to default branch');
×
594
        }
595
        // Re-read the composer.json file, since it can be different on the default branch,
596
        $composer_json_data = $this->composerGetter->getComposerJsonData();
157✔
597
        $this->runAuthExport($hostname);
157✔
598
        $this->handleDrupalContribSa($composer_json_data);
157✔
599
        $config = $this->ensureFreshConfig($composer_json_data);
157✔
600
        $this->handleTimeIntervalSetting($config);
157✔
601
        $lock_file = $this->composerJsonDir . '/composer.lock';
155✔
602
        $initial_composer_lock_data = false;
155✔
603
        $security_alerts = [];
155✔
604
        if (@file_exists($lock_file)) {
155✔
605
            // We might want to know whats in here.
606
            $initial_composer_lock_data = json_decode(file_get_contents($lock_file));
134✔
607
        }
608
        $this->lockFileContents = $initial_composer_lock_data;
155✔
609
        if ($config->shouldAlwaysUpdateAll() && !$initial_composer_lock_data) {
155✔
610
            $this->log('Update all enabled, but no lock file present. This is not supported');
1✔
611
            $this->cleanUp();
1✔
612
            return;
1✔
613
        }
614
        $this->doComposerInstall($config);
154✔
615
        // Now read the lockfile.
616
        $composer_lock_after_installing = json_decode(@file_get_contents($this->composerJsonDir . '/composer.lock'));
154✔
617
        // And do a quick security check in there as well.
618
        try {
619
            $this->log('Checking for security issues in project.');
154✔
620
            $checker = $this->checkerFactory->getChecker();
154✔
621
            $result = $checker->checkDirectory($this->composerJsonDir);
154✔
622
            // Make sure this is an array now.
623
            if (!$result) {
152✔
624
                $result = [];
143✔
625
            }
626
            $this->log('Found ' . count($result) . ' security advisories for packages installed', 'message', [
152✔
627
                'result' => $result,
152✔
628
            ]);
152✔
629
            foreach ($result as $name => $value) {
152✔
630
                $this->log("Security update available for $name");
9✔
631
            }
632
            if (count($result)) {
152✔
633
                $security_alerts = $result;
152✔
634
            }
635
        } catch (\Exception $e) {
2✔
636
            $this->log('Caught exception while looking for security updates:');
2✔
637
            $this->log($e->getMessage());
2✔
638
        }
639
        // We also want to consult the Drupal security advisories, since the FriendsOfPHP/security-advisories
640
        // repo is a manual job merging and maintaining. On top of that, it requires the built container to be
641
        // up to date. So here could be several hours of delay on critical stuff.
642
        $this->attachDrupalAdvisories($security_alerts);
154✔
643
        $direct = null;
154✔
644
        if ($config->shouldCheckDirectOnly()) {
154✔
645
            $this->log('Checking only direct dependencies since config option check_only_direct_dependencies is enabled');
150✔
646
            $direct = '--direct';
150✔
647
        }
648
        // If we should always update all, then of course we should not only check direct dependencies outdated.
649
        // Regardless of the option above actually.
650
        if ($config->shouldAlwaysUpdateAll()) {
154✔
651
            $this->log('Checking all (not only direct dependencies) since config option always_update_all is enabled');
21✔
652
            $direct = null;
21✔
653
        }
654
        // If we should allow indirect packages to updated via running composer update my/direct, then we need to
655
        // uncover which indirect are actually out of date. Meaning direct is required to be false.
656
        if ($config->shouldUpdateIndirectWithDirect()) {
154✔
657
            $this->log('Checking all (not only direct dependencies) since config option allow_update_indirect_with_direct is enabled');
6✔
658
            $direct = null;
6✔
659
        }
660
        $composer_outdated_command = [
154✔
661
            'composer',
154✔
662
            'outdated',
154✔
663
            '--format=json',
154✔
664
            '--no-interaction',
154✔
665
        ];
154✔
666
        if ($direct) {
154✔
667
            $composer_outdated_command[] = $direct;
123✔
668
        }
669
        switch ($config->getComposerOutdatedFlag()) {
154✔
670
            case 'patch':
154✔
671
                $composer_outdated_command[] = '--patch-only';
2✔
672
                break;
2✔
673
            default:
674
                $composer_outdated_command[] = '--minor-only';
152✔
675
                break;
152✔
676
        }
677
        $this->execCommand($composer_outdated_command);
154✔
678
        $raw_data = $this->getLastStdOut();
154✔
679
        $json_update = @json_decode($raw_data);
154✔
680
        if (!$json_update) {
154✔
681
            // We had a problem.
682
            $this->log($this->getLastStdOut());
×
683
            $this->log($this->getLastStdErr());
×
684
            throw new \Exception('The output for available updates could not be parsed as JSON');
×
685
        }
686
        if (!isset($json_update->installed)) {
154✔
687
            // We had a problem.
688
            $this->log($this->getLastStdOut());
2✔
689
            $this->log($this->getLastStdErr());
2✔
690
            throw new \Exception(
2✔
691
                'JSON output from composer was not looking as expected after checking updates'
2✔
692
            );
2✔
693
        }
694
        $data = $json_update->installed;
152✔
695
        if (!is_array($data)) {
152✔
696
            $this->log('Update data was in wrong format or missing. This is an error in violinist and should be reported');
1✔
697
            $this->log(print_r($raw_data, true), Message::COMMAND, [
1✔
698
              'data' => $raw_data,
1✔
699
              'data_guessed' => $data,
1✔
700
            ]);
1✔
701
            $this->cleanUp();
1✔
702
            return;
1✔
703
        }
704
        // Only update the ones in the allow list, if indicated.
705
        $handler = AllowListHandler::createFromConfig($config);
151✔
706
        // If we have an allow list, we should also make sure to include the
707
        // direct ones in it, if indicated.
708
        if ($config->getAllowList() && $config->shouldAlwaysAllowDirect()) {
151✔
709
            $require_list = [];
2✔
710
            if (!empty($composer_json_data->require)) {
2✔
711
                $require_list = array_keys(get_object_vars($composer_json_data->require));
1✔
712
            }
713
            if (!empty($composer_json_data->{'require-dev'})) {
2✔
714
                $require_list = array_merge($require_list, array_keys(get_object_vars($composer_json_data->{'require-dev'})));
1✔
715
            }
716
            $handler = AllowListHandler::createFromArray(array_merge($require_list, $config->getAllowList()));
2✔
717
        }
718
        $handler->setLogger($this->getLogger());
151✔
719
        $data = $handler->applyToItems($data);
151✔
720
        // Remove non-security packages, if indicated.
721
        if ($config->shouldOnlyUpdateSecurityUpdates()) {
151✔
722
            $this->log('Project indicated that it should only receive security updates. Removing non-security related updates from queue');
2✔
723
            foreach ($data as $delta => $item) {
2✔
724
                try {
725
                    $package_name_in_composer_json = Helpers::getComposerJsonName($composer_json_data, $item->name, $this->composerJsonDir);
2✔
726
                    if (isset($security_alerts[$package_name_in_composer_json])) {
2✔
727
                        continue;
2✔
728
                    }
729
                } catch (\Exception $e) {
×
730
                    // Totally fine. Let's check if it's there just by the name we have right here.
731
                    if (isset($security_alerts[$item->name])) {
×
732
                        continue;
×
733
                    }
734
                }
735
                unset($data[$delta]);
1✔
736
                $this->log(sprintf('Skipping update of %s because it is not indicated as a security update', $item->name));
1✔
737
            }
738
        }
739
        // Remove block listed packages.
740
        $block_list = $config->getBlockList();
151✔
741
        if (!is_array($block_list)) {
151✔
742
                $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);
×
743
        } else {
744
            foreach ($data as $delta => $item) {
151✔
745
                if (in_array($item->name, $block_list)) {
149✔
746
                    $this->log(sprintf('Skipping update of %s because it is on the block list', $item->name), Message::BLACKLISTED, [
4✔
747
                        'package' => $item->name,
4✔
748
                    ]);
4✔
749
                    unset($data[$delta]);
4✔
750
                    continue;
4✔
751
                }
752
                // Also try to match on wildcards.
753
                foreach ($block_list as $block_list_item) {
145✔
754
                    if (fnmatch($block_list_item, $item->name)) {
5✔
755
                        $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✔
756
                            'package' => $item->name,
4✔
757
                        ]);
4✔
758
                        unset($data[$delta]);
4✔
759
                        continue 2;
4✔
760
                    }
761
                }
762
            }
763
        }
764
        // Remove dev dependencies, if indicated.
765
        if (!$config->shouldUpdateDevDependencies()) {
151✔
766
            $this->log('Removing dev dependencies from updates since the option update_dev_dependencies is disabled');
3✔
767
            $filterer = DevDepsOnlyFilterer::create($composer_lock_after_installing, $composer_json_data);
3✔
768
            $data = $filterer->filter($data);
3✔
769
        }
770
        foreach ($data as $delta => $item) {
151✔
771
            // Also unset those that are in an unexpected format. A new thing seen in the wild has been this:
772
            // {
773
            //    "name": "symfony/css-selector",
774
            //    "version": "v2.8.49",
775
            //    "description": "Symfony CssSelector Component"
776
            // }
777
            // They should ideally include a latest version and latest status.
778
            if (!isset($item->latest) || !isset($item->{'latest-status'})) {
139✔
779
                unset($data[$delta]);
×
780
            } else {
781
                // If a package is abandoned, we do not really want to know. Since we can't update it anyway.
782
                if (isset($item->version) && ($item->latest === $item->version || $item->{'latest-status'} === 'up-to-date')) {
139✔
783
                    unset($data[$delta]);
3✔
784
                }
785
            }
786
        }
787
        if (empty($data)) {
151✔
788
            $this->log('No updates found');
14✔
789
            $this->cleanUp();
14✔
790
            return;
14✔
791
        }
792
        // Try to log what updates are found.
793
        $this->log('The following updates were found:');
137✔
794
        $updates_string = '';
137✔
795
        foreach ($data as $delta => $item) {
137✔
796
            $updates_string .= sprintf(
137✔
797
                "%s: %s installed, %s available (type %s)\n",
137✔
798
                $item->name,
137✔
799
                $item->version,
137✔
800
                $item->latest,
137✔
801
                $item->{'latest-status'}
137✔
802
            );
137✔
803
        }
804
        $this->log($updates_string, Message::UPDATE, [
137✔
805
            'packages' => $data,
137✔
806
        ]);
137✔
807
        // Try to see if we have already dealt with this (i.e already have a branch for all the updates.
808
        $branch_user = $this->forkUser;
137✔
809
        if ($this->isPrivate) {
137✔
810
            $branch_user = $user_name;
136✔
811
        }
812
        $branch_slug = new Slug();
137✔
813
        $branch_slug->setProvider('github.com');
137✔
814
        $branch_slug->setUserName($branch_user);
137✔
815
        $branch_slug->setUserRepo($user_repo);
137✔
816
        $branches_flattened = [];
137✔
817
        $prs_named = [];
137✔
818
        $default_base = null;
137✔
819
        try {
820
            if ($default_base_upstream = $this->privateClient->getDefaultBase($this->slug, $default_branch)) {
137✔
821
                $default_base = $default_base_upstream;
134✔
822
            }
823
            $prs_named = $this->privateClient->getPrsNamed($this->slug);
137✔
824
            // These can fail if we have not yet created a fork, and the repo is public. That is why we have them at the
825
            // end of this try/catch, so we can still know the default base for the original repo, and its pull
826
            // requests.
827
            if (!$default_base) {
137✔
828
                $default_base = $this->getPrClient()->getDefaultBase($branch_slug, $default_branch);
3✔
829
            }
830
            $branches_flattened = $this->getPrClient()->getBranchesFlattened($branch_slug);
137✔
831
        } catch (RuntimeException $e) {
×
832
            // Safe to ignore.
833
            $this->log('Had a runtime exception with the fetching of branches and Prs: ' . $e->getMessage());
×
834
        }
835
        if ($default_base && $default_branch) {
137✔
836
            $this->log(sprintf('Current commit SHA for %s is %s', $default_branch, $default_base));
134✔
837
        }
838
        $is_allowed_out_of_date_pr = [];
137✔
839
        $one_pr_per_dependency = $config->shouldUseOnePullRequestPerPackage();
137✔
840
        foreach ($data as $delta => $item) {
137✔
841
            $branch_name = Helpers::createBranchName($item, $one_pr_per_dependency, $config);
137✔
842
            if (in_array($branch_name, $branches_flattened)) {
137✔
843
                // Is there a PR for this?
844
                if (array_key_exists($branch_name, $prs_named)) {
23✔
845
                    $this->countPR($item->name);
20✔
846
                    if (!$default_base && !$one_pr_per_dependency) {
20✔
847
                        $this->log(sprintf('Skipping %s because a pull request already exists', $item->name), Message::PR_EXISTS, [
1✔
848
                            'package' => $item->name,
1✔
849
                        ]);
1✔
850
                        unset($data[$delta]);
1✔
851
                        $this->closeOutdatedPrsForPackage($item->name, $item->version, $config, $prs_named[$branch_name]['number'], $prs_named, $default_branch);
1✔
852
                    }
853
                    // Is the pr up to date?
854
                    if ($prs_named[$branch_name]['base']['sha'] == $default_base) {
20✔
855
                        // Create a fake "post-update-data" object.
856
                        $fake_post_update = (object) [
7✔
857
                            'version' => $item->latest,
7✔
858
                        ];
7✔
859
                        $security_update = false;
7✔
860
                        $package_name_in_composer_json = $item->name;
7✔
861
                        try {
862
                            $package_name_in_composer_json = Helpers::getComposerJsonName($composer_json_data, $item->name, $this->composerJsonDir);
7✔
863
                        } catch (\Exception $e) {
×
864
                            // If this was a package that we somehow got because we have allowed to update other than direct
865
                            // dependencies we can avoid re-throwing this.
866
                            if ($config->shouldCheckDirectOnly()) {
×
867
                                throw $e;
×
868
                            }
869
                            // Taking a risk :o.
870
                            $package_name_in_composer_json = $item->name;
×
871
                        }
872
                        if (isset($security_alerts[$package_name_in_composer_json])) {
7✔
873
                            $security_update = true;
3✔
874
                        }
875
                        // If the title does not match, it means either has there arrived a security issue for the
876
                        // update (new title), or we are doing "one-per-dependency", and the title should be something
877
                        // else with this new update. Either way, we want to continue this. Continue in this context
878
                        // would mean, we want to keep this for update checking still, and not unset it from the update
879
                        // array. This will mean it will probably get an updated title later.
880
                        if ($prs_named[$branch_name]['title'] != $this->getPrParamsCreator()->createTitle($item, $fake_post_update, $security_update)) {
7✔
881
                            $this->log(sprintf('Updating the PR of %s since the computed title does not match the title.', $item->name), Message::MESSAGE);
3✔
882
                            continue;
3✔
883
                        }
884
                        $context = [
4✔
885
                            'package' => $item->name,
4✔
886
                        ];
4✔
887
                        if (!empty($prs_named[$branch_name]['html_url'])) {
4✔
888
                            $context['url'] = $prs_named[$branch_name]['html_url'];
×
889
                        }
890
                        $this->log(sprintf('Skipping %s because a pull request already exists', $item->name), Message::PR_EXISTS, $context);
4✔
891
                        $this->closeOutdatedPrsForPackage($item->name, $item->version, $config, $prs_named[$branch_name]['number'], $prs_named, $default_branch);
4✔
892
                        unset($data[$delta]);
4✔
893
                    } else {
894
                        $is_allowed_out_of_date_pr[] = $item->name;
13✔
895
                    }
896
                }
897
            }
898
        }
899
        if ($config->shouldUpdateIndirectWithDirect()) {
137✔
900
            $this->log('Config suggested we should update indirect with direct. Altering the update data based on this');
6✔
901
            $filterer = IndirectWithDirectFilterer::create($composer_lock_after_installing, $composer_json_data);
6✔
902
            $data = $filterer->filter($data);
6✔
903
        }
904
        if (!count($data)) {
137✔
905
            $this->log('No updates that have not already been pushed.');
3✔
906
            $this->cleanUp();
3✔
907
            return;
3✔
908
        }
909

910
        // Unshallow the repo, for syncing it.
911
        $this->execCommand(['git', 'pull', '--unshallow'], false, 600);
134✔
912
        // If the repo is private, we need to push directly to the repo.
913
        if (!$this->isPrivate) {
134✔
914
            $this->preparePrClient();
1✔
915
            $this->log('Creating fork to ' . $this->forkUser);
1✔
916
            $this->client->createFork($user_name, $user_repo, $this->forkUser);
1✔
917
        }
918
        $update_type = self::UPDATE_INDIVIDUAL;
134✔
919
        if ($config->shouldAlwaysUpdateAll()) {
134✔
920
            $update_type = self::UPDATE_ALL;
20✔
921
        }
922
        $this->log('Config suggested update type ' . $update_type);
134✔
923
        if ($this->project && $this->project->shouldUpdateAll()) {
134✔
924
            // Only log this if this might end up being surprising. I mean override all with all. So what?
925
            if ($update_type === self::UPDATE_INDIVIDUAL) {
×
926
                $this->log('Override of update type from project data. Probably meaning first run, allowed update all');
×
927
            }
928
            $update_type = self::UPDATE_ALL;
×
929
        }
930
        switch ($update_type) {
931
            case self::UPDATE_INDIVIDUAL:
134✔
932
                $updater = new IndividualUpdater();
114✔
933
                $updater->setLogger($this->logger);
114✔
934
                $updater->setCWD($this->getCwd());
114✔
935
                $updater->setExecuter($this->executer);
114✔
936
                $updater->setPrCounter($this->getPrCounter());
114✔
937
                $updater->setComposerJsonDir($this->composerJsonDir);
114✔
938
                $updater->setMessageFactory($this->messageFactory);
114✔
939
                $updater->setClient($this->getPrClient());
114✔
940
                $updater->setIsPrivate($this->isPrivate);
114✔
941
                $updater->setSlug($this->slug);
114✔
942
                $updater->setAuthentication($this->untouchedUserToken);
114✔
943
                $updater->setAssigneesAllowed($this->assigneesAllowed);
114✔
944
                if ($this->forkUser) {
114✔
945
                    $updater->setForkUser($this->forkUser);
1✔
946
                }
947
                $updater->setTmpDir($this->tmpDir);
114✔
948
                if ($this->project) {
114✔
949
                    $updater->setProjectData($this->project);
114✔
950
                }
951
                $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);
114✔
952
                break;
114✔
953

954
            case self::UPDATE_ALL:
20✔
955
                $this->handleUpdateAll($initial_composer_lock_data, $composer_lock_after_installing, $security_alerts, $config, $default_base, $default_branch, $prs_named);
20✔
956
                break;
20✔
957
        }
958
        // Clean up.
959
        $this->cleanUp();
134✔
960
    }
961

962
    protected function getPrParamsCreator()
963
    {
964
        if (!$this->prParamsCreator instanceof PrParamsCreator) {
27✔
965
            $this->prParamsCreator = new PrParamsCreator($this->messageFactory, $this->project);
27✔
966
        }
967
        return $this->prParamsCreator;
27✔
968
    }
969

970
    protected function ensureFreshConfig(\stdClass $composer_json_data) : Config
971
    {
972
        return Config::createFromComposerDataInPath($composer_json_data, sprintf('%s/%s', $this->composerJsonDir, 'composer.json'));
158✔
973
    }
974

975
    protected function handleUpdateAll($initial_composer_lock_data, $composer_lock_after_installing, $alerts, Config $config, $default_base, $default_branch, $prs_named)
976
    {
977
        // We are going to hack an item here. We want the package to be "all" and the versions to be blank.
978
        $item = (object) [
20✔
979
            'name' => 'violinist-all',
20✔
980
            'version' => '',
20✔
981
            'latest' => '',
20✔
982
        ];
20✔
983
        $branch_name = Helpers::createBranchName($item, false, $config);
20✔
984
        $pr_params = [];
20✔
985
        $security_update = false;
20✔
986
        try {
987
            $this->switchBranch($branch_name);
20✔
988
            $status = $this->execCommand(['composer', 'update']);
20✔
989
            if ($status) {
20✔
990
                throw new NotUpdatedException('Composer update command exited with status code ' . $status);
×
991
            }
992
            // Now let's find out what has actually been updated.
993
            $new_lock_contents = json_decode(file_get_contents($this->composerJsonDir . '/composer.lock'));
20✔
994
            $comparer = new LockDataComparer($composer_lock_after_installing, $new_lock_contents);
20✔
995
            $list = $comparer->getUpdateList();
20✔
996
            if (empty($list)) {
20✔
997
                // That's too bad. Let's throw an exception for this.
998
                throw new NotUpdatedException('No updates detected after running composer update');
×
999
            }
1000
            // Now see if any of the packages updated was in the alerts.
1001
            foreach ($list as $value) {
20✔
1002
                if (empty($alerts[$value->getPackageName()])) {
20✔
1003
                    continue;
16✔
1004
                }
1005
                $security_update = true;
4✔
1006
            }
1007
            $this->log('Successfully ran command composer update for all packages');
20✔
1008
            $title = 'Update all composer dependencies';
20✔
1009
            if ($security_update) {
20✔
1010
                // @todo: Use message factory and package.
1011
                $title = sprintf('[SECURITY] %s', $title);
4✔
1012
            }
1013
            // We can do this, since the body creates a title, which it does not use. This is only used for the title.
1014
            // Which, again, we do not use.
1015
            $fake_item = $fake_post = (object) [
20✔
1016
                'name' => 'all',
20✔
1017
                'version' => '0.0.0',
20✔
1018
            ];
20✔
1019
            $body = $this->getPrParamsCreator()->createBody($fake_item, $fake_post, null, $security_update, $list);
20✔
1020
            $pr_params = $this->getPrParamsCreator()->getPrParams($this->forkUser, $this->isPrivate, $this->getSlug(), $branch_name, $body, $title, $default_branch, $config);
20✔
1021
            // OK, so... If we already have a branch named the name we are about to use. Is that one a branch
1022
            // containing all the updates we now got? And is it actually up to date with the target branch? Of course,
1023
            // if there is no such branch, then we will happily push it.
1024
            if (!empty($prs_named[$branch_name])) {
20✔
1025
                $up_to_date = false;
10✔
1026
                if (!empty($prs_named[$branch_name]['base']['sha']) && $prs_named[$branch_name]['base']['sha'] == $default_base) {
10✔
1027
                    $up_to_date = true;
3✔
1028
                }
1029
                $should_update = Helpers::shouldUpdatePr($branch_name, $pr_params, $prs_named);
10✔
1030
                if (!$should_update && $up_to_date) {
10✔
1031
                    // Well well well. Let's not push this branch over and over, shall we?
1032
                    $this->log(sprintf('The branch %s with all updates is already up to date. Aborting the PR update', $branch_name));
1✔
1033
                    return;
1✔
1034
                }
1035
            }
1036
            $this->commitFilesForAll($config);
19✔
1037
            $this->pushCode($branch_name, $default_base, $initial_composer_lock_data);
19✔
1038
            $pullRequest = $this->createPullrequest($pr_params);
19✔
1039
            if (!empty($pullRequest['html_url'])) {
14✔
1040
                $this->log($pullRequest['html_url'], Message::PR_URL, [
4✔
1041
                    'package' => 'all',
4✔
1042
                ]);
4✔
1043
                $this->handleAutomerge($config, $pullRequest, $security_update);
14✔
1044
            }
1045
        } catch (ValidationFailedException $e) {
5✔
1046
            // @todo: Do some better checking. Could be several things, this.
1047
            $this->handlePossibleUpdatePrScenario($e, $branch_name, $pr_params, $prs_named, $config, $security_update);
4✔
1048
        } catch (\Gitlab\Exception\RuntimeException $e) {
1✔
1049
            $this->handlePossibleUpdatePrScenario($e, $branch_name, $pr_params, $prs_named, $config, $security_update);
1✔
1050
        } catch (NotUpdatedException $e) {
×
1051
            $this->log($this->getLastStdOut());
×
1052
            $this->log($this->getLastStdErr());
×
1053
            $not_updated_context = [
×
1054
                'package' => sprintf('all:%s', $default_base),
×
1055
            ];
×
1056
            $this->log("Could not update all dependencies with composer update", Message::NOT_UPDATED, $not_updated_context);
×
1057
        } catch (\Throwable $e) {
×
1058
            $this->log('Caught exception while running update all: ' . $e->getMessage());
×
1059
        }
1060
    }
1061

1062
    protected function commitFilesForAll(Config $config)
1063
    {
1064
        $this->cleanRepoForCommit();
19✔
1065
        $creator = $this->getCommitCreator($config);
19✔
1066
        $msg = $creator->generateMessageFromString('Update all dependencies');
19✔
1067
        $this->commitFiles($msg);
19✔
1068
    }
1069

1070
    protected function handlePossibleUpdatePrScenario(\Exception $e, $branch_name, $pr_params, $prs_named, Config $config, $security_update = false)
1071
    {
1072
        $this->log('Had a problem with creating the pull request: ' . $e->getMessage(), 'error');
5✔
1073
        if (Helpers::shouldUpdatePr($branch_name, $pr_params, $prs_named)) {
5✔
1074
            $this->log('Will try to update the PR based on settings.');
5✔
1075
            $this->getPrClient()->updatePullRequest($this->slug, $prs_named[$branch_name]['number'], $pr_params);
5✔
1076
        }
1077
        if (!empty($prs_named[$branch_name])) {
5✔
1078
            $this->handleAutoMerge($config, $prs_named[$branch_name], $security_update);
5✔
1079
            $this->handleLabels($config, $prs_named[$branch_name], $security_update);
5✔
1080
        }
1081
    }
1082

1083
    protected function handleLabels(Config $config, $pullRequest, $security_update = false) : void
1084
    {
1085
        $labels_allowed = false;
5✔
1086
        $labels_allowed_roles = [
5✔
1087
            'agency',
5✔
1088
            'enterprise',
5✔
1089
        ];
5✔
1090
        if ($this->project && $this->project->getRoles()) {
5✔
1091
            foreach ($this->project->getRoles() as $role) {
2✔
1092
                if (in_array($role, $labels_allowed_roles)) {
2✔
1093
                    $labels_allowed = true;
2✔
1094
                }
1095
            }
1096
        }
1097
        if (!$labels_allowed) {
5✔
1098
            return;
3✔
1099
        }
1100
        Helpers::handleLabels($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
2✔
1101
    }
1102

1103
    protected function handleAutoMerge(Config $config, $pullRequest, $security_update = false) : void
1104
    {
1105
        Helpers::handleAutoMerge($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
9✔
1106
    }
1107

1108
    /**
1109
     * Get the messages that are logged.
1110
     *
1111
     * @return \eiriksm\CosyComposer\Message[]
1112
     *   The logged messages.
1113
     */
1114
    public function getOutput()
1115
    {
1116
        $msgs = [];
97✔
1117
        if (!$this->logger instanceof ArrayLogger) {
97✔
1118
            return $msgs;
×
1119
        }
1120
        /** @var ArrayLogger $my_logger */
1121
        $my_logger = $this->logger;
97✔
1122
        foreach ($my_logger->get() as $message) {
97✔
1123
            $msg = $message['message'];
97✔
1124
            if (!$msg instanceof Message && is_string($msg)) {
97✔
1125
                $msg = new Message($msg);
7✔
1126
            }
1127
            $msg->setContext($message['context']);
97✔
1128
            if (isset($message['context']['command'])) {
97✔
1129
                $msg = new Message($msg->getMessage(), Message::COMMAND);
72✔
1130
                $msg->setContext($message['context']);
72✔
1131
            }
1132
            $msgs[] = $msg;
97✔
1133
        }
1134
        return $msgs;
97✔
1135
    }
1136

1137
    /**
1138
     * Cleans up after the run.
1139
     */
1140
    private function cleanUp()
1141
    {
1142
        // Run composer install again, so we can get rid of newly installed updates for next run.
1143
        $this->execCommand(['composer', 'install', '--no-ansi', '-n'], false, 1200);
153✔
1144
        $this->chdir('/tmp');
153✔
1145
        $this->log('Cleaning up after update check.');
153✔
1146
        $this->execCommand(['rm', '-rf', $this->tmpDir], false, 300);
153✔
1147
        if (file_exists('/usr/local/bin/composer.bak')) {
153✔
1148
            rename('/usr/local/bin/composer.bak', '/usr/local/bin/composer');
×
1149
        }
1150
    }
1151

1152
    /**
1153
     * Executes a command.
1154
     */
1155
    protected function execCommand(array $command, $log = true, $timeout = 120, $env = [])
1156
    {
1157
        $this->executer->setCwd($this->getCwd());
162✔
1158
        return $this->executer->executeCommand($command, $log, $timeout, $env);
162✔
1159
    }
1160

1161
    /**
1162
     * Log a message.
1163
     *
1164
     * @param string $message
1165
     */
1166
    protected function log($message, $type = 'message', $context = [])
1167
    {
1168

1169
        $this->getLogger()->log('info', new Message($message, $type), $context);
162✔
1170
    }
1171

1172
    protected function attachDrupalAdvisories(array &$alerts)
1173
    {
1174
        // Also though. If the only alert we have is for the package with
1175
        // literally "drupal/core" we need to make sure it's attached to the
1176
        // other names as well.
1177
        $known_names = [
154✔
1178
            'drupal/core-recommended',
154✔
1179
            'drupal/core-composer-scaffold',
154✔
1180
            'drupal/core-project-message',
154✔
1181
            'drupal/core',
154✔
1182
            'drupal/drupal',
154✔
1183
        ];
154✔
1184
        if (!empty($alerts['drupal/core'])) {
154✔
1185
            foreach ($known_names as $known_name) {
3✔
1186
                if (!empty($alerts[$known_name])) {
3✔
1187
                    continue;
3✔
1188
                }
1189
                $alerts[$known_name] = $alerts['drupal/core'];
3✔
1190
            }
1191
        }
1192
        if (!$this->lockFileContents) {
154✔
1193
            return;
20✔
1194
        }
1195
        $data = ComposerLockData::createFromString(json_encode($this->lockFileContents));
134✔
1196
        try {
1197
            $drupal = $data->getPackageData('drupal/core');
134✔
1198
            // Now see if a newer version is available, and if it is a security update.
1199
            $endpoint = 'current';
28✔
1200
            $version_parts = explode('.', $drupal->version);
28✔
1201
            $major_version = $version_parts[0];
28✔
1202
            // Only 7.x and 8.x use their own endpoint,
1203
            if (in_array($major_version, ['7', '8'])) {
28✔
1204
                $endpoint = $major_version . '.x';
9✔
1205
            }
1206
            if ((int) $major_version < 7) {
28✔
1207
                throw new \Exception(sprintf('Drupal version %s is too old to check for security updates using drupal.org endpoint', $major_version));
×
1208
            }
1209
            $client = $this->getHttpClient();
28✔
1210
            $url = sprintf('https://updates.drupal.org/release-history/drupal/%s', $endpoint);
28✔
1211
            $request = new Request('GET', $url);
28✔
1212
            $response = $client->sendRequest($request);
28✔
1213
            $data = $response->getBody()->getContents();
28✔
1214
            $xml = @simplexml_load_string($data);
28✔
1215
            if (!$xml) {
28✔
1216
                return;
×
1217
            }
1218
            if (empty($xml->releases->release)) {
28✔
1219
                return;
11✔
1220
            }
1221
            $drupal_version_array = explode('.', $drupal->version);
17✔
1222
            $active_branch = sprintf('%s.%s', $drupal_version_array[0], $drupal_version_array[1]);
17✔
1223
            $supported_branches = explode(',', (string) $xml->supported_branches);
17✔
1224
            $is_supported = false;
17✔
1225
            foreach ($supported_branches as $branch) {
17✔
1226
                if (strpos($branch, $active_branch) === 0) {
17✔
1227
                    $is_supported = true;
7✔
1228
                }
1229
            }
1230
            foreach ($xml->releases->release as $release) {
17✔
1231
                if (empty($release->version)) {
17✔
1232
                    continue;
×
1233
                }
1234
                if (empty($release->terms) || empty($release->terms->term)) {
17✔
1235
                    continue;
8✔
1236
                }
1237
                $version = (string) $release->version;
17✔
1238
                // If they are not on the same branch, then let's skip it as well.
1239
                if ($endpoint !== '7.x') {
17✔
1240
                    if ($is_supported && strpos($version, $active_branch) !== 0) {
15✔
1241
                        continue;
5✔
1242
                    }
1243
                }
1244
                if (version_compare($version, $drupal->version) !== 1) {
17✔
1245
                    continue;
8✔
1246
                }
1247
                $is_sec = false;
11✔
1248
                foreach ($release->terms->term as $item) {
11✔
1249
                    $type = (string) $item->value;
11✔
1250
                    if ($type === 'Security update') {
11✔
1251
                        $is_sec = true;
11✔
1252
                    }
1253
                }
1254
                if (!$is_sec) {
11✔
1255
                    continue;
7✔
1256
                }
1257
                if (strpos($release->version, $major_version) !== 0) {
11✔
1258
                    // You know what. We must be checking version 10.x against
1259
                    // version 9.x. Not ideal, is it? Makes for some false
1260
                    // positives (or rather negatives, I guess).
1261
                    continue;
4✔
1262
                }
1263
                $this->log('Found a security update in the update XML. Will populate advisories from this, if not already set.');
9✔
1264
                foreach ($known_names as $known_name) {
9✔
1265
                    if (!empty($alerts[$known_name])) {
9✔
1266
                        continue;
×
1267
                    }
1268
                    $alerts[$known_name] = [
9✔
1269
                        'version' => $version,
9✔
1270
                    ];
9✔
1271
                }
1272
                break;
9✔
1273
            }
1274
        } catch (\Throwable $e) {
106✔
1275
            // Totally fine.
1276
        }
1277
    }
1278

1279
   /**
1280
    * Changes to a different directory.
1281
    */
1282
    private function chdir($dir)
1283
    {
1284
        if (!file_exists($dir)) {
161✔
1285
            return false;
1✔
1286
        }
1287
        $this->setCWD($dir);
160✔
1288
        return true;
160✔
1289
    }
1290

1291
    protected function setCWD($dir)
1292
    {
1293
        $this->cwd = $dir;
160✔
1294
    }
1295

1296

1297
    /**
1298
     * @return string
1299
     */
1300
    public function getTmpDir()
1301
    {
1302
        return $this->tmpDir;
×
1303
    }
1304

1305
    /**
1306
     * @param string $tmpDir
1307
     */
1308
    public function setTmpDir($tmpDir)
1309
    {
1310
        $this->tmpDir = $tmpDir;
176✔
1311
    }
1312

1313
    /**
1314
     * @param Slug $slug
1315
     *
1316
     * @return ProviderInterface
1317
     */
1318
    private function getClient(Slug $slug)
1319
    {
1320
        if (!$this->providerFactory instanceof ProviderFactory) {
158✔
1321
            $this->setProviderFactory(new ProviderFactory());
×
1322
        }
1323
        return $this->providerFactory->createFromHost($slug, $this->urlArray);
158✔
1324
    }
1325

1326
    /**
1327
     * Get the client we should use for the PRs we create.
1328
     */
1329
    private function getPrClient() : ProviderInterface
1330
    {
1331
        if ($this->isPrivate) {
137✔
1332
            return $this->privateClient;
136✔
1333
        }
1334
        $this->preparePrClient();
1✔
1335
        $this->client->authenticate($this->userToken, null);
1✔
1336
        return $this->client;
1✔
1337
    }
1338

1339
    private function preparePrClient() : void
1340
    {
1341
        // We are only allowed to use the public github wrapper if the magic env
1342
        // for this is set, which it will be in jobs coming from the SaaS
1343
        // offering, but not for self hosted.
1344
        $this->logger->log('info', new Message('Checking if we should enable the public github wrapper', Message::COMMAND));
1✔
1345
        if (!self::shouldEnablePublicGithubWrapper()) {
1✔
1346
            // The client should hopefully be fully prepared.
1347
            $this->logger->log('info', new Message('Public github wrapper not enabled', Message::COMMAND));
×
1348
            return;
×
1349
        }
1350
        if (!$this->isPrivate) {
1✔
1351
            $this->logger->log('info', new Message('Public github wrapper enabled', Message::COMMAND));
1✔
1352
            if (!$this->client instanceof PublicGithubWrapper) {
1✔
1353
                $this->client = new PublicGithubWrapper(new Client());
×
1354
            }
1355
            $this->client->setUserToken($this->userToken);
1✔
1356
            $this->client->setUrlFromTokenUrl($this->tokenUrl);
1✔
1357
            $this->client->setProject($this->project);
1✔
1358
        }
1359
    }
1360

1361
    private function checkDefaultBranch() : ?string
1362
    {
1363
        $default_branch = null;
157✔
1364
        try {
1365
            $default_branch = $this->privateClient->getDefaultBranch($this->slug);
157✔
1366
        } catch (\Throwable $e) {
×
1367
            // Could be a personal access token.
1368
            if (!method_exists($this->privateClient, 'authenticatePersonalAccessToken')) {
×
1369
                throw $e;
×
1370
            }
1371
            try {
1372
                $this->privateClient->authenticatePersonalAccessToken($this->userToken, null);
×
1373
                $default_branch = $this->privateClient->getDefaultBranch($this->slug);
×
1374
            } catch (\Throwable $other_exception) {
×
1375
                throw $e;
×
1376
            }
1377
        }
1378
        return $default_branch;
157✔
1379
    }
1380

1381
    private function checkPrivateStatus() : bool
1382
    {
1383
        if (!self::shouldEnablePublicGithubWrapper()) {
157✔
1384
            return true;
156✔
1385
        }
1386
        try {
1387
            return $this->privateClient->repoIsPrivate($this->slug);
1✔
1388
        } catch (\Throwable $e) {
×
1389
            // Could be a personal access token.
1390
            if (!method_exists($this->privateClient, 'authenticatePersonalAccessToken')) {
×
1391
                return true;
×
1392
            }
1393
            try {
1394
                $this->privateClient->authenticatePersonalAccessToken($this->userToken, null);
×
1395
                return $this->privateClient->repoIsPrivate($this->slug);
×
1396
            } catch (\Throwable $other_exception) {
×
1397
                throw $e;
×
1398
            }
1399
        }
1400
    }
1401

1402
    protected function getlockFileContents()
1403
    {
1404
        return $this->lockFileContents;
19✔
1405
    }
1406

1407
    public static function shouldEnablePublicGithubWrapper() : bool
1408
    {
1409
        return !empty(getenv('USE_GITHUB_PUBLIC_WRAPPER'));
162✔
1410
    }
1411
}
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