1<?php
2
3namespace dokuwiki\plugin\issuelinks\services;
4
5use dokuwiki\Form\Form;
6use dokuwiki\plugin\issuelinks\classes\ExternalServerException;
7use dokuwiki\plugin\issuelinks\classes\HTTPRequestException;
8use dokuwiki\plugin\issuelinks\classes\Issue;
9use dokuwiki\plugin\issuelinks\classes\Repository;
10use dokuwiki\plugin\issuelinks\classes\RequestResult;
11
12class 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;
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 = '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)) {
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) {
624            return false;
625        }
626
627        return true;
628    }
629}
630