dokuHTTPClient = new \DokuHTTPClient(); } public static function getProjectIssueSeparator($isMergeRequest) { return '#'; } public static function isOurWebhook() { global $INPUT; $userAgent = $INPUT->server->str('HTTP_USER_AGENT'); return strpos($userAgent, self::WEBHOOK_UA) === 0; } public static function isIssueValid(Issue $issue) { $summary = $issue->getSummary(); $valid = !blank($summary); $status = $issue->getStatus(); $valid &= !blank($status); $type = $issue->getType(); $valid &= !blank($type); return $valid; } public function getIssueURL($projectId, $issueId, $isMergeRequest) { return 'https://github.com' . '/' . $projectId . '/issues/' . $issueId; } public function parseIssueSyntax($issueSyntax) { list($projectKey, $issueId) = explode('#', $issueSyntax); // try to load as pull request $issue = Issue::getInstance('github', $projectKey, $issueId, true); $isPullRequest = $issue->getFromDB(); if ($isPullRequest) { return $issue; } // not a pull request, retrieve it as normal issue $issue = Issue::getInstance('github', $projectKey, $issueId, false); $issue->getFromDB(); return $issue; } /** * @return bool */ public function isConfigured() { /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $authToken = $db->getKeyValue('github_token'); if (empty($authToken)) { $this->configError = 'Authentication token is missing!'; return false; } try { $user = $this->makeGitHubGETRequest('/user'); // $status = $this->connector->getLastStatus(); } catch (\Exception $e) { $this->configError = 'The GitHub authentication failed with message: ' . hsc($e->getMessage()); return false; } $this->user = $user; $headers = $this->dokuHTTPClient->resp_headers; $missing_scopes = array_diff($this->scopes, explode(', ', $headers['x-oauth-scopes'])); if (count($missing_scopes) !== 0) { $this->configError = 'Scopes "' . implode(', ', $missing_scopes) . '" are missing!'; return false; } return true; } /** * * todo: ensure correct headers are set: https://developer.github.com/v3/#current-version * * @param string $endpoint the endpoint as defined in the GitHub documentation. With leading and trailing slashes * @param int|null $max do not make more requests after this number of items have been retrieved * * @return array The decoded response-text * @throws HTTPRequestException */ protected function makeGitHubGETRequest($endpoint, $max = null) { $results = []; $waittime = 0; /** @var \helper_plugin_issuelinks_util $utils */ $utils = plugin_load('helper', 'issuelinks_util'); do { usleep($waittime); try { $data = $this->makeGitHubRequest($endpoint, [], 'GET', []); } catch (ExternalServerException $e) { if ($waittime >= 500) { msg('Error repeats. Aborting Requests.', -1); dbglog('Error repeats. Aborting Requests.', -1); break; } $waittime += 100; msg("Server Error occured. Waiting $waittime ms between requests and repeating request.", -1); dbglog("Server Error occured. Waiting $waittime ms between requests and repeating request.", -1); continue; } if ($this->dokuHTTPClient->resp_headers['x-ratelimit-remaining'] < 500) { msg(sprintf( $utils->getLang('error:system too many requests'), dformat($this->dokuHTTPClient->resp_headers['x-ratelimit-reset']) ), -1); break; } $results = array_merge($results, $data); if (empty($this->dokuHTTPClient->resp_headers['link'])) { break; } $links = $utils->parseHTTPLinkHeaders($this->dokuHTTPClient->resp_headers['link']); if (empty($links['next'])) { break; } $endpoint = substr($links['next'], strlen($this->githubUrl)); } while (empty($max) || count($results) < $max); return $results; } /** * @param string $endpoint * @param array $data * @param string $method * @param array $headers * * @return mixed * * @throws HTTPRequestException|ExternalServerException */ protected function makeGitHubRequest($endpoint, $data, $method, $headers = []) { /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $authToken = $db->getKeyValue('github_token'); $defaultHeaders = [ 'Authorization' => "token $authToken", 'Content-Type' => 'application/json', ]; $requestHeaders = array_merge($defaultHeaders, $headers); // todo ensure correct slashes everywhere $url = $this->githubUrl . $endpoint; return $this->makeHTTPRequest($this->dokuHTTPClient, $url, $requestHeaders, $data, $method); } public function hydrateConfigForm(Form $configForm) { $scopes = implode(', ', $this->scopes); $link = 'https://github.com/settings/tokens/'; $message = '
'; $message .= $this->configError; $message .= " Please go to $link and generate a new token for this plugin with the scopes $scopes."; $message .= '
'; $configForm->addHTML($message); $configForm->addTextInput('githubToken', 'GitHub AccessToken')->useInput(false); } public function handleAuthorization() { global $INPUT; $token = $INPUT->str('githubToken'); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveKeyValuePair('github_token', $token); } /** * @inheritdoc */ public function getListOfAllReposAndHooks($organisation) { $endpoint = "/orgs/$organisation/repos"; try { $repos = $this->makeGitHubGETRequest($endpoint); } catch (HTTPRequestException $e) { msg($e->getMessage() . ' ' . $e->getCode(), -1); return []; } $projects = []; foreach ($repos as $repoData) { $repo = new Repository(); $repo->full_name = $repoData['full_name']; $repo->displayName = $repoData['name']; $endpoint = "/repos/$repoData[full_name]/hooks"; try { $repoHooks = $this->makeGitHubGETRequest($endpoint); } catch (HTTPRequestException $e) { $repoHooks = []; $repo->error = 403; } $repoHooks = array_filter($repoHooks, [$this, 'isOurIssueHook']); $ourIsseHook = reset($repoHooks); if (!empty($ourIsseHook)) { $repo->hookID = $ourIsseHook['id']; } $projects[] = $repo; } return $projects; } public function deleteWebhook($project, $hookid) { try { $data = $this->makeGitHubRequest("/repos/$project/hooks/$hookid", [], 'DELETE'); $status = $this->dokuHTTPClient->status; /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->deleteWebhook('github', $project, $hookid); } catch (HTTPRequestException $e) { $data = $e->getMessage(); $status = $e->getCode(); } return ['data' => $data, 'status' => $status]; } public function createWebhook($project) { $secret = md5(openssl_random_pseudo_bytes(32)); $config = [ "url" => self::WEBHOOK_URL, "content_type" => 'json', "insecure_ssl" => 0, "secret" => $secret, ]; $data = [ "name" => "web", "config" => $config, "active" => true, 'events' => ['issues', 'issue_comment', 'pull_request'], ]; try { $data = $this->makeGitHubRequest("/repos/$project/hooks", $data, 'POST'); $status = $this->dokuHTTPClient->status; $id = $data['id']; /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveWebhook('github', $project, $id, $secret); } catch (HTTPRequestException $e) { $data = $e->getMessage(); $status = $e->getCode(); } return ['data' => $data, 'status' => $status]; } public function validateWebhook($webhookBody) { $data = json_decode($webhookBody, true); if (!$this->isSignatureValid($webhookBody, $data['repository']['full_name'])) { return new RequestResult(403, 'Signature invalid or missing!'); } return true; } /** * Check if the signature in the header provided by github is valid by using a stored secret * * * Known issue: * * We have to cycle through the webhooks/secrets stored for a given repo because the hookid is not in the * request * * @param string $body The unaltered payload of the request * @param string $repo_id the repo id (in the format of "organisation/repo-name") * * @return bool wether the provided signature checks out against a stored one */ protected function isSignatureValid($body, $repo_id) { global $INPUT; if (!$INPUT->server->has('HTTP_X_HUB_SIGNATURE')) { return false; } list($algo, $signature_github) = explode('=', $INPUT->server->str('HTTP_X_HUB_SIGNATURE')); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $secrets = $db->getWebhookSecrets('github', $repo_id); foreach ($secrets as $secret) { $signature_local = hash_hmac($algo, $body, $secret['secret']); if (hash_equals($signature_local, $signature_github)) { return true; } } return false; } public function handleWebhook($webhookBody) { global $INPUT; $data = json_decode($webhookBody, true); $event = $INPUT->server->str('HTTP_X_GITHUB_EVENT'); if ($event === 'ping') { return new RequestResult(202, 'Webhook ping successful. Pings are not processed.'); } if (!$this->saveIssue($data)) { return new RequestResult(500, 'There was an error saving the issue.'); } return new RequestResult(200, 'OK'); } /** * Handle the webhook event, triggered by an updated or created issue * * @param array $data * * @return bool whether saving was successful * * @throws \InvalidArgumentException * @throws HTTPRequestException */ protected function saveIssue($data) { $issue = Issue::getInstance( 'github', $data['repository']['full_name'], $data['issue']['number'], false, true ); $this->setIssueData($issue, $data['issue']); return $issue->saveToDB(); } /** * @param Issue $issue * @param array $info */ protected function setIssueData(Issue $issue, $info) { $issue->setSummary($info['title']); $issue->setDescription($info['body']); $labels = []; foreach ($info['labels'] as $label) { $labels[] = $label['name']; $issue->setLabelData($label['name'], '#' . $label['color']); } $issue->setType($this->getTypeFromLabels($labels)); $issue->setStatus(isset($info['merged']) ? 'merged' : $info['state']); $issue->setUpdated($info['updated_at']); if (!empty($info['milestone'])) { $issue->setVersions([$info['milestone']['title']]); } $issue->setLabels($labels); if ($info['assignee']) { $issue->setAssignee($info['assignee']['login'], $info['assignee']['avatar_url']); } } protected function getTypeFromLabels(array $labels) { $bugTypeLabels = ['bug']; $improvementTypeLabels = ['enhancement']; $storyTypeLabels = ['feature']; if (count(array_intersect($labels, $bugTypeLabels))) { return 'bug'; } if (count(array_intersect($labels, $improvementTypeLabels))) { return 'improvement'; } if (count(array_intersect($labels, $storyTypeLabels))) { return 'story'; } return 'unknown'; } public function getListOfAllUserOrganisations() { if ($this->orgs === null) { $endpoint = '/user/orgs'; try { $this->orgs = $this->makeGitHubGETRequest($endpoint); } catch (\Throwable $e) { $this->orgs = []; msg(hsc($e->getMessage()), -1); } } // fixme: add 'user repos'! return array_map(function ($org) { return $org['login']; }, $this->orgs); } public function getUserString() { return $this->user['login']; } public function retrieveIssue(Issue $issue) { $repo = $issue->getProject(); $issueNumber = $issue->getKey(); $endpoint = '/repos/' . $repo . '/issues/' . $issueNumber; $result = $this->makeGitHubGETRequest($endpoint); $this->setIssueData($issue, $result); if (isset($result['pull_request'])) { $issue->isMergeRequest(true); $endpoint = '/repos/' . $repo . '/pulls/' . $issueNumber; $result = $this->makeGitHubGETRequest($endpoint); $issue->setStatus($result['merged'] ? 'merged' : $result['state']); $mergeRequestText = $issue->getSummary() . ' ' . $issue->getDescription(); $issues = $this->parseMergeRequestDescription($repo, $mergeRequestText); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveIssueIssues($issue, $issues); } } /** * Parse a string for issue-ids * * Currently only parses issues for the same repo and jira issues * * @param string $currentProject * @param string $description * * @return array */ protected function parseMergeRequestDescription($currentProject, $description) { $issues = []; $issueOwnRepoPattern = '/(?:\W|^)#([1-9]\d*)\b/'; preg_match_all($issueOwnRepoPattern, $description, $githubMatches); foreach ($githubMatches[1] as $issueId) { $issues[] = [ 'service' => 'github', 'project' => $currentProject, 'issueId' => $issueId, ]; } // FIXME: this should be done by JIRA service class $jiraMatches = []; $jiraPattern = '/[A-Z0-9]+-[1-9]\d*/'; preg_match_all($jiraPattern, $description, $jiraMatches); foreach ($jiraMatches[0] as $match) { list($project, $issueId) = explode('-', $match); $issues[] = [ 'service' => 'jira', 'project' => $project, 'issueId' => $issueId, ]; } return $issues; } /** * * @see https://developer.github.com/v3/issues/#list-issues-for-a-repository * * @param string $projectKey The short-key of the project to be imported * @param int $startat The offset from the last Element from which to start importing * * @return array The issues, suitable to be saved into the db * @throws HTTPRequestException * * // FIXME: set Header application/vnd.github.symmetra-preview+json ? */ public function retrieveAllIssues($projectKey, &$startat = 0) { $perPage = 30; $page = ceil(($startat + 1) / $perPage); // FIXME: implent `since` parameter? $endpoint = "/repos/$projectKey/issues?state=all&page=$page"; $issues = $this->makeGitHubGETRequest($endpoint); if (!is_array($issues)) { return []; } if (empty($this->total)) { $this->total = $this->estimateTotal($perPage, count($issues)); } $retrievedIssues = []; foreach ($issues as $issueData) { try { $issue = Issue::getInstance( 'github', $projectKey, $issueData['number'], !empty($issueData['pull_request']) ); } catch (\InvalidArgumentException $e) { continue; } $this->setIssueData($issue, $issueData); $issue->saveToDB(); $retrievedIssues[] = $issue; } $startat += $perPage; return $retrievedIssues; } /** * Estimate the total amount of results * * @param int $perPage amount of results per page * @param int $default what is returned if the total can not be calculated otherwise * * @return */ protected function estimateTotal($perPage, $default) { $headers = $this->dokuHTTPClient->resp_headers; if (empty($headers['link'])) { return $default; } /** @var \helper_plugin_issuelinks_util $util */ $util = plugin_load('helper', 'issuelinks_util'); $links = $util->parseHTTPLinkHeaders($headers['link']); preg_match('/page=(\d+)$/', $links['last'], $matches); if (!empty($matches[1])) { return $matches[1] * $perPage; } return $default; } /** * @return mixed */ public function getTotalIssuesBeingImported() { return $this->total; } /** * See if this is a hook for issue events, that has been set by us * * @param array $hook the hook data coming from github * * @return bool */ protected function isOurIssueHook($hook) { if ($hook['config']['url'] !== self::WEBHOOK_URL) { return false; } if ($hook['config']['content_type'] !== 'json') { return false; } if ($hook['config']['insecure_ssl'] !== '0') { return false; } if (!$hook['active']) { return false; } $missingEvents = array_diff($hook['events'], ['issues', 'issue_comment', 'pull_request']); if (count($hook['events']) !== 3 || $missingEvents) { return false; } return true; } }