GitHub::createWebhook()   A
last analyzed

Complexity

Conditions 2
Paths 5

Size

Total Lines 28
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
624
            return false;
625
        }
626
627
        return true;
628
    }
629
}
630