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