cosmocode/dokuwiki-plugin-issuelinks

View on GitHub
services/GitHub.service.php

Summary

Maintainability
F
3 days
Test Coverage
<?php

namespace dokuwiki\plugin\issuelinks\services;

use dokuwiki\Form\Form;
use dokuwiki\plugin\issuelinks\classes\ExternalServerException;
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 GitHub extends AbstractService
{


    const SYNTAX = 'gh';
    const DISPLAY_NAME = 'GitHub';
    const ID = 'github';
    const WEBHOOK_UA = 'GitHub-Hookshot/';
    protected $configError = '';
    protected $user = [];
    protected $total = null;
    protected $orgs;
    /** @var \DokuHTTPClient */
    protected $dokuHTTPClient;
    protected $githubUrl = 'https://api.github.com';
    private $scopes = ['admin:repo_hook', 'read:org', 'public_repo'];

    protected function __construct()
    {
        $this->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 = '<a href="https://github.com/settings/tokens">https://github.com/settings/tokens/</a>';
        $message = '<p>';
        $message .= $this->configError;
        $message .= " Please go to $link and generate a new token for this plugin with the scopes $scopes.";
        $message .= '</p>';
        $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;
    }
}