1<?php
2/**
3 * Created by IntelliJ IDEA.
4 * User: michael
5 * Date: 4/16/18
6 * Time: 7:57 AM
7 */
8
9namespace dokuwiki\plugin\issuelinks\services;
10
11use dokuwiki\Form\Form;
12use dokuwiki\plugin\issuelinks\classes\Issue;
13use dokuwiki\plugin\issuelinks\classes\Repository;
14use dokuwiki\plugin\issuelinks\classes\RequestResult;
15
16class Jira extends AbstractService
17{
18
19    const SYNTAX = 'jira';
20    const DISPLAY_NAME = 'Jira';
21    const ID = 'jira';
22
23    protected $dokuHTTPClient;
24    protected $jiraUrl;
25    protected $token;
26    protected $configError;
27    protected $authUser;
28    protected $total;
29
30    // FIXME should this be rather protected?
31    public function __construct()
32    {
33        $this->dokuHTTPClient = new \DokuHTTPClient();
34        /** @var \helper_plugin_issuelinks_db $db */
35        $db = plugin_load('helper', 'issuelinks_db');
36        $jiraUrl = $db->getKeyValue('jira_url');
37        $this->jiraUrl = $jiraUrl ? trim($jiraUrl, '/') : null;
38        $authToken = $db->getKeyValue('jira_token');
39        $this->token = $authToken;
40        $jiraUser = $db->getKeyValue('jira_user');
41        $this->authUser = $jiraUser;
42    }
43
44    /**
45     * Decide whether the provided issue is valid
46     *
47     * @param Issue $issue
48     *
49     * @return bool
50     */
51    public static function isIssueValid(Issue $issue)
52    {
53        $summary = $issue->getSummary();
54        $valid = !blank($summary);
55        $status = $issue->getStatus();
56        $valid &= !blank($status);
57        $type = $issue->getType();
58        $valid &= !blank($type);
59        return $valid;
60    }
61
62    /**
63     * Provide the character separation the project name from the issue number, may be different for merge requests
64     *
65     * @param bool $isMergeRequest
66     *
67     * @return string
68     */
69    public static function getProjectIssueSeparator($isMergeRequest)
70    {
71        return '-';
72    }
73
74    public static function isOurWebhook()
75    {
76        global $INPUT;
77        $userAgent = $INPUT->server->str('HTTP_USER_AGENT');
78        return strpos($userAgent, 'Atlassian') === 0;
79    }
80
81    /**
82     * Get the url to the given issue at the given project
83     *
84     * @param      $projectId
85     * @param      $issueId
86     * @param bool $isMergeRequest ignored, GitHub routes the requests correctly by itself
87     *
88     * @return string
89     */
90    public function getIssueURL($projectId, $issueId, $isMergeRequest)
91    {
92        return $this->jiraUrl . '/browse/' . $projectId . '-' . $issueId;
93    }
94
95    /**
96     * @param string $issueSyntax
97     *
98     * @return Issue
99     */
100    public function parseIssueSyntax($issueSyntax)
101    {
102        if (preg_match('/^\w+\-[1-9]\d*$/', $issueSyntax) !== 1) {
103            return null;
104        }
105
106        list($projectKey, $issueNumber) = explode('-', $issueSyntax);
107
108        $issue = Issue::getInstance('jira', $projectKey, $issueNumber, false);
109        $issue->getFromDB();
110
111        return $issue;
112    }
113
114    /**
115     * @return bool
116     */
117    public function isConfigured()
118    {
119        if (null === $this->jiraUrl) {
120            $this->configError = 'Jira URL not set!';
121            return false;
122        }
123
124        if (empty($this->token)) {
125            $this->configError = 'Authentication token is missing!';
126            return false;
127        }
128
129        if (empty($this->authUser)) {
130            $this->configError = 'Authentication user is missing!';
131            return false;
132        }
133
134        try {
135            $this->makeJiraRequest('/rest/webhooks/1.0/webhook', [], 'GET');
136//            $user = $this->makeJiraRequest('/rest/api/2/user', [], 'GET');
137        } catch (\Exception $e) {
138            $this->configError = 'The Jira authentication failed with message: ' . hsc($e->getMessage());
139            return false;
140        }
141
142        return true;
143    }
144
145    protected function makeJiraRequest($endpoint, array $data, $method, array $headers = [])
146    {
147        $url = $this->jiraUrl . $endpoint;
148        $defaultHeaders = [
149            'Authorization' => 'Basic ' . base64_encode("$this->authUser:$this->token"),
150            'Content-Type' => 'application/json',
151        ];
152
153        $requestHeaders = array_merge($defaultHeaders, $headers);
154
155        return $this->makeHTTPRequest($this->dokuHTTPClient, $url, $requestHeaders, $data, $method);
156    }
157
158    /**
159     * @param Form $configForm
160     *
161     * @return void
162     */
163    public function hydrateConfigForm(Form $configForm)
164    {
165        $url = 'https://id.atlassian.com/manage/api-tokens';
166        $link = "<a href=\"$url\">$url</a>";
167        $message = "Please go to $link and generate a new token for this plugin.";
168        $configForm->addHTML("<p>{$this->configError} $message</p>");
169        $configForm->addTextInput('jira_url', 'Jira Url')->val($this->jiraUrl);
170        $configForm->addTextInput('jira_user', 'Jira User')
171            ->val($this->authUser)
172            ->attr('placeholder', 'username@company.com');
173        $configForm->addPasswordInput('jira_token', 'Jira AccessToken')->useInput(false);
174    }
175
176    public function handleAuthorization()
177    {
178        global $INPUT;
179
180        $token = $INPUT->str('jira_token');
181        $url = $INPUT->str('jira_url');
182        $user = $INPUT->str('jira_user');
183
184        /** @var \helper_plugin_issuelinks_db $db */
185        $db = plugin_load('helper', 'issuelinks_db');
186        if (!empty($token)) {
187            $db->saveKeyValuePair('jira_token', $token);
188        }
189        if (!empty($url)) {
190            $db->saveKeyValuePair('jira_url', $url);
191        }
192        if (!empty($user)) {
193            $db->saveKeyValuePair('jira_user', $user);
194        }
195    }
196
197    public function getUserString()
198    {
199        return hsc($this->authUser);
200    }
201
202    public function getRepoPageText()
203    {
204        /** @var \helper_plugin_issuelinks_db $db */
205        $db = plugin_load('helper', 'issuelinks_db');
206        $jira_url = $db->getKeyValue('jira_url');
207        $href = $jira_url . '/plugins/servlet/webhooks';
208        $msg = $db->getLang('jira:webhook settings link');
209        $link = "<a href=\"$href\" target='_blank'>$msg</a>";
210        return $link;
211    }
212
213    public function retrieveIssue(Issue $issue)
214    {
215        // FIXME: somehow validate that we are allowed to retrieve that issue
216
217        $projectKey = $issue->getProject();
218
219        /** @var \helper_plugin_issuelinks_db $db */
220        $db = plugin_load('helper', 'issuelinks_db');
221        $webhooks = $db->getWebhooks('jira');
222        $allowedRepos = explode(',', $webhooks[0]['repository_id']);
223
224        if (!in_array($projectKey, $allowedRepos, true)) {
225//            Jira Projects must be enabled as Webhook for on-demand fetching
226            return;
227        }
228
229
230        $issueNumber = $issue->getKey();
231        $endpoint = "/rest/api/2/issue/$projectKey-$issueNumber";
232
233        $issueData = $this->makeJiraRequest($endpoint, [], 'GET');
234        $this->setIssueData($issue, $issueData);
235    }
236
237    protected function setIssueData(Issue $issue, $issueData)
238    {
239        $issue->setSummary($issueData['fields']['summary']);
240        $issue->setStatus($issueData['fields']['status']['name']);
241        $issue->setDescription($issueData['fields']['description']);
242        $issue->setType($issueData['fields']['issuetype']['name']);
243        $issue->setPriority($issueData['fields']['priority']['name']);
244
245        $issue->setUpdated($issueData['fields']['updated']);
246        $versions = array_column($issueData['fields']['fixVersions'], 'name');
247        $issue->setVersions($versions);
248        $components = array_column($issueData['fields']['components'], 'name');
249        $issue->setComponents($components);
250        $issue->setLabels($issueData['fields']['labels']);
251
252        if ($issueData['fields']['assignee']) {
253            $assignee = $issueData['fields']['assignee'];
254            $issue->setAssignee($assignee['displayName'], $assignee['avatarUrls']['48x48']);
255        }
256
257        if ($issueData['fields']['duedate']) {
258            $issue->setDuedate($issueData['fields']['duedate']);
259        }
260
261        // FIXME: check and handle these fields:
262//        $issue->setParent($issueData['fields']['parent']['key']);
263    }
264
265    public function retrieveAllIssues($projectKey, &$startat = 0)
266    {
267        $jqlQuery = "project=$projectKey";
268//        $jqlQuery = urlencode("project=$projectKey ORDER BY updated DESC");
269        $endpoint = '/rest/api/2/search?jql=' . $jqlQuery . '&maxResults=50&startAt=' . $startat;
270        $result = $this->makeJiraRequest($endpoint, [], 'GET');
271
272        if (empty($result['issues'])) {
273            return [];
274        }
275
276        $this->total = $result['total'];
277
278        $startat += $result['maxResults'];
279
280        $retrievedIssues = [];
281        foreach ($result['issues'] as $issueData) {
282            list(, $issueNumber) = explode('-', $issueData['key']);
283            try {
284                $issue = Issue::getInstance('jira', $projectKey, $issueNumber, false);
285            } catch (\InvalidArgumentException $e) {
286                continue;
287            }
288            $this->setIssueData($issue, $issueData);
289            $issue->saveToDB();
290            $retrievedIssues[] = $issue;
291        }
292        return $retrievedIssues;
293    }
294
295    /**
296     * Get the total of issues currently imported by retrieveAllIssues()
297     *
298     * This may be an estimated number
299     *
300     * @return int
301     */
302    public function getTotalIssuesBeingImported()
303    {
304        return $this->total;
305    }
306
307    /**
308     * Get a list of all organisations a user is member of
309     *
310     * @return string[] the identifiers of the organisations
311     */
312    public function getListOfAllUserOrganisations()
313    {
314        return ['All projects'];
315    }
316
317    /**
318     * @param $organisation
319     *
320     * @return Repository[]
321     */
322    public function getListOfAllReposAndHooks($organisation)
323    {
324        /** @var \helper_plugin_issuelinks_db $db */
325        $db = plugin_load('helper', 'issuelinks_db');
326        $webhooks = $db->getWebhooks('jira');
327        $subscribedProjects = [];
328        if (!empty($webhooks)) {
329            $subscribedProjects = explode(',', $webhooks[0]['repository_id']);
330        }
331
332        $projects = $this->makeJiraRequest('/rest/api/2/project', [], 'GET');
333
334        $repositories = [];
335        foreach ($projects as $project) {
336            $repo = new Repository();
337            $repo->displayName = $project['name'];
338            $repo->full_name = $project['key'];
339            if (in_array($project['key'], $subscribedProjects)) {
340                $repo->hookID = 1;
341            }
342            $repositories[] = $repo;
343        }
344        return $repositories;
345    }
346
347    public function createWebhook($project)
348    {
349        // get old webhook id
350        /** @var \helper_plugin_issuelinks_db $db */
351        $db = plugin_load('helper', 'issuelinks_db');
352        $webhooks = $db->getWebhooks('jira');
353        $projects = [];
354        if (!empty($webhooks)) {
355            $oldID = $webhooks[0]['id'];
356            // get current webhook projects
357            $projects = explode(',', $webhooks[0]['repository_id']);
358            // remove old webhook
359            $this->makeJiraRequest('/rest/webhooks/1.0/webhook/' . $oldID, [], 'DELETE');
360            // delete old webhook from database
361            $db->deleteWebhook('jira', $webhooks[0]['repository_id'], $oldID);
362        }
363
364        // add new project
365        $projects[] = $project;
366        $projects = array_filter(array_unique($projects));
367        $projectsString = implode(',', $projects);
368
369        // add new webhooks
370        global $conf;
371        $payload = [
372            'name' => 'dokuwiki plugin issuelinks for Wiki: ' . $conf['title'],
373            'url' => self::WEBHOOK_URL,
374            'events' => [
375                'jira:issue_created',
376                'jira:issue_updated',
377            ],
378            'description' => 'dokuwiki plugin issuelinks for Wiki: ' . $conf['title'],
379            'jqlFilter' => "project in ($projectsString)",
380            'excludeIssueDetails' => 'false',
381        ];
382
383        $response = $this->makeJiraRequest('/rest/webhooks/1.0/webhook', $payload, 'POST');
384
385        $selfLink = $response['self'];
386        $newWebhookID = substr($selfLink, strrpos($selfLink, '/') + 1);
387
388        // store new webhook to database
389        $db->saveWebhook('jira', $projectsString, $newWebhookID, 'jira rest webhooks have no secrets :/');
390        return ['status' => 200, 'data' => ['id' => $newWebhookID]];
391    }
392
393    /**
394     * Delete our webhook in a source repository
395     *
396     * @param     $project
397     * @param int $hookid the numerical id of the hook to be deleted
398     *
399     * @return array
400     */
401    public function deleteWebhook($project, $hookid)
402    {
403        // get old webhook id
404        /** @var \helper_plugin_issuelinks_db $db */
405        $db = plugin_load('helper', 'issuelinks_db');
406        $webhooks = $db->getWebhooks('jira');
407        $projects = [];
408        if (!empty($webhooks)) {
409            $oldID = $webhooks[0]['id'];
410            // get current webhook projects
411            $projects = explode(',', $webhooks[0]['repository_id']);
412            // remove old webhook
413            $this->makeJiraRequest('/rest/webhooks/1.0/webhook/' . $oldID, [], 'DELETE');
414            // delete old webhook from database
415            $db->deleteWebhook('jira', $webhooks[0]['repository_id'], $oldID);
416        }
417
418        // remove project
419        $projects = array_filter(array_diff($projects, [$project]));
420        if (empty($projects)) {
421            return ['status' => 204, 'data' => ''];
422        }
423
424        $projectsString = implode(',', $projects);
425
426        // add new webhooks
427        global $conf;
428        $payload = [
429            'name' => 'dokuwiki plugin issuelinks for Wiki: ' . $conf['title'],
430            'url' => self::WEBHOOK_URL,
431            'events' => [
432                'jira:issue_created',
433                'jira:issue_updated',
434            ],
435            'description' => 'dokuwiki plugin issuelinks for Wiki: ' . $conf['title'],
436            'jqlFilter' => "project in ($projectsString)",
437            'excludeIssueDetails' => 'false',
438        ];
439        $response = $this->makeJiraRequest('/rest/webhooks/1.0/webhook', $payload, 'POST');
440        $selfLink = $response['self'];
441        $newWebhookID = substr($selfLink, strrpos($selfLink, '/') + 1);
442
443        // store new webhook to database
444        $db->saveWebhook('jira', $projectsString, $newWebhookID, 'jira rest webhooks have no secrets :/');
445
446        return ['status' => 204, 'data' => ''];
447    }
448
449    /**
450     * Do all checks to verify that the webhook is expected and actually ours
451     *
452     * @param $webhookBody
453     *
454     * @return true|RequestResult true if the the webhook is our and should be processed RequestResult with explanation
455     *                            otherwise
456     */
457    public function validateWebhook($webhookBody)
458    {
459        $data = json_decode($webhookBody, true);
460        /** @var \helper_plugin_issuelinks_db $db */
461        $db = plugin_load('helper', 'issuelinks_db');
462        $webhooks = $db->getWebhooks('jira');
463        $projects = [];
464        if (!empty($webhooks)) {
465            // get current webhook projects
466            $projects = explode(',', $webhooks[0]['repository_id']);
467        }
468
469        if (!$data['webhookEvent'] || !in_array($data['webhookEvent'], ['jira:issue_updated', 'jira:issue_created'])) {
470            return new RequestResult(400, 'unknown webhook event');
471        }
472
473        list($projectKey, $issueId) = explode('-', $data['issue']['key']);
474
475        if (!in_array($projectKey, $projects)) {
476            return new RequestResult(202, 'Project ' . $projectKey . ' is not handled by this wiki.');
477        }
478
479        return true;
480    }
481
482    /**
483     * Handle the contents of the webhooks body
484     *
485     * @param $webhookBody
486     *
487     * @return RequestResult
488     */
489    public function handleWebhook($webhookBody)
490    {
491        $data = json_decode($webhookBody, true);
492        $issueData = $data['issue'];
493        list($projectKey, $issueId) = explode('-', $issueData['key']);
494        $issue = Issue::getInstance('jira', $projectKey, $issueId, false);
495        $this->setIssueData($issue, $issueData);
496        $issue->saveToDB();
497
498        return new RequestResult(200, 'OK');
499    }
500}
501