GitLab::handleWebhook()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 2
eloc 12
c 1
b 0
f 1
nc 2
nop 1
dl 0
loc 18
ccs 0
cts 13
cp 0
crap 6
rs 9.8666
1
<?php
2
3
namespace dokuwiki\plugin\issuelinks\services;
4
5
use dokuwiki\Form\Form;
6
use dokuwiki\plugin\issuelinks\classes\HTTPRequestException;
7
use dokuwiki\plugin\issuelinks\classes\Issue;
8
use dokuwiki\plugin\issuelinks\classes\Repository;
9
use dokuwiki\plugin\issuelinks\classes\RequestResult;
10
11
class 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 1
    protected function __construct()
26
    {
27 1
        $this->dokuHTTPClient = new \DokuHTTPClient();
28
        /** @var \helper_plugin_issuelinks_db $db */
29 1
        $db = plugin_load('helper', 'issuelinks_db');
30 1
        $gitLabUrl = $db->getKeyValue('gitlab_url');
31 1
        $this->gitlabUrl = $gitLabUrl ? trim($gitLabUrl, '/') : null;
0 ignored issues
show
Bug introduced by
It seems like $gitLabUrl can also be of type true; however, parameter $str of trim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

31
        $this->gitlabUrl = $gitLabUrl ? trim(/** @scrutinizer ignore-type */ $gitLabUrl, '/') : null;
Loading history...
32 1
        $authToken = $db->getKeyValue('gitlab_token');
33 1
        $this->token = $authToken;
34 1
    }
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;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $valid returns the type integer which is incompatible with the documented return type boolean.
Loading history...
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
     */
0 ignored issues
show
Documentation Bug introduced by
The doc comment $errorsToBeReturned at position 0 could not be parsed: Unknown type name '$errorsToBeReturned' at position 0 in $errorsToBeReturned.
Loading history...
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']);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $repoHooks seems to be defined later in this foreach loop on line 215. Are you sure it is defined here?
Loading history...
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 1
    public function parseIssueSyntax($issueSyntax)
297
    {
298 1
        $isMergeRequest = false;
299 1
        $projectIssueSeperator = '#';
300 1
        if (strpos($issueSyntax, '!') !== false) {
301 1
            $isMergeRequest = true;
302 1
            $projectIssueSeperator = '!';
303
        }
304 1
        list($projectKey, $issueId) = explode($projectIssueSeperator, $issueSyntax);
305 1
        $issue = Issue::getInstance('gitlab', $projectKey, $issueId, $isMergeRequest);
306 1
        $issue->getFromDB();
307 1
        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