cosmocode/dokuwiki-plugin-issuelinks

View on GitHub
services/GitLab.service.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

namespace dokuwiki\plugin\issuelinks\services;

use dokuwiki\Form\Form;
use dokuwiki\plugin\issuelinks\classes\HTTPRequestException;
use dokuwiki\plugin\issuelinks\classes\Issue;
use dokuwiki\plugin\issuelinks\classes\Repository;
use dokuwiki\plugin\issuelinks\classes\RequestResult;

class GitLab extends AbstractService
{

    const SYNTAX = 'gl';
    const DISPLAY_NAME = 'GitLab';
    const ID = 'gitlab';

    protected $dokuHTTPClient;
    protected $gitlabUrl;
    protected $token;
    protected $configError;
    protected $user;
    protected $total;

    protected function __construct()
    {
        $this->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://<em>your.gitlab.host</em>/profile/personal_access_tokens';
        if (null !== $this->gitlabUrl) {
            $url = $this->gitlabUrl . '/profile/personal_access_tokens';
            $link = "<a href=\"$url\">$url</a>";
        }

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

        $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 "<a href=\"$url\" target=\"_blank\">$name</a>";
    }

    /**
     * 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;
    }
}