Passed
Push — master ( cf401d...252787 )
by Michael
02:49
created

GitLab   F

Complexity

Total Complexity 69

Size/Duplication

Total Lines 568
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
dl 0
loc 568
rs 2.8301
c 0
b 0
f 0
wmc 69

25 Methods

Rating   Name   Duplication   Size   Complexity  
A isConfigured() 0 21 4
A makeSingleGitLabGetRequest() 0 3 1
A getProjectIssueSeparator() 0 3 2
A isIssueValid() 0 7 1
A isOurWebhook() 0 8 2
A __construct() 0 9 2
B validateWebhook() 0 25 4
B parseMergeRequestDescription() 0 26 3
D setIssueData() 0 32 9
B retrieveAllIssues() 0 42 5
A retrieveIssue() 0 19 4
A makeGitLabRequest() 0 10 1
A getIssueURL() 0 3 2
A handleAuthorization() 0 14 3
A estimateTotal() 0 16 3
A handleWebhook() 0 18 2
B getListOfAllReposAndHooks() 0 25 4
B createWebhook() 0 25 2
A getTotalIssuesBeingImported() 0 3 1
A parseIssueSyntax() 0 12 2
A hydrateConfigForm() 0 12 2
A deleteWebhook() 0 16 2
A getListOfAllUserOrganisations() 0 7 1
A getUserString() 0 6 1
B isOurIssueHook() 0 23 6

How to fix   Complexity   

Complex Class

Complex classes like GitLab often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GitLab, and based on these observations, apply Extract Interface, too.

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 GitLab extends AbstractService
13
{
14
15
    const SYNTAX = 'gl';
16
    const DISPLAY_NAME = 'GitLab';
17
    const ID = 'gitlab';
18
19
    protected $dokuHTTPClient;
20
    protected $gitlabUrl;
21
    protected $token;
22
    protected $configError;
23
    protected $user;
24
    protected $total;
25
26
    protected function __construct()
27
    {
28
        $this->dokuHTTPClient = new \DokuHTTPClient();
29
        /** @var \helper_plugin_issuelinks_db $db */
30
        $db = plugin_load('helper', 'issuelinks_db');
31
        $gitLabUrl = $db->getKeyValue('gitlab_url');
32
        $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

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