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

eiriksm / cosy-composer / 16469015360

23 Jul 2025 11:10AM UTC coverage: 86.578% (-0.4%) from 86.939%
16469015360

push

github

web-flow
More accurate parsing of named prs  (#427)

82 of 102 new or added lines in 9 files covered. (80.39%)

1 existing line in 1 file now uncovered.

2019 of 2332 relevant lines covered (86.58%)

44.84 hits per line

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

83.74
/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

44
    const UPDATE_ALL = 'update_all';
45

46
    const UPDATE_INDIVIDUAL = 'update_individual';
47

48
    private $urlArray;
49

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

234

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

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

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

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

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

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

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

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

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

379
    protected function closeOutdatedPrsForPackage($package_name, $current_version, Config $config, $pr_id, NamedPrs $prs_named_obj, $default_branch)
380
    {
381
        $prs_for_package = $prs_named_obj->getPrsFromPackage($package_name);
6✔
382
        foreach ($prs_for_package as $pr) {
6✔
383
            if (!empty($pr["base"]["ref"])) {
3✔
384
                // The base ref should be what we are actually using for merge requests.
NEW
385
                if ($pr["base"]["ref"] !== $default_branch) {
×
386
                    continue;
×
387
                }
388
            }
389
            // We don't want to close this exact PR do we?
390
            if ((string) $pr['number'] === (string) $pr_id) {
3✔
391
                continue;
3✔
392
            }
393
            $comment = $this->messageFactory->getPullRequestClosedMessage($pr_id);
3✔
394
            $pr_number = $pr['number'];
3✔
395
            $this->getLogger()->log('info', new Message("Trying to close PR number $pr_number since it has been superseded by $pr_id"));
3✔
396
            try {
397
                $this->getPrClient()->closePullRequestWithComment($this->slug, $pr_number, $comment);
3✔
398
                $this->getLogger()->log('info', new Message("Successfully closed PR $pr_number"));
3✔
399
            } catch (\Throwable $e) {
×
400
                $msg = $e->getMessage();
×
401
                $this->getLogger()->log('error', new Message("Caught an exception trying to close pr $pr_number. The message was '$msg'"));
×
402
            }
403
        }
404
    }
405

406
    public function setViolinistHostname(string $hostname)
407
    {
408
        $this->hostName = $hostname;
185✔
409
    }
410

411
    public function getLocalAdapterForTempDir(string $directory) : FilesystemAdapter
412
    {
413
        $this->setTmpDir($directory);
171✔
414
        $composer_json_dir = $this->tmpDir;
171✔
415
        if ($this->project && $this->project->getComposerJsonDir()) {
171✔
416
            $composer_json_dir = sprintf('%s/%s', $this->tmpDir, $this->project->getComposerJsonDir());
×
417
        }
418
        $this->composerJsonDir = $composer_json_dir;
171✔
419
        return new LocalFilesystemAdapter($this->composerJsonDir);
171✔
420
    }
421

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

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

565
        $this->logger->log('info', new Message('Checking private status of repo', Message::COMMAND));
168✔
566
        $this->isPrivate = $this->checkPrivateStatus();
168✔
567
        $this->logger->log('info', new Message('Checking default branch of repo', Message::COMMAND));
168✔
568
        $default_branch = $this->checkDefaultBranch();
168✔
569

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

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

962
            case self::UPDATE_ALL:
20✔
963
                $this->handleUpdateAll($initial_composer_lock_data, $composer_lock_after_installing, $security_alerts, $config, $default_base, $default_branch, $prs_named);
20✔
964
                break;
20✔
965
        }
966
        // Clean up.
967
        $this->cleanUp();
145✔
968
    }
969

970
    protected function getPrParamsCreator()
971
    {
972
        if (!$this->prParamsCreator instanceof PrParamsCreator) {
36✔
973
            $this->prParamsCreator = new PrParamsCreator($this->messageFactory, $this->project);
36✔
974
        }
975
        return $this->prParamsCreator;
36✔
976
    }
977

978
    protected function ensureFreshConfig(\stdClass $composer_json_data) : Config
979
    {
980
        return Config::createFromComposerDataInPath($composer_json_data, sprintf('%s/%s', $this->composerJsonDir, 'composer.json'));
169✔
981
    }
982

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

1071
    protected function commitFilesForAll(Config $config)
1072
    {
1073
        $this->cleanRepoForCommit();
19✔
1074
        $creator = $this->getCommitCreator($config);
19✔
1075
        $msg = $creator->generateMessageFromString('Update all dependencies');
19✔
1076
        $this->commitFiles($msg);
19✔
1077
    }
1078

1079
    protected function handlePossibleUpdatePrScenario(\Exception $e, $branch_name, $pr_params, NamedPrs $prs_named, Config $config, $security_update = false)
1080
    {
1081
        $prs_named_array = $prs_named->getAllPrsNamed();
5✔
1082
        $this->log('Had a problem with creating the pull request: ' . $e->getMessage(), 'error');
5✔
1083
        if (Helpers::shouldUpdatePr($branch_name, $pr_params, $prs_named)) {
5✔
1084
            $this->log('Will try to update the PR based on settings.');
5✔
1085
            $this->getPrClient()->updatePullRequest($this->slug, $prs_named_array[$branch_name]['number'], $pr_params);
5✔
1086
        }
1087
        if (!empty($prs_named_array[$branch_name])) {
5✔
1088
            $this->handleAutoMerge($config, $prs_named_array[$branch_name], $security_update);
5✔
1089
            $this->handleLabels($config, $prs_named_array[$branch_name], $security_update);
5✔
1090
        }
1091
    }
1092

1093
    protected function handleLabels(Config $config, $pullRequest, $security_update = false) : void
1094
    {
1095
        $labels_allowed = false;
5✔
1096
        $labels_allowed_roles = [
5✔
1097
            'agency',
5✔
1098
            'enterprise',
5✔
1099
        ];
5✔
1100
        if ($this->project && $this->project->getRoles()) {
5✔
1101
            foreach ($this->project->getRoles() as $role) {
2✔
1102
                if (in_array($role, $labels_allowed_roles)) {
2✔
1103
                    $labels_allowed = true;
2✔
1104
                }
1105
            }
1106
        }
1107
        if (!$labels_allowed) {
5✔
1108
            return;
3✔
1109
        }
1110
        Helpers::handleLabels($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
2✔
1111
    }
1112

1113
    protected function handleAutoMerge(Config $config, $pullRequest, $security_update = false) : void
1114
    {
1115
        Helpers::handleAutoMerge($this->getPrClient(), $this->getLogger(), $this->slug, $config, $pullRequest, $security_update);
9✔
1116
    }
1117

1118
    /**
1119
     * Get the messages that are logged.
1120
     *
1121
     * @return \eiriksm\CosyComposer\Message[]
1122
     *   The logged messages.
1123
     */
1124
    public function getOutput()
1125
    {
1126
        $msgs = [];
106✔
1127
        if (!$this->logger instanceof ArrayLogger) {
106✔
1128
            return $msgs;
×
1129
        }
1130
        /** @var ArrayLogger $my_logger */
1131
        $my_logger = $this->logger;
106✔
1132
        foreach ($my_logger->get() as $message) {
106✔
1133
            $msg = $message['message'];
106✔
1134
            if (!$msg instanceof Message && is_string($msg)) {
106✔
1135
                $msg = new Message($msg);
7✔
1136
            }
1137
            $msg->setContext($message['context']);
106✔
1138
            if (isset($message['context']['command'])) {
106✔
1139
                $msg = new Message($msg->getMessage(), Message::COMMAND);
80✔
1140
                $msg->setContext($message['context']);
80✔
1141
            }
1142
            $msgs[] = $msg;
106✔
1143
        }
1144
        return $msgs;
106✔
1145
    }
1146

1147
    /**
1148
     * Cleans up after the run.
1149
     */
1150
    private function cleanUp()
1151
    {
1152
        // Run composer install again, so we can get rid of newly installed updates for next run.
1153
        $this->execCommand(['composer', 'install', '--no-ansi', '-n'], false, 1200);
164✔
1154
        $this->chdir('/tmp');
164✔
1155
        $this->log('Cleaning up after update check.');
164✔
1156
        $this->execCommand(['rm', '-rf', $this->tmpDir], false, 300);
164✔
1157
        if (file_exists('/usr/local/bin/composer.bak')) {
164✔
1158
            rename('/usr/local/bin/composer.bak', '/usr/local/bin/composer');
×
1159
        }
1160
    }
1161

1162
    /**
1163
     * Executes a command.
1164
     */
1165
    protected function execCommand(array $command, $log = true, $timeout = 120, $env = [])
1166
    {
1167
        $this->executer->setCwd($this->getCwd());
172✔
1168
        return $this->executer->executeCommand($command, $log, $timeout, $env);
172✔
1169
    }
1170

1171
    /**
1172
     * Log a message.
1173
     *
1174
     * @param string $message
1175
     */
1176
    protected function log($message, $type = 'message', $context = [])
1177
    {
1178

1179
        $this->getLogger()->log('info', new Message($message, $type), $context);
172✔
1180
    }
1181

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

1289
   /**
1290
    * Changes to a different directory.
1291
    */
1292
    private function chdir($dir)
1293
    {
1294
        if (!file_exists($dir)) {
171✔
1295
            return false;
×
1296
        }
1297
        $this->setCWD($dir);
171✔
1298
        return true;
171✔
1299
    }
1300

1301
    protected function setCWD($dir)
1302
    {
1303
        $this->cwd = $dir;
171✔
1304
    }
1305

1306

1307
    /**
1308
     * @return string
1309
     */
1310
    public function getTmpDir()
1311
    {
1312
        return $this->tmpDir;
×
1313
    }
1314

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

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

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

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

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

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

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