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