dokuHTTPClient = new \DokuHTTPClient(); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $gitLabUrl = $db->getKeyValue('gitlab_url'); $this->gitlabUrl = $gitLabUrl ? trim($gitLabUrl, '/') : null; $authToken = $db->getKeyValue('gitlab_token'); $this->token = $authToken; } /** * Decide whether the provided issue is valid * * @param Issue $issue * * @return bool */ public static function isIssueValid(Issue $issue) { $summary = $issue->getSummary(); $valid = !blank($summary); $status = $issue->getStatus(); $valid &= !blank($status); return $valid; } /** * Provide the character separation the project name from the issue number, may be different for merge requests * * @param bool $isMergeRequest * * @return string */ public static function getProjectIssueSeparator($isMergeRequest) { return $isMergeRequest ? '!' : '#'; } public static function isOurWebhook() { global $INPUT; if ($INPUT->server->has('HTTP_X_GITLAB_TOKEN')) { return true; } return false; } /** * @return bool */ public function isConfigured() { if (null === $this->gitlabUrl) { $this->configError = 'GitLab URL not set!'; return false; } if (empty($this->token)) { $this->configError = 'Authentication token is missing!'; return false; } try { $user = $this->makeSingleGitLabGetRequest('/user'); } catch (\Exception $e) { $this->configError = 'The GitLab authentication failed with message: ' . hsc($e->getMessage()); return false; } $this->user = $user; return true; } /** * Make a single GET request to GitLab * * @param string $endpoint The endpoint as specifed in the gitlab documentatin (with leading slash!) * * @return array The response as array * @throws HTTPRequestException */ protected function makeSingleGitLabGetRequest($endpoint) { return $this->makeGitLabRequest($endpoint, [], 'GET'); } /** * Make a request to GitLab * * @param string $endpoint The endpoint as specifed in the gitlab documentatin (with leading slash!) * @param array $data * @param string $method the http method to make, defaults to 'GET' * @param array $headers an array of additional headers to send along * * @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 * * @throws HTTPRequestException */ protected function makeGitLabRequest($endpoint, array $data, $method, array $headers = []) { $url = $this->gitlabUrl . '/api/v4' . strtolower($endpoint); $defaultHeaders = [ 'PRIVATE-TOKEN' => $this->token, 'Content-Type' => 'application/json', ]; $requestHeaders = array_merge($defaultHeaders, $headers); return $this->makeHTTPRequest($this->dokuHTTPClient, $url, $requestHeaders, $data, $method); } /** * @param Form $configForm * * @return void */ public function hydrateConfigForm(Form $configForm) { $link = 'https://your.gitlab.host/profile/personal_access_tokens'; if (null !== $this->gitlabUrl) { $url = $this->gitlabUrl . '/profile/personal_access_tokens'; $link = "$url"; } $message = '

'; $message .= $this->configError; $message .= "Please go to $link and generate a new token for this plugin with the api scope."; $message .= '

'; $configForm->addHTML($message); $configForm->addTextInput('gitlab_url', 'GitLab Url')->val($this->gitlabUrl); $configForm->addTextInput('gitlab_token', 'GitLab AccessToken')->useInput(false); } public function handleAuthorization() { global $INPUT; $token = $INPUT->str('gitlab_token'); $url = $INPUT->str('gitlab_url'); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); if (!empty($token)) { $db->saveKeyValuePair('gitlab_token', $token); } if (!empty($url)) { $db->saveKeyValuePair('gitlab_url', $url); } } public function getUserString() { $name = $this->user['name']; $url = $this->user['web_url']; return "$name"; } /** * Get a list of all organisations a user is member of * * @return string[] the identifiers of the organisations */ public function getListOfAllUserOrganisations() { $groups = $this->makeSingleGitLabGetRequest('/groups'); return array_map(function ($group) { return $group['full_path']; }, $groups); } /** * @param $organisation * * @return Repository[] */ public function getListOfAllReposAndHooks($organisation) { $projects = $this->makeSingleGitLabGetRequest("/groups/$organisation/projects?per_page=100"); $repositories = []; foreach ($projects as $project) { $repo = new Repository(); $repo->full_name = $project['path_with_namespace']; $repo->displayName = $project['name']; try { $endpoint = "/projects/$organisation%2F{$project['path']}/hooks?per_page=100"; $repoHooks = $this->makeSingleGitLabGetRequest($endpoint); } catch (HTTPRequestException $e) { $repo->error = (int)$e->getCode(); } $repoHooks = array_filter($repoHooks, [$this, 'isOurIssueHook']); $ourIsseHook = reset($repoHooks); if (!empty($ourIsseHook)) { $repo->hookID = $ourIsseHook['id']; } $repositories[] = $repo; } return $repositories; } public function createWebhook($project) { $secret = md5(openssl_random_pseudo_bytes(32)); $data = [ 'url' => self::WEBHOOK_URL, 'enable_ssl_verification' => true, 'token' => $secret, 'push_events' => false, 'issues_events' => true, 'merge_requests_events' => true, ]; try { $encProject = urlencode($project); $data = $this->makeGitLabRequest("/projects/$encProject/hooks", $data, 'POST'); $status = $this->dokuHTTPClient->status; /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveWebhook('gitlab', $project, $data['id'], $secret); } catch (HTTPRequestException $e) { $data = $e->getMessage(); $status = $e->getCode(); } return ['data' => $data, 'status' => $status]; } public function deleteWebhook($project, $hookid) { /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $encProject = urlencode($project); $endpoint = "/projects/$encProject/hooks/$hookid"; try { $data = $this->makeGitLabRequest($endpoint, [], 'DELETE'); $status = $this->dokuHTTPClient->status; $db->deleteWebhook('gitlab', $project, $hookid); } catch (HTTPRequestException $e) { $data = $e->getMessage(); $status = $e->getCode(); } return ['data' => $data, 'status' => $status]; } /** * Get the url to the given issue at the given project * * @param $projectId * @param $issueId * @param bool $isMergeRequest ignored, GitHub routes the requests correctly by itself * * @return string */ public function getIssueURL($projectId, $issueId, $isMergeRequest) { return $this->gitlabUrl . '/' . $projectId . ($isMergeRequest ? '/merge_requests/' : '/issues/') . $issueId; } /** * @param string $issueSyntax * * @return Issue */ public function parseIssueSyntax($issueSyntax) { $isMergeRequest = false; $projectIssueSeperator = '#'; if (strpos($issueSyntax, '!') !== false) { $isMergeRequest = true; $projectIssueSeperator = '!'; } list($projectKey, $issueId) = explode($projectIssueSeperator, $issueSyntax); $issue = Issue::getInstance('gitlab', $projectKey, $issueId, $isMergeRequest); $issue->getFromDB(); return $issue; } public function retrieveIssue(Issue $issue) { $notable = $issue->isMergeRequest() ? 'merge_requests' : 'issues'; $repoUrlEnc = rawurlencode($issue->getProject()); $endpoint = '/projects/' . $repoUrlEnc . '/' . $notable . '/' . $issue->getKey(); $info = $this->makeSingleGitLabGetRequest($endpoint); $this->setIssueData($issue, $info); if ($issue->isMergeRequest()) { $mergeRequestText = $issue->getSummary() . ' ' . $issue->getDescription(); $issues = $this->parseMergeRequestDescription($issue->getProject(), $mergeRequestText); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveIssueIssues($issue, $issues); } $endpoint = '/projects/' . $repoUrlEnc . '/labels'; $projectLabelData = $this->makeSingleGitLabGetRequest($endpoint); foreach ($projectLabelData as $labelData) { $issue->setLabelData($labelData['name'], $labelData['color']); } } /** * @param Issue $issue * @param array $info */ protected function setIssueData(Issue $issue, $info) { $issue->setSummary($info['title']); $issue->setDescription($info['description']); $issue->setType($this->getTypeFromLabels($info['labels'])); $issue->setStatus($info['state']); $issue->setUpdated($info['updated_at']); $issue->setLabels($info['labels']); if (!empty($info['milestone'])) { $issue->setVersions([$info['milestone']['title']]); } if (!empty($info['milestone'])) { $issue->setDuedate($info['duedate']); } if (!empty($info['assignee'])) { $issue->setAssignee($info['assignee']['name'], $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'; } /** * 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 */ public function parseMergeRequestDescription($currentProject, $description) { $issues = []; $issueOwnRepoPattern = '/(?:\W|^)#([1-9]\d*)\b/'; preg_match_all($issueOwnRepoPattern, $description, $gitlabMatches); foreach ($gitlabMatches[1] as $issueId) { $issues[] = [ 'service' => 'gitlab', 'project' => $currentProject, 'issueId' => $issueId, ]; } $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; } public function retrieveAllIssues($projectKey, &$startat = 0) { $perPage = 100; $page = ceil(($startat + 1) / $perPage); $endpoint = '/projects/' . urlencode($projectKey) . "/issues?page=$page&per_page=$perPage"; $issues = $this->makeSingleGitLabGetRequest($endpoint); $this->total = $this->estimateTotal($perPage, count($issues)); $mrEndpoint = '/projects/' . urlencode($projectKey) . "/merge_requests?page=$page&per_page=$perPage"; $mrs = $this->makeSingleGitLabGetRequest($mrEndpoint); $this->total += $this->estimateTotal($perPage, count($mrs)); $retrievedIssues = []; try { foreach ($issues as $issueData) { $issue = Issue::getInstance('gitlab', $projectKey, $issueData['iid'], false); $this->setIssueData($issue, $issueData); $issue->saveToDB(); $retrievedIssues[] = $issue; } $startat += $perPage; } catch (\InvalidArgumentException $e) { dbglog($e->getMessage()); dbglog($issueData); } try { foreach ($mrs as $mrData) { $issue = Issue::getInstance('gitlab', $projectKey, $mrData['iid'], true); $this->setIssueData($issue, $mrData); $issue->saveToDB(); $retrievedIssues[] = $issue; $issueText = $issue->getSummary() . ' ' . $issue->getDescription(); $issues = $this->parseMergeRequestDescription($projectKey, $issueText); /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $db->saveIssueIssues($issue, $issues); } } catch (\InvalidArgumentException $e) { dbglog($e->getMessage()); dbglog($mrData); } 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; } /** * Get the total of issues currently imported by retrieveAllIssues() * * This may be an estimated number * * @return int */ public function getTotalIssuesBeingImported() { return $this->total; } /** * Do all checks to verify that the webhook is expected and actually ours * * @param $webhookBody * * @return true|RequestResult true if the the webhook is our and should be processed RequestResult with explanation * otherwise */ public function validateWebhook($webhookBody) { global $INPUT; $requestToken = $INPUT->server->str('HTTP_X_GITLAB_TOKEN'); $data = json_decode($webhookBody, true); dbglog($data, __FILE__ . ': ' . __LINE__); $project = $data['project']['path_with_namespace']; /** @var \helper_plugin_issuelinks_db $db */ $db = plugin_load('helper', 'issuelinks_db'); $secrets = array_column($db->getWebhookSecrets('gitlab', $project), 'secret'); $tokenMatches = false; foreach ($secrets as $secret) { if ($secret === $requestToken) { $tokenMatches = true; break; } } if (!$tokenMatches) { return new RequestResult(403, 'Token does not match!'); } return true; } /** * Handle the contents of the webhooks body * * @param $webhookBody * * @return RequestResult */ public function handleWebhook($webhookBody) { $data = json_decode($webhookBody, true); $allowedEventTypes = ['issue', 'merge_request']; if (!in_array($data['event_type'], $allowedEventTypes)) { return new RequestResult(406, 'Invalid event type: ' . $data['event_type']); } $isMergeRequest = $data['event_type'] === 'merge_request'; $issue = Issue::getInstance( 'gitlab', $data['project']['path_with_namespace'], $data['object_attributes']['iid'], $isMergeRequest ); $issue->getFromService(); return new RequestResult(200, 'OK.'); } /** * 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['url'] !== self::WEBHOOK_URL) { return false; } if (!$hook['enable_ssl_verification']) { return false; } if ($hook['push_events']) { return false; } if (!$hook['issues_events']) { return false; } if (!$hook['merge_requests_events']) { return false; } return true; } }