Completed
Push — master ( 979731...d0541a )
by Robbie
01:29
created

code/tasks/CheckComposerUpdatesTask.php (9 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Task which does the actual checking of updates
4
 *
5
 * Originally from https://github.com/XploreNet/silverstripe-composerupdates
6
 *
7
 * @author Matt Dwen
8
 * @license MIT
9
 */
10
class CheckComposerUpdatesTask extends BuildTask
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
11
{
12
    /**
13
     * @var string
14
     */
15
    protected $title = 'Composer update checker';
16
17
    /**
18
     * @var string
19
     */
20
    protected $description = 'Checks if any composer dependencies can be updated.';
21
22
    /**
23
     * Deserialized JSON from composer.lock
24
     *
25
     * @var object
26
     */
27
    protected $composerLock;
28
29
    /**
30
     * Minimum required stability defined in the site composer.json
31
     *
32
     * @var string
33
     */
34
    protected $minimumStability;
35
36
    /**
37
     * Whether or not to prefer stable packages
38
     *
39
     * @var bool
40
     */
41
    protected $preferStable;
42
43
    /**
44
     * Known stability values
45
     *
46
     * @var array
47
     */
48
    protected $stabilityOptions = array(
49
        'dev',
50
        'alpha',
51
        'beta',
52
        'rc',
53
        'stable'
54
    );
55
56
    /**
57
     * Whether to write all log messages or not
58
     *
59
     * @var bool
60
     */
61
    protected $extendedLogging = true;
62
63
    /**
64
     * Retrieve an array of primary composer dependencies from composer.json
65
     *
66
     * @return array
0 ignored issues
show
Should the return type not be null|array?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
67
     */
68
    protected function getPackages()
69
    {
70
        $composerPath = BASE_PATH . '/composer.json';
71
        if (!file_exists($composerPath)) {
72
            return null;
73
        }
74
75
        // Read the contents of composer.json
76
        $composerFile = file_get_contents($composerPath);
77
78
        // Parse the json
79
        $composerJson = json_decode($composerFile);
80
81
        ini_set('display_errors', 1);
82
        error_reporting(E_ALL);
83
84
        // Set the stability parameters
85
        $this->minimumStability = (isset($composerJson->{'minimum-stability'}))
86
            ? $composerJson->{'minimum-stability'}
87
            : 'stable';
88
89
        $this->preferStable = (isset($composerJson->{'prefer-stable'}))
90
            ? $composerJson->{'prefer-stable'}
91
            : true;
92
93
        $packages = array();
94
        foreach ($composerJson->require as $package => $version) {
95
            // Ensure there's a / in the name, probably not an addon with it
96
            if (!strpos($package, '/')) {
97
                continue;
98
            }
99
100
            $packages[] = $package;
101
        }
102
103
        return $packages;
104
    }
105
106
    /**
107
     * Return an array of all Composer dependencies from composer.lock
108
     *
109
     * @return array(package => hash)
0 ignored issues
show
The doc-type array(package could not be parsed: Expected "|" or "end of type", but got "(" at position 5. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
110
     */
111
    protected function getDependencies()
112
    {
113
        $composerPath = BASE_PATH . '/composer.lock';
114
        if (!file_exists($composerPath)) {
115
            return null;
116
        }
117
118
        // Read the contents of composer.json
119
        $composerFile = file_get_contents($composerPath);
120
121
        // Parse the json
122
        $dependencies = json_decode($composerFile);
123
124
        $packages = array();
125
126
        // Loop through the requirements
127
        foreach ($dependencies->packages as $package) {
128
            $packages[$package->name] = $package->version;
129
        }
130
131
        $this->composerLock = $dependencies;
132
133
        return $packages;
134
    }
135
136
    /**
137
     * Check if an available version is better than the current version,
138
     * considering stability requirements
139
     *
140
     * Returns FALSE if no update is available.
141
     * Returns the best available version if an update is available.
142
     *
143
     * @param string $currentVersion
144
     * @param string $availableVersions
145
     * @return bool|string
0 ignored issues
show
Should the return type not be boolean|string|integer?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
146
     */
147
    protected function hasUpdate($currentVersion, $availableVersions)
148
    {
149
        $currentVersion = strtolower($currentVersion);
150
151
        // Check there are some versions
152
        if (count($availableVersions) < 1) {
153
            return false;
154
        }
155
156
        // If this is dev-master, compare the hashes
157
        if ($currentVersion === 'dev-master') {
158
            return $this->hasUpdateOnDevMaster($availableVersions);
159
        }
160
161
        // Loop through each available version
162
        $currentStability = $this->getStability($currentVersion);
163
        $bestVersion = $currentVersion;
164
        $bestStability = $currentStability;
165
        $availableVersions = array_reverse($availableVersions, true);
166
        foreach ($availableVersions as $version => $details) {
167
            // Get the stability of the version
168
            $versionStability = $this->getStability($version);
169
170
            // Does this meet minimum stability
171
            if (!$this->isStableEnough($this->minimumStability, $versionStability)) {
172
                continue;
173
            }
174
175
            if ($this->preferStable) {
176
                // A simple php version compare rules out the dumb stuff
177
                if (version_compare($bestVersion, $version) !== -1) {
178
                    continue;
179
                }
180
            } else {
181
                // We're doing a straight version compare
182
                $pureBestVersion = $this->getPureVersion($bestVersion);
183
                $pureVersion = $this->getPureVersion($version);
184
185
                // Checkout the version
186
                $continue = false;
187
                switch (version_compare($pureBestVersion, $pureVersion)) {
188
                    case -1:
189
                        // The version is better, take it
190
                        break;
191
192
                    case 0:
193
                        // The version is the same.
194
                        // Do another straight version compare to rule out rc1 vs rc2 etc...
195
                        if ($bestStability == $versionStability) {
196
                            if (version_compare($bestVersion, $version) !== -1) {
197
                                $continue = true;
198
                                break;
199
                            }
200
                        }
201
                        break;
202
203
                    case 1:
204
                        // The version is worse, ignore it
205
                        $continue = true;
206
                        break;
207
                }
208
209
                if ($continue) {
210
                    continue;
211
                }
212
            }
213
214
            $bestVersion = $version;
215
            $bestStability = $versionStability;
216
        }
217
218
        if ($bestVersion !== $currentVersion || $bestStability !== $currentStability) {
219
            if ($bestStability === 'stable') {
220
                return $bestVersion;
221
            }
222
223
            return $bestVersion . '-' . $bestStability;
224
        }
225
226
        return false;
227
    }
228
229
    /**
230
     * Check the latest hash on the dev-master branch, and return it if different to the local hash
231
     *
232
     * FALSE is returned if the hash is the same.
233
     *
234
     * @param $availableVersions
235
     * @return bool|string
236
     */
237
    protected function hasUpdateOnDevMaster($availableVersions)
238
    {
239
        // Get the dev-master version
240
        $devMaster = $availableVersions['dev-master'];
241
242
        // Sneak the name of the package
243
        $packageName = $devMaster->getName();
244
245
        // Get the local package details
246
        $localPackage = $this->getLocalPackage($packageName);
247
248
        // What's the current hash?
249
        $localHash = $localPackage->source->reference;
250
251
        // What's the latest hash in the available versions
252
        $remoteHash = $devMaster->getSource()->getReference();
253
254
        // return either the new hash or false
255
        return ($localHash != $remoteHash) ? $remoteHash : false;
256
    }
257
258
    /**
259
     * Return details from composer.lock for a specific package
260
     *
261
     * @param string $packageName
262
     * @return object
263
     * @throws Exception if package cannot be found in composer.lock
264
     */
265
    protected function getLocalPackage($packageName)
266
    {
267
        foreach ($this->composerLock->packages as $package) {
268
            if ($package->name == $packageName) {
269
                return $package;
270
            }
271
        }
272
273
        throw new Exception('Cannot locate local package ' . $packageName);
274
    }
275
276
    /**
277
     * Retrieve the pure numerical version
278
     *
279
     * @param string $version
280
     * @return string|null
281
     */
282
    protected function getPureVersion($version)
283
    {
284
        $matches = array();
285
286
        preg_match("/^(\d+\\.)?(\d+\\.)?(\\*|\d+)/", $version, $matches);
287
288
        if (count($matches) > 0) {
289
            return $matches[0];
290
        }
291
292
        return null;
293
    }
294
295
    /**
296
     * Determine the stability of a given version
297
     *
298
     * @param string $version
299
     * @return string
300
     */
301
    protected function getStability($version)
302
    {
303
        $version = strtolower($version);
304
305
        foreach ($this->stabilityOptions as $option) {
306
            if (strpos($version, $option) !== false) {
307
                return $option;
308
            }
309
        }
310
311
        return 'stable';
312
    }
313
314
    /**
315
     * Return a numerical representation of a stability
316
     *
317
     * Higher is more stable
318
     *
319
     * @param string $stability
320
     * @return int
0 ignored issues
show
Should the return type not be integer|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
321
     * @throws Exception If the stability is unknown
322
     */
323
    protected function getStabilityIndex($stability)
324
    {
325
        $stability = strtolower($stability);
326
327
        $index = array_search($stability, $this->stabilityOptions, true);
328
329
        if ($index === false) {
330
            throw new Exception("Unknown stability: $stability");
331
        }
332
333
        return $index;
334
    }
335
336
    /**
337
     * Check if a stability meets a given minimum requirement
338
     *
339
     * @param $currentStability
340
     * @param $possibleStability
341
     * @return bool
342
     */
343
    protected function isStableEnough($currentStability, $possibleStability)
344
    {
345
        $minimumIndex = $this->getStabilityIndex($currentStability);
346
        $possibleIndex = $this->getStabilityIndex($possibleStability);
347
348
        return ($possibleIndex >= $minimumIndex);
349
    }
350
351
    /**
352
     * Record package details in the database
353
     *
354
     * @param string $package Name of the Composer Package
355
     * @param string $installed Currently installed version
356
     * @param string|boolean $latest The latest available version
357
     */
358
    protected function recordUpdate($package, $installed, $latest)
359
    {
360
        // Is there a record already for the package? If so find it.
361
        $packages = ComposerUpdate::get()->filter(array('Name' => $package));
362
363
        // if there is already one use it otherwise create a new data object
364
        if ($packages->count() > 0) {
365
            $update = $packages->first();
366
        } else {
367
            $update = new ComposerUpdate();
368
            $update->Name = $package;
0 ignored issues
show
The property Name does not exist on object<ComposerUpdate>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
369
        }
370
371
        // If installed is dev-master get the hash
372
        if ($installed === 'dev-master') {
373
            $localPackage = $this->getLocalPackage($package);
374
            $installed = $localPackage->source->reference;
375
        }
376
377
        // Set the new details and save it
378
        $update->Installed = $installed;
379
        $update->Available = $latest;
380
        $update->write();
381
    }
382
383
    /**
384
     * runs the actual steps to verify if there are updates available
385
     *
386
     * @param SS_HTTPRequest $request
387
     */
388
    public function run($request)
389
    {
390
        // Retrieve the packages
391
        $packages = $this->getPackages();
392
        $dependencies = $this->getDependencies();
393
394
        // Load the Packagist API
395
        $packagist = new Packagist\Api\Client();
396
397
        // run through the packages and check each for updates
398
        foreach ($packages as $package) {
0 ignored issues
show
The expression $packages of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
399
            // verify that we need to check this package.
400
            if (!isset($dependencies[$package])) {
401
                continue;
402
            } else {
403
                // get information about this package from packagist.
404
                try {
405
                    $latest = $packagist->get($package);
406
                } catch (Guzzle\Http\Exception\ClientErrorResponseException $e) {
0 ignored issues
show
The class Guzzle\Http\Exception\ClientErrorResponseException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
407
                    SS_Log::log($e->getMessage(), SS_Log::WARN);
408
                    continue;
409
                }
410
411
                // Check if there is a newer version
412
                $currentVersion = $dependencies[$package];
413
                $result = $this->hasUpdate($currentVersion, $latest->getVersions());
0 ignored issues
show
$latest->getVersions() is of type array<integer,object<Pac...esult\Package\Version>>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
414
415
                // Check if there is a newer version and if so record the update
416
                if ($result !== false) {
417
                    $this->recordUpdate($package, $currentVersion, $result);
418
                }
419
            }
420
        }
421
422
        // finished message
423
        $this->message('The task finished running. You can find the updated information in the database now.');
424
    }
425
426
    /**
427
     * prints a message during the run of the task
428
     *
429
     * @param string $text
430
     */
431
    protected function message($text)
432
    {
433
        if (!Director::is_cli()) {
434
            $text = '<p>' . $text . '</p>';
435
        }
436
437
        echo $text . PHP_EOL;
438
    }
439
}
440