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