Passed
Push — master ( cf401d...252787 )
by Michael
02:49
created

GitHub::isConfigured()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 27
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 17
c 0
b 0
f 0
nc 4
nop 0
dl 0
loc 27
rs 8.5806
1
<?php
2
3
namespace dokuwiki\plugin\issuelinks\services;
4
5
use dokuwiki\Form\Form;
6
use dokuwiki\plugin\issuelinks\classes\ExternalServerException;
7
use dokuwiki\plugin\issuelinks\classes\HTTPRequestException;
8
use dokuwiki\plugin\issuelinks\classes\Issue;
9
use dokuwiki\plugin\issuelinks\classes\Repository;
10
use dokuwiki\plugin\issuelinks\classes\RequestResult;
11
12
class GitHub extends AbstractService
13
{
14
15
16
    const SYNTAX = 'gh';
17
    const DISPLAY_NAME = 'GitHub';
18
    const ID = 'github';
19
    const WEBHOOK_UA = 'GitHub-Hookshot/';
20
    protected $configError = '';
21
    protected $user = [];
22
    protected $total = null;
23
    protected $orgs;
24
    /** @var \DokuHTTPClient */
25
    protected $dokuHTTPClient;
26
    protected $githubUrl = 'https://api.github.com';
27
    private $scopes = ['admin:repo_hook', 'read:org', 'public_repo'];
28
29
    protected function __construct()
30
    {
31
        $this->dokuHTTPClient = new \DokuHTTPClient();
32
    }
33
34
    public static function getProjectIssueSeparator($isMergeRequest)
35
    {
36
        return '#';
37
    }
38
39
    public static function isOurWebhook()
40
    {
41
        global $INPUT;
42
        $userAgent = $INPUT->server->str('HTTP_USER_AGENT');
43
        return strpos($userAgent, self::WEBHOOK_UA) === 0;
44
    }
45
46
    public static function isIssueValid(Issue $issue)
47
    {
48
        $summary = $issue->getSummary();
49
        $valid = !blank($summary);
50
        $status = $issue->getStatus();
51
        $valid &= !blank($status);
52
        $type = $issue->getType();
53
        $valid &= !blank($type);
54
        return $valid;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $valid returns the type integer which is incompatible with the return type mandated by dokuwiki\plugin\issuelin...terface::isIssueValid() of boolean.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
55
    }
56
57
    public function getIssueURL($projectId, $issueId, $isMergeRequest)
58
    {
59
        return 'https://github.com' . '/' . $projectId . '/issues/' . $issueId;
60
    }
61
62
    public function parseIssueSyntax($issueSyntax)
63
    {
64
        list($projectKey, $issueId) = explode('#', $issueSyntax);
65
66
        // try to load as pull request
67
        $issue = Issue::getInstance('github', $projectKey, $issueId, true);
68
        $isPullRequest = $issue->getFromDB();
69
70
        if ($isPullRequest) {
71
            return $issue;
72
        }
73
74
        // not a pull request, retrieve it as normal issue
75
        $issue = Issue::getInstance('github', $projectKey, $issueId, false);
76
        $issue->getFromDB();
77
78
        return $issue;
79
    }
80
81
    /**
82
     * @return bool
83
     */
84
    public function isConfigured()
85
    {
86
        /** @var \helper_plugin_issuelinks_db $db */
87
        $db = plugin_load('helper', 'issuelinks_db');
88
        $authToken = $db->getKeyValue('github_token');
89
90
        if (empty($authToken)) {
91
            $this->configError = 'Authentication token is missing!';
92
            return false;
93
        }
94
95
        try {
96
            $user = $this->makeGitHubGETRequest('/user');
97
//            $status = $this->connector->getLastStatus();
98
        } catch (\Exception $e) {
99
            $this->configError = 'Attempt to verify the GitHub authentication failed with message: ' . hsc($e->getMessage());
100
            return false;
101
        }
102
        $this->user = $user;
103
104
        $headers = $this->dokuHTTPClient->resp_headers;
105
        $missing_scopes = array_diff($this->scopes, explode(', ', $headers['x-oauth-scopes']));
106
        if (count($missing_scopes) !== 0) {
107
            $this->configError = 'Scopes "' . implode(', ', $missing_scopes) . '" are missing!';
108
            return false;
109
        }
110
        return true;
111
    }
112
113
    /**
114
     *
115
     * todo: ensure correct headers are set: https://developer.github.com/v3/#current-version
116
     *
117
     * @param string   $endpoint the endpoint as defined in the GitHub documentation. With leading and trailing slashes
118
     * @param int|null $max      do not make more requests after this number of items have been retrieved
119
     *
120
     * @return array The decoded response-text
121
     * @throws HTTPRequestException
122
     */
123
    protected function makeGitHubGETRequest($endpoint, $max = null)
124
    {
125
        $results = [];
126
        $error = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $error is dead and can be removed.
Loading history...
127
        $waittime = 0;
128
        /** @var \helper_plugin_issuelinks_util $utils */
129
        $utils = plugin_load('helper', 'issuelinks_util');
130
        do {
131
            usleep($waittime);
132
            try {
133
                $data = $this->makeGitHubRequest($endpoint, [], 'GET', []);
134
            } catch (ExternalServerException $e) {
135
                if ($waittime >= 500) {
136
                    msg('Error repeats. Aborting Requests.', -1);
137
                    dbglog('Error repeats. Aborting Requests.', -1);
138
                    break;
139
                }
140
                $waittime += 100;
141
                msg("Server Error occured. Waiting $waittime ms between requests and repeating request.", -1);
142
                dbglog("Server Error occured. Waiting $waittime ms between requests and repeating request.", -1);
143
144
                continue;
145
            }
146
147
148
            if ($this->dokuHTTPClient->resp_headers['x-ratelimit-remaining'] < 500) {
149
                msg(sprintf($utils->getLang('error:system too many requests'),
150
                    dformat($this->dokuHTTPClient->resp_headers['x-ratelimit-reset'])), -1);
151
                break;
152
            }
153
154
            $results = array_merge($results, $data);
155
156
            if (empty($this->dokuHTTPClient->resp_headers['link'])) {
157
                break;
158
            }
159
            $links = $utils->parseHTTPLinkHeaders($this->dokuHTTPClient->resp_headers['link']);
160
            if (empty($links['next'])) {
161
                break;
162
            }
163
            $endpoint = substr($links['next'], strlen($this->githubUrl));
164
        } while (empty($max) || count($results) < $max);
165
        return $results;
166
    }
167
168
    /**
169
     * @param string $endpoint
170
     * @param array  $data
171
     * @param string $method
172
     * @param array  $headers
173
     *
174
     * @return mixed
175
     *
176
     * @throws HTTPRequestException|ExternalServerException
177
     */
178
    protected function makeGitHubRequest($endpoint, $data, $method, $headers = [])
179
    {
180
        /** @var \helper_plugin_issuelinks_db $db */
181
        $db = plugin_load('helper', 'issuelinks_db');
182
        $authToken = $db->getKeyValue('github_token');
183
        $defaultHeaders = [
184
            'Authorization' => "token $authToken",
185
            'Content-Type' => 'application/json',
186
        ];
187
188
        $requestHeaders = array_merge($defaultHeaders, $headers);
189
190
        // todo ensure correct slashes everywhere
191
        $url = $this->githubUrl . $endpoint;
192
193
        return $this->makeHTTPRequest($this->dokuHTTPClient, $url, $requestHeaders, $data, $method);
194
    }
195
196
    public function hydrateConfigForm(Form $configForm)
197
    {
198
        $scopes = implode(', ', $this->scopes);
199
        $configForm->addHTML('<p>' . $this->configError . ' Please go to <a href="https://github.com/settings/tokens">https://github.com/settings/tokens/</a> and generate a new token for this plugin with the scopes ' . $scopes . '</p>');
200
        $configForm->addTextInput('githubToken', 'GitHub AccessToken')->useInput(false);
201
    }
202
203
    public function handleAuthorization()
204
    {
205
        global $INPUT;
206
207
        $token = $INPUT->str('githubToken');
208
209
        /** @var \helper_plugin_issuelinks_db $db */
210
        $db = plugin_load('helper', 'issuelinks_db');
211
        $db->saveKeyValuePair('github_token', $token);
212
    }
213
214
    /**
215
     * @inheritdoc
216
     */
217
    public function getListOfAllReposAndHooks($organisation)
218
    {
219
        $endpoint = "/orgs/$organisation/repos";
220
        try {
221
            $repos = $this->makeGitHubGETRequest($endpoint);
222
        } catch (HTTPRequestException $e) {
223
            msg($e->getMessage() . ' ' . $e->getCode(), -1);
224
            return [];
225
        }
226
        $projects = [];
227
228
        foreach ($repos as $repoData) {
229
            $repo = new Repository();
230
            $repo->full_name = $repoData['full_name'];
231
            $repo->displayName = $repoData['name'];
232
233
            $endpoint = "/repos/$repoData[full_name]/hooks";
234
            try {
235
                $repoHooks = $this->makeGitHubGETRequest($endpoint);
236
            } catch (HTTPRequestException $e) {
237
                $repoHooks = [];
238
                $repo->error = 403;
239
            }
240
            $repoHooks = array_filter($repoHooks, [$this, 'isOurIssueHook']);
241
            $ourIsseHook = reset($repoHooks);
242
            if (!empty($ourIsseHook)) {
243
                $repo->hookID = $ourIsseHook['id'];
244
            }
245
            $projects[] = $repo;
246
        }
247
248
        return $projects;
249
    }
250
251
    public function deleteWebhook($project, $hookid)
252
    {
253
        try {
254
            $data = $this->makeGitHubRequest("/repos/$project/hooks/$hookid", [], 'DELETE');
255
            $status = $this->dokuHTTPClient->status;
256
257
            /** @var \helper_plugin_issuelinks_db $db */
258
            $db = plugin_load('helper', 'issuelinks_db');
259
            $db->deleteWebhook('github', $project, $hookid);
260
        } catch (HTTPRequestException $e) {
261
            $data = $e->getMessage();
262
            $status = $e->getCode();
263
        }
264
265
        return ['data' => $data, 'status' => $status];
266
    }
267
268
    public function createWebhook($project)
269
    {
270
        $secret = md5(openssl_random_pseudo_bytes(32));
271
        $config = [
272
            "url" => self::WEBHOOK_URL,
273
            "content_type" => 'json',
274
            "insecure_ssl" => 0,
275
            "secret" => $secret,
276
        ];
277
        $data = [
278
            "name" => "web",
279
            "config" => $config,
280
            "active" => true,
281
            'events' => ['issues', 'issue_comment', 'pull_request'],
282
        ];
283
        try {
284
            $data = $this->makeGitHubRequest("/repos/$project/hooks", $data, 'POST');
285
            $status = $this->dokuHTTPClient->status;
286
            $id = $data['id'];
287
            /** @var \helper_plugin_issuelinks_db $db */
288
            $db = plugin_load('helper', 'issuelinks_db');
289
            $db->saveWebhook('github', $project, $id, $secret);
290
        } catch (HTTPRequestException $e) {
291
            $data = $e->getMessage();
292
            $status = $e->getCode();
293
        }
294
295
        return ['data' => $data, 'status' => $status];
296
    }
297
298
    public function validateWebhook($webhookBody)
299
    {
300
        $data = json_decode($webhookBody, true);
301
        if (!$this->isSignatureValid($webhookBody, $data['repository']['full_name'])) {
302
            return new RequestResult(403, 'Signature invalid or missing!');
303
        }
304
        return true;
305
    }
306
307
    /**
308
     * Check if the signature in the header provided by github is valid by using a stored secret
309
     *
310
     *
311
     * Known issue:
312
     *   * We have to cycle through the webhooks/secrets stored for a given repo because the hookid is not in the request
313
     *
314
     * @param string $body    The unaltered payload of the request
315
     * @param string $repo_id the repo id (in the format of "organisation/repo-name")
316
     *
317
     * @return bool wether the provided signature checks out against a stored one
318
     */
319
    protected function isSignatureValid($body, $repo_id)
320
    {
321
        global $INPUT;
322
        if (!$INPUT->server->has('HTTP_X_HUB_SIGNATURE')) {
323
            return false;
324
        }
325
        list($algo, $signature_github) = explode('=', $INPUT->server->str('HTTP_X_HUB_SIGNATURE'));
326
        /** @var \helper_plugin_issuelinks_db $db */
327
        $db = plugin_load('helper', 'issuelinks_db');
328
        $secrets = $db->getWebhookSecrets('github', $repo_id);
329
        foreach ($secrets as $secret) {
330
            $signature_local = hash_hmac($algo, $body, $secret['secret']);
331
            if (hash_equals($signature_local, $signature_github)) {
332
                return true;
333
            }
334
        }
335
        return false;
336
    }
337
338
    public function handleWebhook($webhookBody)
339
    {
340
        global $INPUT;
341
        $data = json_decode($webhookBody, true);
342
        $event = $INPUT->server->str('HTTP_X_GITHUB_EVENT');
343
344
        if ($event === 'ping') {
345
            return new RequestResult(202, 'Webhook ping successful. Pings are not processed.');
346
        }
347
348
        if (!$this->saveIssue($data)) {
349
            return new RequestResult(500, 'There was an error saving the issue.');
350
        }
351
352
353
        return new RequestResult(200, 'OK');
354
    }
355
356
    /**
357
     * Handle the webhook event, triggered by an updated or created issue
358
     *
359
     * @param array $data
360
     *
361
     * @return bool whether saving was successful
362
     *
363
     * @throws \InvalidArgumentException
364
     * @throws HTTPRequestException
365
     */
366
    protected function saveIssue($data)
367
    {
368
369
        $issue = Issue::getInstance(
370
            'github',
371
            $data['repository']['full_name'],
372
            $data['issue']['number'],
373
            false,
374
            true
375
        );
376
377
        $this->setIssueData($issue, $data['issue']);
378
379
        return $issue->saveToDB();
380
    }
381
382
    /**
383
     * @param Issue $issue
384
     * @param array $info
385
     */
386
    protected function setIssueData(Issue $issue, $info)
387
    {
388
        $issue->setSummary($info['title']);
389
        $issue->setDescription($info['body']);
390
        $labels = [];
391
        $type = false;
392
        foreach ($info['labels'] as $label) {
393
            $labels[] = $label['name'];
394
            if (!$type) {
395
                switch ($label['name']) {
396
                    case 'bug':
397
                        $type = 'bug';
398
                        break;
399
                    case 'enhancement':
400
                        $type = 'improvement';
401
                        break;
402
                    case 'feature':
403
                        $type = 'story';
404
                        break;
405
                    default:
406
                }
407
            }
408
            $issue->setLabelData($label['name'], '#' . $label['color']);
409
        }
410
        $issue->setType($type ? $type : 'unknown');
411
        $issue->setStatus(isset($info['merged']) ? 'merged' : $info['state']);
412
        $issue->setUpdated($info['updated_at']);
413
        if (!empty($info['milestone'])) {
414
            $issue->setVersions([$info['milestone']['title']]);
415
        }
416
        $issue->setLabels($labels);
417
        if ($info['assignee']) {
418
            $issue->setAssignee($info['assignee']['login'], $info['assignee']['avatar_url']);
419
        }
420
    }
421
422
    public function getListOfAllUserOrganisations()
423
    {
424
        if ($this->orgs === null) {
425
            $endpoint = '/user/orgs';
426
            try {
427
                $this->orgs = $this->makeGitHubGETRequest($endpoint);
428
            } catch (\Throwable $e) {
429
                $this->orgs = [];
430
                msg(hsc($e->getMessage()), -1);
431
            }
432
        }
433
        // fixme: add 'user repos'!
434
        return array_map(function ($org) {
435
            return $org['login'];
436
        }, $this->orgs);
437
    }
438
439
    public function getUserString()
440
    {
441
        return $this->user['login'];
442
    }
443
444
    public function retrieveIssue(Issue $issue)
445
    {
446
        $repo = $issue->getProject();
447
        $issueNumber = $issue->getKey();
448
        $endpoint = '/repos/' . $repo . '/issues/' . $issueNumber;
449
        $result = $this->makeGitHubGETRequest($endpoint);
450
        $this->setIssueData($issue, $result);
451
        if (isset($result['pull_request'])) {
452
            $issue->isMergeRequest(true);
453
            $endpoint = '/repos/' . $repo . '/pulls/' . $issueNumber;
454
            $result = $this->makeGitHubGETRequest($endpoint);
455
            $issue->setStatus($result['merged'] ? 'merged' : $result['state']);
456
            $mergeRequestText = $issue->getSummary() . ' ' . $issue->getDescription();
457
            $issues = $this->parseMergeRequestDescription($repo, $mergeRequestText);
458
            /** @var \helper_plugin_issuelinks_db $db */
459
            $db = plugin_load('helper', 'issuelinks_db');
460
            $db->saveIssueIssues($issue, $issues);
461
        }
462
    }
463
464
    /**
465
     * Parse a string for issue-ids
466
     *
467
     * Currently only parses issues for the same repo and jira issues
468
     *
469
     * @param string $currentProject
470
     * @param string $description
471
     *
472
     * @return array
473
     */
474
    protected function parseMergeRequestDescription($currentProject, $description)
475
    {
476
        $issues = [];
477
478
        $issueOwnRepoPattern = '/(?:\W|^)#([1-9]\d*)\b/';
479
        preg_match_all($issueOwnRepoPattern, $description, $githubMatches);
480
        foreach ($githubMatches[1] as $issueId) {
481
            $issues[] = [
482
                'service' => 'github',
483
                'project' => $currentProject,
484
                'issueId' => $issueId,
485
            ];
486
        }
487
488
        // FIXME: this should be done by JIRA service class
489
        $jiraMatches = [];
490
        $jiraPattern = '/[A-Z0-9]+-[1-9]\d*/';
491
        preg_match_all($jiraPattern, $description, $jiraMatches);
492
        foreach ($jiraMatches[0] as $match) {
493
            list($project, $issueId) = explode('-', $match);
494
            $issues[] = [
495
                'service' => 'jira',
496
                'project' => $project,
497
                'issueId' => $issueId,
498
            ];
499
        }
500
501
        return $issues;
502
    }
503
504
    /**
505
     *
506
     * @see https://developer.github.com/v3/issues/#list-issues-for-a-repository
507
     *
508
     * @param string $projectKey The short-key of the project to be imported
509
     * @param int    $startat    The offset from the last Element from which to start importing
510
     *
511
     * @return array               The issues, suitable to be saved into the db
512
     * @throws HTTPRequestException
513
     *
514
     * // FIXME: set Header application/vnd.github.symmetra-preview+json ?
515
     */
516
    public function retrieveAllIssues($projectKey, &$startat = 0)
517
    {
518
        $perPage = 30;
519
        $page = ceil(($startat + 1) / $perPage);
520
        // FIXME: implent `since` parameter?
521
        $endpoint = "/repos/$projectKey/issues?state=all&page=$page";
522
        $issues = $this->makeGitHubGETRequest($endpoint);
523
524
        if (!is_array($issues)) {
0 ignored issues
show
introduced by
The condition is_array($issues) is always true.
Loading history...
525
            return [];
526
        }
527
        if (empty($this->total)) {
528
            $this->total = $this->estimateTotal($perPage, count($issues));
529
        }
530
        $retrievedIssues = [];
531
        foreach ($issues as $issueData) {
532
            try {
533
                $issue = Issue::getInstance('github', $projectKey, $issueData['number'],
534
                    !empty($issueData['pull_request']));
535
            } catch (\InvalidArgumentException $e) {
536
                continue;
537
            }
538
            $this->setIssueData($issue, $issueData);
539
            $issue->saveToDB();
540
            $retrievedIssues[] = $issue;
541
        }
542
        $startat += $perPage;
543
        return $retrievedIssues;
544
    }
545
546
    /**
547
     * Estimate the total amount of results
548
     *
549
     * @param int $perPage amount of results per page
550
     * @param int $default what is returned if the total can not be calculated otherwise
551
     *
552
     * @return
553
     */
554
    protected function estimateTotal($perPage, $default)
555
    {
556
        $headers = $this->dokuHTTPClient->resp_headers;
557
558
        if (empty($headers['link'])) {
559
            return $default;
560
        }
561
562
        /** @var \helper_plugin_issuelinks_util $util */
563
        $util = plugin_load('helper', 'issuelinks_util');
564
        $links = $util->parseHTTPLinkHeaders($headers['link']);
565
        preg_match('/page=(\d+)$/', $links['last'], $matches);
566
        if (!empty($matches[1])) {
567
            return $matches[1] * $perPage;
568
        }
569
        return $default;
570
    }
571
572
    /**
573
     * @return mixed
574
     */
575
    public function getTotalIssuesBeingImported()
576
    {
577
        return $this->total;
578
    }
579
580
    /**
581
     * See if this is a hook for issue events, that has been set by us
582
     *
583
     * @param array $hook the hook data coming from github
584
     *
585
     * @return bool
586
     */
587
    protected function isOurIssueHook($hook)
588
    {
589
        if ($hook['config']['url'] !== self::WEBHOOK_URL) {
590
            return false;
591
        }
592
593
        if ($hook['config']['content_type'] !== 'json') {
594
            return false;
595
        }
596
597
        if ($hook['config']['insecure_ssl'] !== '0') {
598
            return false;
599
        }
600
601
        if (!$hook['active']) {
602
            return false;
603
        }
604
605
        if (count($hook['events']) !== 3 || array_diff($hook['events'],
606
                ['issues', 'issue_comment', 'pull_request'])) {
607
            return false;
608
        }
609
610
        return true;
611
    }
612
}
613