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

code/tasks/CheckComposerUpdatesTask.php (3 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
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)
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;
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) {
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) {
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());
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