Passed
Push — master ( 69b4a5...1004b6 )
by Ronan
06:49
created

Github::getBranchLink()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 10
ccs 0
cts 9
cp 0
rs 10
cc 1
nc 1
nop 2
crap 2
1
<?php
2
3
namespace App\Provider;
4
5
use App\Builder;
6
use App\Facades\Log;
7
use App\Facades\Settings;
8
use App\Model\Deployment;
9
use App\Model\Project;
10
use App\Provider\ProviderInterface;
11
use Closure;
12
use Exception;
13
use Ronanchilvers\Foundation\Config;
14
use Ronanchilvers\Utility\Str;
15
use RuntimeException;
16
use Symfony\Component\Process\Exception\ProcessFailedException;
17
use Symfony\Component\Process\Process;
18
use Symfony\Component\Yaml\Yaml;
19
20
/**
21
 * Github source control provider
22
 *
23
 * @author Ronan Chilvers <[email protected]>
24
 */
25
class Github implements ProviderInterface
26
{
27
    /**
28
     * @var string
29
     */
30
    protected $token;
31
32
    /**
33
     * @var string
34
     */
35
    protected $headUrl = 'https://api.github.com/repos/{repository}/git/refs/heads/{branch}';
36
37
    /**
38
     * @var string
39
     */
40
    protected $commitUrl = 'https://api.github.com/repos/{repository}/commits/{sha}';
41
42
    /**
43
     * @var string
44
     */
45
    protected $downloadUrl = 'https://api.github.com/repos/{repository}/tarball/{sha}';
46
47
    /**
48
     * @var string
49
     */
50
    protected $configUrl = 'https://api.github.com/repos/{repository}/contents/deploy.yaml?ref={sha}';
51
52
    /**
53
     * @var string
54
     */
55
    protected $repoUrl = 'https://github.com/{repository}';
56
57
    /**
58
     * @var string
59
     */
60
    protected $branchUrl = 'https://github.com/{repository}/tree/{branch}';
61
62
    /**
63
     * @var string
64
     */
65
    protected $shaUrl = 'https://github.com/{repository}/commit/{sha}';
66
67
    /**
68
     * Class constructor
69
     *
70
     * @param string $token
71
     * @author Ronan Chilvers <[email protected]>
72
     */
73
    public function __construct(string $token)
74
    {
75
        $this->token = $token;
76
    }
77
78
    /**
79
     * @author Ronan Chilvers <[email protected]>
80
     */
81
    public function getLabel()
82
    {
83
        return 'Github';
84
    }
85
86
    /**
87
     * @see \App\Provider\ProviderInterface::handles()
88
     */
89
    public function handles(Project $project)
90
    {
91
        return 'github' == $project->provider;
92
    }
93
94
    /**
95
     * Get a repository link for a given repository
96
     *
97
     * @param string $repository
98
     * @return string
99
     * @author Ronan Chilvers <[email protected]>
100
     */
101
    public function getRepositoryLink(string $repository)
102
    {
103
        $params = [
104
            'repository' => $repository,
105
        ];
106
107
        return Str::moustaches(
108
            $this->repoUrl,
109
            $params
110
        );
111
    }
112
113
    /**
114
     * Get a link to a repository branch
115
     *
116
     * @param string $repository
117
     * @param string $branch
118
     * @return string
119
     * @author Ronan Chilvers <[email protected]>
120
     */
121
    public function getBranchLink(string $repository, string $branch)
122
    {
123
        $params = [
124
            'repository' => $repository,
125
            'branch'     => $branch,
126
        ];
127
128
        return Str::moustaches(
129
            $this->branchUrl,
130
            $params
131
        );
132
    }
133
134
    /**
135
     * Get a link for a given repository and sha
136
     *
137
     * @param string $repository
138
     * @param string $sha
139
     * @return string
140
     * @author Ronan Chilvers <[email protected]>
141
     */
142
    public function getShaLink(string $repository, string $sha)
143
    {
144
        $params = [
145
            'repository' => $repository,
146
            'sha'        => $sha,
147
        ];
148
149
        return Str::moustaches(
150
            $this->shaUrl,
151
            $params
152
        );
153
    }
154
155
    /**
156
     * @see \App\Provider\ProviderInterface::getHeadInfo()
157
     */
158
    public function getHeadInfo(string $repository, string $branch, Closure $closure = null)
159
    {
160
        $params = [
161
            'repository' => $repository,
162
            'branch'     => $branch,
163
        ];
164
        $url = Str::moustaches(
165
            $this->headUrl,
166
            $params
167
        );
168
        $closure('info', 'Querying Github API for head commit data', "API URL : {$url}");
169
        $curl = $this->getCurlHandle($url);
170
        if (false === ($data = curl_exec($curl))) {
171
            $closure(
172
                'error',
173
                'Github API request failed',
174
                implode("\n", [
175
                    "API URL - {$url}",
176
                    "CURL Error - (" . curl_errno($curl) . ') ' . curl_error($curl)
177
                ])
178
            );
179
            throw new RuntimeException('CURL request failed - (' . curl_errno($curl) . ') ' . curl_error($curl));
180
        }
181
        $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
182
        curl_close($curl);
183
        if ($statusCode != 200) {
184
            $error = 'Unknown';
185
            if (is_string($data)) {
186
                $data  = json_decode($data, true);
187
                $error = $data['message'];
188
            }
189
            $closure(
190
                'error',
191
                'Error obtaining head info from Github',
192
                implode("\n", [
193
                    "URL - {$url}",
194
                    "Status code - {$statusCode}",
195
                    "Error - {$error}"
196
                ])
197
            );
198
            throw new RuntimeException('Github request failed - ' . $data['message']);
199
        }
200
        if (!$data = json_decode($data, true)) {
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type true; however, parameter $json of json_decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

200
        if (!$data = json_decode(/** @scrutinizer ignore-type */ $data, true)) {
Loading history...
201
            $closure(
202
                'error',
203
                'Unable to parse Github response JSON',
204
                "API URL : {$url}"
205
            );
206
            throw new RuntimeException('Invalid commit data for head');
207
        }
208
        $params['sha'] = $data['object']['sha'];
209
        $url = Str::moustaches(
210
            $this->commitUrl,
211
            $params
212
        );
213
        $closure('info', 'Querying Github API for commit detail', "API URL : {$url}");
214
        $curl = $this->getCurlHandle($url);
215
        if (false === ($data = curl_exec($curl))) {
216
            $closure(
217
                'error',
218
                'Github API request failed',
219
                implode("\n", [
220
                    "API URL - {$url}",
221
                    "CURL Error - (" . curl_errno($curl) . ') ' . curl_error($curl)
222
                ])
223
            );
224
            throw new RuntimeException('CURL request failed - (' . curl_errno($curl) . ') ' . curl_error($curl));
225
        }
226
        curl_close($curl);
227
        if (!$data = json_decode($data, true)) {
228
            $closure(
229
                'error',
230
                'Unable to parse Github Response JSON',
231
                implode("\n", [
232
                    "API URL - {$url}"
233
                ])
234
            );
235
            throw new RuntimeException('Invalid commit data for ' . $params['sha']);
236
        }
237
238
        return [
239
            'sha'       => $data['sha'],
240
            'author'    => $data['commit']['author']['email'],
241
            'committer' => $data['commit']['committer']['email'],
242
            'message'   => $data['commit']['message'],
243
        ];
244
    }
245
246
    /**
247
     * @see \App\Provider\ProviderInterface::download()
248
     */
249
    public function download(Project $project, Deployment $deployment, $directory, Closure $closure = null)
250
    {
251
        $params = [
252
            'repository' => $project->repository,
253
            'sha'        => $deployment->sha,
254
        ];
255
        $url = Str::moustaches(
256
            $this->downloadUrl,
257
            $params
258
        );
259
        $closure(
260
            'info',
261
            'Initiating codebase download using Github provider'
262
        );
263
264
        // Download the code tarball
265
        $filename = tempnam('/tmp', 'deploy-' . $params['sha'] . '-');
266
        if (!$handle = fopen($filename, "w")) {
267
            $closure(
268
                'error',
269
                'Unable to open temporary download file',
270
                implode("\n", [
271
                    "Temporary filename - {$filename}"
272
                ])
273
            );
274
            throw new RuntimeException('Unable to open temporary file');
275
        }
276
        $curl = $this->getCurlHandle($url);
277
        curl_setopt_array($curl, [
278
            CURLOPT_FOLLOWLOCATION => true,
279
            CURLOPT_FILE           => $handle
280
        ]);
281
        if (false === curl_exec($curl)) {
282
            $closure(
283
                'error',
284
                'Error downloading codebase',
285
                implode("\n", [
286
                    "Filename - {$filename}",
287
                    "CURL Error - (" . curl_errno($curl) . ') ' . curl_error($curl),
288
                ])
289
            );
290
            throw new RuntimeException(curl_errno($curl) . ' - ' . curl_error($curl));
291
        }
292
        $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
293
        curl_close($curl);
294
        fclose($handle);
295
        if ($statusCode != 200) {
296
            $closure(
297
                'error',
298
                'Error downloading codebase',
299
                implode("\n", [
300
                    "Filename - {$filename}",
301
                    "Status code - {$statusCode}",
302
                ])
303
            );
304
            throw new RuntimeException('Failed to download codebase - ' . $statusCode);
305
        }
306
307
        // Make sure the deployment download directory exists
308
        if (!is_dir($directory)) {
309
            $mode = Settings::get('build.chmod.default_folder', Builder::MODE_DEFAULT);
310
            $closure(
311
                'info',
312
                'Creating deployment directory',
313
                implode("\n", [
314
                    "Directory - {$directory}",
315
                ])
316
            );
317
            if (!mkdir($directory, $mode, true)) {
318
                $closure(
319
                    'error',
320
                    'Failed to create deployment directory',
321
                    implode("\n", [
322
                        "Directory - {$directory}",
323
                    ])
324
                );
325
                throw new RuntimeException(
326
                    'Unable to create build directory at ' . $directory
327
                );
328
            }
329
        }
330
331
        // Decompress the archive into the download directory
332
        $tar     = Settings::get('binary.tar', '/bin/tar');
333
        $command = "{$tar} --strip-components=1 -xzf {$filename} -C {$directory}";
334
        $closure(
335
            'info',
336
            'Unpacking codebase tarball',
337
            implode("\n", [
338
                "Command - {$command}",
339
            ])
340
        );
341
        $process = new Process(explode(' ', $command));
342
        $process->run();
343
        if (!$process->isSuccessful()) {
344
            $closure(
345
                'error',
346
                'Failed to unpack codebase tarball',
347
                implode("\n", [
348
                    "Command - {$command}",
349
                    $process->getErrorOutput(),
350
                ])
351
            );
352
            throw new ProcessFailedException($process);
353
        }
354
355
        // Remove the downloaded archive
356
        if (!unlink($filename)) {
357
            $closure(
358
                'error',
359
                'Codebase tarball unpacked',
360
                implode("\n", [
361
                    $process->getOutput(),
362
                ])
363
            );
364
            throw new RuntimeException('Unable to remove local code archive');
365
        }
366
        $closure(
367
            'info',
368
            'Codebase download completed'
369
        );
370
371
        return true;
372
    }
373
374
    /**
375
     * @see \App\Provider\ProviderInterface::scanConfiguration()
376
     */
377
    public function scanConfiguration(Project $project, Deployment $deployment, Closure $closure = null)
378
    {
379
        $params = [
380
            'repository' => $project->repository,
381
            'sha'        => $deployment->sha,
382
        ];
383
        $url = Str::moustaches(
384
            $this->configUrl,
385
            $params
386
        );
387
        $closure(
388
            'info',
389
            'Querying Github API for deployment configuration',
390
            implode("\n", [
391
                "API URL - {$url}"
392
            ])
393
        );
394
        $curl = $this->getCurlHandle($url);
395
        $json = curl_exec($curl);
396
        if (false === $json || !is_string($json)) {
397
            $closure(
398
                'error',
399
                'Github API request failed',
400
                implode("\n", [
401
                    "API URL - {$url}",
402
                    "CURL Error - (" . curl_errno($curl) . ') ' . curl_error($curl)
403
                ])
404
            );
405
            throw new RuntimeException("(" . curl_errno($curl) . ') ' . curl_error($curl));
406
        }
407
        $info = curl_getinfo($curl);
408
        if (404 == $info['http_code']) {
409
            $closure(
410
                'info',
411
                'No deployment configuration found in repository - using defaults'
412
            );
413
            Log::debug('Remote configuration file not found', [
414
                'project' => $project->toArray(),
415
            ]);
416
            return;
417
        }
418
        $data = json_decode($json, true);
419
        if (!$data || !isset($data['content'])) {
420
            $closure(
421
                'error',
422
                'Failed to parse Github response json',
423
                implode("\n", [
424
                    "API URL - {$url}",
425
                    "JSON - " . $json
426
                ])
427
            );
428
            Log::debug('Remote configuration file could not be read', [
429
                'project' => $project->toArray(),
430
            ]);
431
            return;
432
        }
433
        $yaml = base64_decode($data['content']);
434
        try {
435
            $yaml = Yaml::parse($yaml);
436
            $closure(
437
                'info',
438
                'Parsed YAML deployment configuration successfully',
439
                implode("\n", [
440
                    "API URL - {$url}",
441
                    "JSON - " . $json
442
                ])
443
            );
444
        } catch (Exception $ex) {
445
            $closure(
446
                'info',
447
                'Unable to parse YAML deployment configuration',
448
                implode("\n", [
449
                    "API URL - {$url}",
450
                    "Exception - " . $ex->getMessage(),
451
                ])
452
            );
453
            Log::error('Unable to parse YAML deployment configuration', [
454
                'project'   => $project->toArray(),
455
                'exception' => $ex,
456
            ]);
457
            return;
458
        }
459
460
        return new Config($yaml);
461
    }
462
463
    /**
464
     * Get a curl handle
465
     *
466
     * @param string $url
467
     * @return resource
468
     * @author Ronan Chilvers <[email protected]>
469
     */
470
    protected function getCurlHandle($url)
471
    {
472
        if (!$curl = curl_init($url)) {
473
            throw new RuntimeException('Unable to initialise CURL Github API request');
474
        }
475
        curl_setopt_array($curl, [
476
            CURLOPT_USERAGENT      => 'ronanchilvers/deploy - curl ' . curl_version()['version'],
477
            CURLOPT_FOLLOWLOCATION => false,
478
            CURLOPT_RETURNTRANSFER => true,
479
            CURLOPT_TIMEOUT        => 5,
480
            CURLOPT_HTTPHEADER     => [
481
                "Authorization: token {$this->token}"
482
            ],
483
        ]);
484
485
        return $curl;
486
    }
487
}
488