Passed
Push — master ( 252787...0ddf87 )
by Michael
02:45
created

GitHub::setIssueData()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 19
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 15
nc 8
nop 2
dl 0
loc 19
rs 8.8571
c 0
b 0
f 0
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;
0 ignored issues
show
Unused Code introduced by
The assignment to $type is dead and can be removed.
Loading history...
392
        foreach ($info['labels'] as $label) {
393
            $labels[] = $label['name'];
394
            $issue->setLabelData($label['name'], '#' . $label['color']);
395
        }
396
        $issue->setType($this->getTypeFromLabels($labels));
397
        $issue->setStatus(isset($info['merged']) ? 'merged' : $info['state']);
398
        $issue->setUpdated($info['updated_at']);
399
        if (!empty($info['milestone'])) {
400
            $issue->setVersions([$info['milestone']['title']]);
401
        }
402
        $issue->setLabels($labels);
403
        if ($info['assignee']) {
404
            $issue->setAssignee($info['assignee']['login'], $info['assignee']['avatar_url']);
405
        }
406
    }
407
408
    protected function getTypeFromLabels(array $labels)
409
    {
410
        $bugTypeLabels = ['bug'];
411
        $improvementTypeLabels = ['enhancement'];
412
        $storyTypeLabels = ['feature'];
413
414
        if (count(array_intersect($labels, $bugTypeLabels))) {
415
            return 'bug';
416
        }
417
418
        if (count(array_intersect($labels, $improvementTypeLabels))) {
419
            return 'improvement';
420
        }
421
422
        if (count(array_intersect($labels, $storyTypeLabels))) {
423
            return 'story';
424
        }
425
426
        return 'unknown';
427
    }
428
429
    public function getListOfAllUserOrganisations()
430
    {
431
        if ($this->orgs === null) {
432
            $endpoint = '/user/orgs';
433
            try {
434
                $this->orgs = $this->makeGitHubGETRequest($endpoint);
435
            } catch (\Throwable $e) {
436
                $this->orgs = [];
437
                msg(hsc($e->getMessage()), -1);
438
            }
439
        }
440
        // fixme: add 'user repos'!
441
        return array_map(function ($org) {
442
            return $org['login'];
443
        }, $this->orgs);
444
    }
445
446
    public function getUserString()
447
    {
448
        return $this->user['login'];
449
    }
450
451
    public function retrieveIssue(Issue $issue)
452
    {
453
        $repo = $issue->getProject();
454
        $issueNumber = $issue->getKey();
455
        $endpoint = '/repos/' . $repo . '/issues/' . $issueNumber;
456
        $result = $this->makeGitHubGETRequest($endpoint);
457
        $this->setIssueData($issue, $result);
458
        if (isset($result['pull_request'])) {
459
            $issue->isMergeRequest(true);
460
            $endpoint = '/repos/' . $repo . '/pulls/' . $issueNumber;
461
            $result = $this->makeGitHubGETRequest($endpoint);
462
            $issue->setStatus($result['merged'] ? 'merged' : $result['state']);
463
            $mergeRequestText = $issue->getSummary() . ' ' . $issue->getDescription();
464
            $issues = $this->parseMergeRequestDescription($repo, $mergeRequestText);
465
            /** @var \helper_plugin_issuelinks_db $db */
466
            $db = plugin_load('helper', 'issuelinks_db');
467
            $db->saveIssueIssues($issue, $issues);
468
        }
469
    }
470
471
    /**
472
     * Parse a string for issue-ids
473
     *
474
     * Currently only parses issues for the same repo and jira issues
475
     *
476
     * @param string $currentProject
477
     * @param string $description
478
     *
479
     * @return array
480
     */
481
    protected function parseMergeRequestDescription($currentProject, $description)
482
    {
483
        $issues = [];
484
485
        $issueOwnRepoPattern = '/(?:\W|^)#([1-9]\d*)\b/';
486
        preg_match_all($issueOwnRepoPattern, $description, $githubMatches);
487
        foreach ($githubMatches[1] as $issueId) {
488
            $issues[] = [
489
                'service' => 'github',
490
                'project' => $currentProject,
491
                'issueId' => $issueId,
492
            ];
493
        }
494
495
        // FIXME: this should be done by JIRA service class
496
        $jiraMatches = [];
497
        $jiraPattern = '/[A-Z0-9]+-[1-9]\d*/';
498
        preg_match_all($jiraPattern, $description, $jiraMatches);
499
        foreach ($jiraMatches[0] as $match) {
500
            list($project, $issueId) = explode('-', $match);
501
            $issues[] = [
502
                'service' => 'jira',
503
                'project' => $project,
504
                'issueId' => $issueId,
505
            ];
506
        }
507
508
        return $issues;
509
    }
510
511
    /**
512
     *
513
     * @see https://developer.github.com/v3/issues/#list-issues-for-a-repository
514
     *
515
     * @param string $projectKey The short-key of the project to be imported
516
     * @param int    $startat    The offset from the last Element from which to start importing
517
     *
518
     * @return array               The issues, suitable to be saved into the db
519
     * @throws HTTPRequestException
520
     *
521
     * // FIXME: set Header application/vnd.github.symmetra-preview+json ?
522
     */
523
    public function retrieveAllIssues($projectKey, &$startat = 0)
524
    {
525
        $perPage = 30;
526
        $page = ceil(($startat + 1) / $perPage);
527
        // FIXME: implent `since` parameter?
528
        $endpoint = "/repos/$projectKey/issues?state=all&page=$page";
529
        $issues = $this->makeGitHubGETRequest($endpoint);
530
531
        if (!is_array($issues)) {
0 ignored issues
show
introduced by
The condition is_array($issues) is always true.
Loading history...
532
            return [];
533
        }
534
        if (empty($this->total)) {
535
            $this->total = $this->estimateTotal($perPage, count($issues));
536
        }
537
        $retrievedIssues = [];
538
        foreach ($issues as $issueData) {
539
            try {
540
                $issue = Issue::getInstance('github', $projectKey, $issueData['number'],
541
                    !empty($issueData['pull_request']));
542
            } catch (\InvalidArgumentException $e) {
543
                continue;
544
            }
545
            $this->setIssueData($issue, $issueData);
546
            $issue->saveToDB();
547
            $retrievedIssues[] = $issue;
548
        }
549
        $startat += $perPage;
550
        return $retrievedIssues;
551
    }
552
553
    /**
554
     * Estimate the total amount of results
555
     *
556
     * @param int $perPage amount of results per page
557
     * @param int $default what is returned if the total can not be calculated otherwise
558
     *
559
     * @return
560
     */
561
    protected function estimateTotal($perPage, $default)
562
    {
563
        $headers = $this->dokuHTTPClient->resp_headers;
564
565
        if (empty($headers['link'])) {
566
            return $default;
567
        }
568
569
        /** @var \helper_plugin_issuelinks_util $util */
570
        $util = plugin_load('helper', 'issuelinks_util');
571
        $links = $util->parseHTTPLinkHeaders($headers['link']);
572
        preg_match('/page=(\d+)$/', $links['last'], $matches);
573
        if (!empty($matches[1])) {
574
            return $matches[1] * $perPage;
575
        }
576
        return $default;
577
    }
578
579
    /**
580
     * @return mixed
581
     */
582
    public function getTotalIssuesBeingImported()
583
    {
584
        return $this->total;
585
    }
586
587
    /**
588
     * See if this is a hook for issue events, that has been set by us
589
     *
590
     * @param array $hook the hook data coming from github
591
     *
592
     * @return bool
593
     */
594
    protected function isOurIssueHook($hook)
595
    {
596
        if ($hook['config']['url'] !== self::WEBHOOK_URL) {
597
            return false;
598
        }
599
600
        if ($hook['config']['content_type'] !== 'json') {
601
            return false;
602
        }
603
604
        if ($hook['config']['insecure_ssl'] !== '0') {
605
            return false;
606
        }
607
608
        if (!$hook['active']) {
609
            return false;
610
        }
611
612
        if (count($hook['events']) !== 3 || array_diff($hook['events'],
613
                ['issues', 'issue_comment', 'pull_request'])) {
614
            return false;
615
        }
616
617
        return true;
618
    }
619
}
620