1<?php
2
3namespace dokuwiki\plugin\issuelinks\services;
4
5use dokuwiki\Form\Form;
6use dokuwiki\plugin\issuelinks\classes\HTTPRequestException;
7use dokuwiki\plugin\issuelinks\classes\Issue;
8use dokuwiki\plugin\issuelinks\classes\Repository;
9use dokuwiki\plugin\issuelinks\classes\RequestResult;
10
11class GitLab extends AbstractService
12{
13
14    const SYNTAX = 'gl';
15    const DISPLAY_NAME = 'GitLab';
16    const ID = 'gitlab';
17
18    protected $dokuHTTPClient;
19    protected $gitlabUrl;
20    protected $token;
21    protected $configError;
22    protected $user;
23    protected $total;
24
25    protected function __construct()
26    {
27        $this->dokuHTTPClient = new \DokuHTTPClient();
28        /** @var \helper_plugin_issuelinks_db $db */
29        $db = plugin_load('helper', 'issuelinks_db');
30        $gitLabUrl = $db->getKeyValue('gitlab_url');
31        $this->gitlabUrl = $gitLabUrl ? trim($gitLabUrl, '/') : null;
32        $authToken = $db->getKeyValue('gitlab_token');
33        $this->token = $authToken;
34    }
35
36    /**
37     * Decide whether the provided issue is valid
38     *
39     * @param Issue $issue
40     *
41     * @return bool
42     */
43    public static function isIssueValid(Issue $issue)
44    {
45        $summary = $issue->getSummary();
46        $valid = !blank($summary);
47        $status = $issue->getStatus();
48        $valid &= !blank($status);
49        return $valid;
50    }
51
52    /**
53     * Provide the character separation the project name from the issue number, may be different for merge requests
54     *
55     * @param bool $isMergeRequest
56     *
57     * @return string
58     */
59    public static function getProjectIssueSeparator($isMergeRequest)
60    {
61        return $isMergeRequest ? '!' : '#';
62    }
63
64    public static function isOurWebhook()
65    {
66        global $INPUT;
67        if ($INPUT->server->has('HTTP_X_GITLAB_TOKEN')) {
68            return true;
69        }
70
71        return false;
72    }
73
74    /**
75     * @return bool
76     */
77    public function isConfigured()
78    {
79        if (null === $this->gitlabUrl) {
80            $this->configError = 'GitLab URL not set!';
81            return false;
82        }
83
84        if (empty($this->token)) {
85            $this->configError = 'Authentication token is missing!';
86            return false;
87        }
88
89        try {
90            $user = $this->makeSingleGitLabGetRequest('/user');
91        } catch (\Exception $e) {
92            $this->configError = 'The GitLab authentication failed with message: ' . hsc($e->getMessage());
93            return false;
94        }
95        $this->user = $user;
96
97        return true;
98    }
99
100    /**
101     * Make a single GET request to GitLab
102     *
103     * @param string $endpoint The endpoint as specifed in the gitlab documentatin (with leading slash!)
104     *
105     * @return array The response as array
106     * @throws HTTPRequestException
107     */
108    protected function makeSingleGitLabGetRequest($endpoint)
109    {
110        return $this->makeGitLabRequest($endpoint, [], 'GET');
111    }
112
113    /**
114     * Make a request to GitLab
115     *
116     * @param string $endpoint The endpoint as specifed in the gitlab documentatin (with leading slash!)
117     * @param array  $data
118     * @param string $method   the http method to make, defaults to 'GET'
119     * @param array  $headers  an array of additional headers to send along
120     *
121     * @return array|int The response as array or the number of an occurred error if it is in @param
122     *                   $errorsToBeReturned or an empty array if the error is not in @param $errorsToBeReturned
123     *
124     * @throws HTTPRequestException
125     */
126    protected function makeGitLabRequest($endpoint, array $data, $method, array $headers = [])
127    {
128        $url = $this->gitlabUrl . '/api/v4' . strtolower($endpoint);
129        $defaultHeaders = [
130            'PRIVATE-TOKEN' => $this->token,
131            'Content-Type' => 'application/json',
132        ];
133
134        $requestHeaders = array_merge($defaultHeaders, $headers);
135        return $this->makeHTTPRequest($this->dokuHTTPClient, $url, $requestHeaders, $data, $method);
136    }
137
138    /**
139     * @param Form $configForm
140     *
141     * @return void
142     */
143    public function hydrateConfigForm(Form $configForm)
144    {
145        $link = 'https://<em>your.gitlab.host</em>/profile/personal_access_tokens';
146        if (null !== $this->gitlabUrl) {
147            $url = $this->gitlabUrl . '/profile/personal_access_tokens';
148            $link = "<a href=\"$url\">$url</a>";
149        }
150
151        $message = '<p>';
152        $message .= $this->configError;
153        $message .= "Please go to $link and generate a new token for this plugin with the <b>api</b> scope.";
154        $message .= '</p>';
155
156        $configForm->addHTML($message);
157        $configForm->addTextInput('gitlab_url', 'GitLab Url')->val($this->gitlabUrl);
158        $configForm->addTextInput('gitlab_token', 'GitLab AccessToken')->useInput(false);
159    }
160
161    public function handleAuthorization()
162    {
163        global $INPUT;
164
165        $token = $INPUT->str('gitlab_token');
166        $url = $INPUT->str('gitlab_url');
167
168        /** @var \helper_plugin_issuelinks_db $db */
169        $db = plugin_load('helper', 'issuelinks_db');
170        if (!empty($token)) {
171            $db->saveKeyValuePair('gitlab_token', $token);
172        }
173        if (!empty($url)) {
174            $db->saveKeyValuePair('gitlab_url', $url);
175        }
176    }
177
178    public function getUserString()
179    {
180        $name = $this->user['name'];
181        $url = $this->user['web_url'];
182
183        return "<a href=\"$url\" target=\"_blank\">$name</a>";
184    }
185
186    /**
187     * Get a list of all organisations a user is member of
188     *
189     * @return string[] the identifiers of the organisations
190     */
191    public function getListOfAllUserOrganisations()
192    {
193        $groups = $this->makeSingleGitLabGetRequest('/groups');
194
195        return array_map(function ($group) {
196            return $group['full_path'];
197        }, $groups);
198    }
199
200    /**
201     * @param $organisation
202     *
203     * @return Repository[]
204     */
205    public function getListOfAllReposAndHooks($organisation)
206    {
207        $projects = $this->makeSingleGitLabGetRequest("/groups/$organisation/projects?per_page=100");
208        $repositories = [];
209        foreach ($projects as $project) {
210            $repo = new Repository();
211            $repo->full_name = $project['path_with_namespace'];
212            $repo->displayName = $project['name'];
213            try {
214                $endpoint = "/projects/$organisation%2F{$project['path']}/hooks?per_page=100";
215                $repoHooks = $this->makeSingleGitLabGetRequest($endpoint);
216            } catch (HTTPRequestException $e) {
217                $repo->error = (int)$e->getCode();
218            }
219
220            $repoHooks = array_filter($repoHooks, [$this, 'isOurIssueHook']);
221            $ourIsseHook = reset($repoHooks);
222            if (!empty($ourIsseHook)) {
223                $repo->hookID = $ourIsseHook['id'];
224            }
225
226            $repositories[] = $repo;
227        }
228
229        return $repositories;
230    }
231
232    public function createWebhook($project)
233    {
234        $secret = md5(openssl_random_pseudo_bytes(32));
235        $data = [
236            'url' => self::WEBHOOK_URL,
237            'enable_ssl_verification' => true,
238            'token' => $secret,
239            'push_events' => false,
240            'issues_events' => true,
241            'merge_requests_events' => true,
242        ];
243
244        try {
245            $encProject = urlencode($project);
246            $data = $this->makeGitLabRequest("/projects/$encProject/hooks", $data, 'POST');
247            $status = $this->dokuHTTPClient->status;
248            /** @var \helper_plugin_issuelinks_db $db */
249            $db = plugin_load('helper', 'issuelinks_db');
250            $db->saveWebhook('gitlab', $project, $data['id'], $secret);
251        } catch (HTTPRequestException $e) {
252            $data = $e->getMessage();
253            $status = $e->getCode();
254        }
255
256        return ['data' => $data, 'status' => $status];
257    }
258
259    public function deleteWebhook($project, $hookid)
260    {
261        /** @var \helper_plugin_issuelinks_db $db */
262        $db = plugin_load('helper', 'issuelinks_db');
263        $encProject = urlencode($project);
264        $endpoint = "/projects/$encProject/hooks/$hookid";
265        try {
266            $data = $this->makeGitLabRequest($endpoint, [], 'DELETE');
267            $status = $this->dokuHTTPClient->status;
268            $db->deleteWebhook('gitlab', $project, $hookid);
269        } catch (HTTPRequestException $e) {
270            $data = $e->getMessage();
271            $status = $e->getCode();
272        }
273
274        return ['data' => $data, 'status' => $status];
275    }
276
277    /**
278     * Get the url to the given issue at the given project
279     *
280     * @param      $projectId
281     * @param      $issueId
282     * @param bool $isMergeRequest ignored, GitHub routes the requests correctly by itself
283     *
284     * @return string
285     */
286    public function getIssueURL($projectId, $issueId, $isMergeRequest)
287    {
288        return $this->gitlabUrl . '/' . $projectId . ($isMergeRequest ? '/merge_requests/' : '/issues/') . $issueId;
289    }
290
291    /**
292     * @param string $issueSyntax
293     *
294     * @return Issue
295     */
296    public function parseIssueSyntax($issueSyntax)
297    {
298        $isMergeRequest = false;
299        $projectIssueSeperator = '#';
300        if (strpos($issueSyntax, '!') !== false) {
301            $isMergeRequest = true;
302            $projectIssueSeperator = '!';
303        }
304        list($projectKey, $issueId) = explode($projectIssueSeperator, $issueSyntax);
305        $issue = Issue::getInstance('gitlab', $projectKey, $issueId, $isMergeRequest);
306        $issue->getFromDB();
307        return $issue;
308    }
309
310    public function retrieveIssue(Issue $issue)
311    {
312        $notable = $issue->isMergeRequest() ? 'merge_requests' : 'issues';
313        $repoUrlEnc = rawurlencode($issue->getProject());
314        $endpoint = '/projects/' . $repoUrlEnc . '/' . $notable . '/' . $issue->getKey();
315        $info = $this->makeSingleGitLabGetRequest($endpoint);
316        $this->setIssueData($issue, $info);
317
318        if ($issue->isMergeRequest()) {
319            $mergeRequestText = $issue->getSummary() . ' ' . $issue->getDescription();
320            $issues = $this->parseMergeRequestDescription($issue->getProject(), $mergeRequestText);
321            /** @var \helper_plugin_issuelinks_db $db */
322            $db = plugin_load('helper', 'issuelinks_db');
323            $db->saveIssueIssues($issue, $issues);
324        }
325        $endpoint = '/projects/' . $repoUrlEnc . '/labels';
326        $projectLabelData = $this->makeSingleGitLabGetRequest($endpoint);
327        foreach ($projectLabelData as $labelData) {
328            $issue->setLabelData($labelData['name'], $labelData['color']);
329        }
330    }
331
332    /**
333     * @param Issue $issue
334     * @param array $info
335     */
336    protected function setIssueData(Issue $issue, $info)
337    {
338        $issue->setSummary($info['title']);
339        $issue->setDescription($info['description']);
340
341        $issue->setType($this->getTypeFromLabels($info['labels']));
342        $issue->setStatus($info['state']);
343        $issue->setUpdated($info['updated_at']);
344        $issue->setLabels($info['labels']);
345        if (!empty($info['milestone'])) {
346            $issue->setVersions([$info['milestone']['title']]);
347        }
348        if (!empty($info['milestone'])) {
349            $issue->setDuedate($info['duedate']);
350        }
351
352        if (!empty($info['assignee'])) {
353            $issue->setAssignee($info['assignee']['name'], $info['assignee']['avatar_url']);
354        }
355    }
356
357    protected function getTypeFromLabels(array $labels)
358    {
359        $bugTypeLabels = ['bug'];
360        $improvementTypeLabels = ['enhancement'];
361        $storyTypeLabels = ['feature'];
362
363        if (count(array_intersect($labels, $bugTypeLabels))) {
364            return 'bug';
365        }
366
367        if (count(array_intersect($labels, $improvementTypeLabels))) {
368            return 'improvement';
369        }
370
371        if (count(array_intersect($labels, $storyTypeLabels))) {
372            return 'story';
373        }
374
375        return 'unknown';
376    }
377
378    /**
379     * Parse a string for issue-ids
380     *
381     * Currently only parses issues for the same repo and jira issues
382     *
383     * @param string $currentProject
384     * @param string $description
385     *
386     * @return array
387     */
388    public function parseMergeRequestDescription($currentProject, $description)
389    {
390        $issues = [];
391
392        $issueOwnRepoPattern = '/(?:\W|^)#([1-9]\d*)\b/';
393        preg_match_all($issueOwnRepoPattern, $description, $gitlabMatches);
394        foreach ($gitlabMatches[1] as $issueId) {
395            $issues[] = [
396                'service' => 'gitlab',
397                'project' => $currentProject,
398                'issueId' => $issueId,
399            ];
400        }
401
402        $jiraMatches = [];
403        $jiraPattern = '/[A-Z0-9]+-[1-9]\d*/';
404        preg_match_all($jiraPattern, $description, $jiraMatches);
405        foreach ($jiraMatches[0] as $match) {
406            list($project, $issueId) = explode('-', $match);
407            $issues[] = [
408                'service' => 'jira',
409                'project' => $project,
410                'issueId' => $issueId,
411            ];
412        }
413        return $issues;
414    }
415
416    public function retrieveAllIssues($projectKey, &$startat = 0)
417    {
418        $perPage = 100;
419        $page = ceil(($startat + 1) / $perPage);
420        $endpoint = '/projects/' . urlencode($projectKey) . "/issues?page=$page&per_page=$perPage";
421        $issues = $this->makeSingleGitLabGetRequest($endpoint);
422        $this->total = $this->estimateTotal($perPage, count($issues));
423        $mrEndpoint = '/projects/' . urlencode($projectKey) . "/merge_requests?page=$page&per_page=$perPage";
424        $mrs = $this->makeSingleGitLabGetRequest($mrEndpoint);
425        $this->total += $this->estimateTotal($perPage, count($mrs));
426        $retrievedIssues = [];
427        try {
428            foreach ($issues as $issueData) {
429                $issue = Issue::getInstance('gitlab', $projectKey, $issueData['iid'], false);
430                $this->setIssueData($issue, $issueData);
431                $issue->saveToDB();
432                $retrievedIssues[] = $issue;
433            }
434            $startat += $perPage;
435        } catch (\InvalidArgumentException $e) {
436            dbglog($e->getMessage());
437            dbglog($issueData);
438        }
439
440        try {
441            foreach ($mrs as $mrData) {
442                $issue = Issue::getInstance('gitlab', $projectKey, $mrData['iid'], true);
443                $this->setIssueData($issue, $mrData);
444                $issue->saveToDB();
445                $retrievedIssues[] = $issue;
446                $issueText = $issue->getSummary() . ' ' . $issue->getDescription();
447                $issues = $this->parseMergeRequestDescription($projectKey, $issueText);
448                /** @var \helper_plugin_issuelinks_db $db */
449                $db = plugin_load('helper', 'issuelinks_db');
450                $db->saveIssueIssues($issue, $issues);
451            }
452        } catch (\InvalidArgumentException $e) {
453            dbglog($e->getMessage());
454            dbglog($mrData);
455        }
456
457        return $retrievedIssues;
458    }
459
460    /**
461     * Estimate the total amount of results
462     *
463     * @param int $perPage amount of results per page
464     * @param int $default what is returned if the total can not be calculated otherwise
465     *
466     * @return
467     */
468    protected function estimateTotal($perPage, $default)
469    {
470        $headers = $this->dokuHTTPClient->resp_headers;
471
472        if (empty($headers['link'])) {
473            return $default;
474        }
475
476        /** @var \helper_plugin_issuelinks_util $util */
477        $util = plugin_load('helper', 'issuelinks_util');
478        $links = $util->parseHTTPLinkHeaders($headers['link']);
479        preg_match('/page=(\d+)$/', $links['last'], $matches);
480        if (!empty($matches[1])) {
481            return $matches[1] * $perPage;
482        }
483        return $default;
484    }
485
486    /**
487     * Get the total of issues currently imported by retrieveAllIssues()
488     *
489     * This may be an estimated number
490     *
491     * @return int
492     */
493    public function getTotalIssuesBeingImported()
494    {
495        return $this->total;
496    }
497
498    /**
499     * Do all checks to verify that the webhook is expected and actually ours
500     *
501     * @param $webhookBody
502     *
503     * @return true|RequestResult true if the the webhook is our and should be processed RequestResult with explanation
504     *                            otherwise
505     */
506    public function validateWebhook($webhookBody)
507    {
508        global $INPUT;
509        $requestToken = $INPUT->server->str('HTTP_X_GITLAB_TOKEN');
510
511        $data = json_decode($webhookBody, true);
512        dbglog($data, __FILE__ . ': ' . __LINE__);
513        $project = $data['project']['path_with_namespace'];
514
515        /** @var \helper_plugin_issuelinks_db $db */
516        $db = plugin_load('helper', 'issuelinks_db');
517        $secrets = array_column($db->getWebhookSecrets('gitlab', $project), 'secret');
518        $tokenMatches = false;
519        foreach ($secrets as $secret) {
520            if ($secret === $requestToken) {
521                $tokenMatches = true;
522                break;
523            }
524        }
525
526        if (!$tokenMatches) {
527            return new RequestResult(403, 'Token does not match!');
528        }
529
530        return true;
531    }
532
533    /**
534     * Handle the contents of the webhooks body
535     *
536     * @param $webhookBody
537     *
538     * @return RequestResult
539     */
540    public function handleWebhook($webhookBody)
541    {
542        $data = json_decode($webhookBody, true);
543
544        $allowedEventTypes = ['issue', 'merge_request'];
545        if (!in_array($data['event_type'], $allowedEventTypes)) {
546            return new RequestResult(406, 'Invalid event type: ' . $data['event_type']);
547        }
548        $isMergeRequest = $data['event_type'] === 'merge_request';
549        $issue = Issue::getInstance(
550            'gitlab',
551            $data['project']['path_with_namespace'],
552            $data['object_attributes']['iid'],
553            $isMergeRequest
554        );
555        $issue->getFromService();
556
557        return new RequestResult(200, 'OK.');
558    }
559
560    /**
561     * See if this is a hook for issue events, that has been set by us
562     *
563     * @param array $hook the hook data coming from github
564     *
565     * @return bool
566     */
567    protected function isOurIssueHook($hook)
568    {
569        if ($hook['url'] !== self::WEBHOOK_URL) {
570            return false;
571        }
572
573        if (!$hook['enable_ssl_verification']) {
574            return false;
575        }
576
577        if ($hook['push_events']) {
578            return false;
579        }
580
581        if (!$hook['issues_events']) {
582            return false;
583        }
584
585        if (!$hook['merge_requests_events']) {
586            return false;
587        }
588
589        return true;
590    }
591}
592