@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add JIRA doorkeeper and remarkup support

Summary:
Ref T3687. Adds a Doorkeeper bridge for JIRA issues, plus remarkup support. In particular:

- The Asana and JIRA remarkup rules shared most of their implementation, so I refactored what I could into a base class.
- Actual bridge implementation is straightforward and similar to Asana, although probably not similar enough to really justify refactoring.

Test Plan:
- When logged in as a JIRA-connected user, pasted a JIRA issue link and saw it enriched at rendering time.
- Logged in and out with JIRA.
- Tested an Asana link, too (seems I haven't broken anything).

Reviewers: btrahan

Reviewed By: btrahan

CC: aran

Maniphest Tasks: T3687

Differential Revision: https://secure.phabricator.com/D6878

+281 -68
+8 -1
src/__phutil_library_map__.php
··· 545 545 'DivinerWorkflow' => 'applications/diviner/workflow/DivinerWorkflow.php', 546 546 'DoorkeeperBridge' => 'applications/doorkeeper/bridge/DoorkeeperBridge.php', 547 547 'DoorkeeperBridgeAsana' => 'applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php', 548 + 'DoorkeeperBridgeJIRA' => 'applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php', 548 549 'DoorkeeperDAO' => 'applications/doorkeeper/storage/DoorkeeperDAO.php', 549 550 'DoorkeeperExternalObject' => 'applications/doorkeeper/storage/DoorkeeperExternalObject.php', 550 551 'DoorkeeperExternalObjectQuery' => 'applications/doorkeeper/query/DoorkeeperExternalObjectQuery.php', ··· 552 553 'DoorkeeperFeedWorkerAsana' => 'applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php', 553 554 'DoorkeeperImportEngine' => 'applications/doorkeeper/engine/DoorkeeperImportEngine.php', 554 555 'DoorkeeperObjectRef' => 'applications/doorkeeper/engine/DoorkeeperObjectRef.php', 556 + 'DoorkeeperRemarkupRule' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRule.php', 555 557 'DoorkeeperRemarkupRuleAsana' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleAsana.php', 558 + 'DoorkeeperRemarkupRuleJIRA' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleJIRA.php', 556 559 'DoorkeeperTagsController' => 'applications/doorkeeper/controller/DoorkeeperTagsController.php', 557 560 'DrydockAllocatorWorker' => 'applications/drydock/worker/DrydockAllocatorWorker.php', 558 561 'DrydockApacheWebrootInterface' => 'applications/drydock/interface/webroot/DrydockApacheWebrootInterface.php', ··· 2026 2029 'function' => 2027 2030 array( 2028 2031 '_phabricator_date_format' => 'view/viewutils.php', 2032 + '_phabricator_time_format' => 'view/viewutils.php', 2029 2033 'celerity_generate_unique_node_id' => 'infrastructure/celerity/api.php', 2030 2034 'celerity_get_resource_uri' => 'infrastructure/celerity/api.php', 2031 2035 'celerity_register_resource_map' => 'infrastructure/celerity/map.php', ··· 2575 2579 'DivinerWorkflow' => 'PhutilArgumentWorkflow', 2576 2580 'DoorkeeperBridge' => 'Phobject', 2577 2581 'DoorkeeperBridgeAsana' => 'DoorkeeperBridge', 2582 + 'DoorkeeperBridgeJIRA' => 'DoorkeeperBridge', 2578 2583 'DoorkeeperDAO' => 'PhabricatorLiskDAO', 2579 2584 'DoorkeeperExternalObject' => 2580 2585 array( ··· 2585 2590 'DoorkeeperFeedWorkerAsana' => 'FeedPushWorker', 2586 2591 'DoorkeeperImportEngine' => 'Phobject', 2587 2592 'DoorkeeperObjectRef' => 'Phobject', 2588 - 'DoorkeeperRemarkupRuleAsana' => 'PhutilRemarkupRule', 2593 + 'DoorkeeperRemarkupRule' => 'PhutilRemarkupRule', 2594 + 'DoorkeeperRemarkupRuleAsana' => 'DoorkeeperRemarkupRule', 2595 + 'DoorkeeperRemarkupRuleJIRA' => 'DoorkeeperRemarkupRule', 2589 2596 'DoorkeeperTagsController' => 'PhabricatorController', 2590 2597 'DrydockAllocatorWorker' => 'PhabricatorWorker', 2591 2598 'DrydockApacheWebrootInterface' => 'DrydockWebrootInterface',
+29
src/applications/auth/provider/PhabricatorAuthProviderOAuth1JIRA.php
··· 3 3 final class PhabricatorAuthProviderOAuth1JIRA 4 4 extends PhabricatorAuthProviderOAuth1 { 5 5 6 + public function getJIRABaseURI() { 7 + return $this->getProviderConfig()->getProperty(self::PROPERTY_JIRA_URI); 8 + } 9 + 6 10 public function getProviderName() { 7 11 return pht('JIRA'); 8 12 } ··· 243 247 */ 244 248 public function hasSetupStep() { 245 249 return true; 250 + } 251 + 252 + public static function getJIRAProvider() { 253 + $providers = self::getAllEnabledProviders(); 254 + 255 + foreach ($providers as $provider) { 256 + if ($provider instanceof PhabricatorAuthProviderOAuth1JIRA) { 257 + return $provider; 258 + } 259 + } 260 + 261 + return null; 262 + } 263 + 264 + public function newJIRAFuture( 265 + PhabricatorExternalAccount $account, 266 + $path, 267 + $method, 268 + $params = array()) { 269 + 270 + $adapter = clone $this->getAdapter(); 271 + $adapter->setToken($account->getProperty('oauth1.token')); 272 + $adapter->setTokenSecret($account->getProperty('oauth1.token.secret')); 273 + 274 + return $adapter->newJIRAFuture($path, $method, $params); 246 275 } 247 276 248 277 }
+1
src/applications/doorkeeper/application/PhabricatorApplicationDoorkeeper.php
··· 17 17 public function getRemarkupRules() { 18 18 return array( 19 19 new DoorkeeperRemarkupRuleAsana(), 20 + new DoorkeeperRemarkupRuleJIRA(), 20 21 ); 21 22 } 22 23
+119
src/applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php
··· 1 + <?php 2 + 3 + final class DoorkeeperBridgeJIRA extends DoorkeeperBridge { 4 + 5 + const APPTYPE_JIRA = 'jira'; 6 + const OBJTYPE_ISSUE = 'jira:issue'; 7 + 8 + public function canPullRef(DoorkeeperObjectRef $ref) { 9 + if ($ref->getApplicationType() != self::APPTYPE_JIRA) { 10 + return false; 11 + } 12 + 13 + $types = array( 14 + self::OBJTYPE_ISSUE => true, 15 + ); 16 + 17 + return isset($types[$ref->getObjectType()]); 18 + } 19 + 20 + public function pullRefs(array $refs) { 21 + 22 + $id_map = mpull($refs, 'getObjectID', 'getObjectKey'); 23 + $viewer = $this->getViewer(); 24 + 25 + $provider = PhabricatorAuthProviderOAuth1JIRA::getJIRAProvider(); 26 + if (!$provider) { 27 + return; 28 + } 29 + 30 + $accounts = id(new PhabricatorExternalAccountQuery()) 31 + ->setViewer($viewer) 32 + ->withUserPHIDs(array($viewer->getPHID())) 33 + ->withAccountTypes(array($provider->getProviderType())) 34 + ->execute(); 35 + 36 + if (!$accounts) { 37 + return; 38 + } 39 + 40 + // TODO: When we support multiple JIRA instances, we need to disambiguate 41 + // issues (perhaps with additional configuration) or cast a wide net 42 + // (by querying all instances). For now, just query the one instance. 43 + $account = head($accounts); 44 + 45 + $futures = array(); 46 + foreach ($id_map as $key => $id) { 47 + $futures[$key] = $provider->newJIRAFuture( 48 + $account, 49 + 'rest/api/2/issue/'.phutil_escape_uri($id), 50 + 'GET'); 51 + } 52 + 53 + $results = array(); 54 + $failed = array(); 55 + foreach (Futures($futures) as $key => $future) { 56 + try { 57 + $results[$key] = $future->resolveJSON(); 58 + } catch (Exception $ex) { 59 + if (($ex instanceof HTTPFutureResponseStatus) && 60 + ($ex->getStatusCode() == 404)) { 61 + // This indicates that the object has been deleted (or never existed, 62 + // or isn't visible to the current user) but it's a successful sync of 63 + // an object which isn't visible. 64 + } else { 65 + // This is something else, so consider it a synchronization failure. 66 + phlog($ex); 67 + $failed[$key] = $ex; 68 + } 69 + } 70 + } 71 + 72 + foreach ($refs as $ref) { 73 + $ref->setAttribute('name', pht('JIRA %s', $ref->getObjectID())); 74 + 75 + $did_fail = idx($failed, $ref->getObjectKey()); 76 + if ($did_fail) { 77 + $ref->setSyncFailed(true); 78 + continue; 79 + } 80 + 81 + $result = idx($results, $ref->getObjectKey()); 82 + if (!$result) { 83 + continue; 84 + } 85 + 86 + $fields = idx($result, 'fields', array()); 87 + 88 + $ref->setIsVisible(true); 89 + $ref->setAttribute( 90 + 'fullname', 91 + pht('JIRA %s %s', $result['key'], idx($fields, 'summary'))); 92 + 93 + $ref->setAttribute('title', idx($fields, 'summary')); 94 + $ref->setAttribute('description', idx($result, 'description')); 95 + 96 + $obj = $ref->getExternalObject(); 97 + if ($obj->getID()) { 98 + continue; 99 + } 100 + 101 + $this->fillObjectFromData($obj, $result); 102 + 103 + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 104 + $obj->save(); 105 + unset($unguarded); 106 + } 107 + } 108 + 109 + public function fillObjectFromData(DoorkeeperExternalObject $obj, $result) { 110 + // Convert the "self" URI, which points at the REST endpoint, into a 111 + // browse URI. 112 + $self = idx($result, 'self'); 113 + $uri = new PhutilURI($self); 114 + $uri->setPath('browse/'.$obj->getObjectID()); 115 + 116 + $obj->setObjectURI((string)$uri); 117 + } 118 + 119 + }
+65
src/applications/doorkeeper/remarkup/DoorkeeperRemarkupRule.php
··· 1 + <?php 2 + 3 + abstract class DoorkeeperRemarkupRule 4 + extends PhutilRemarkupRule { 5 + 6 + const KEY_TAGS = 'doorkeeper.tags'; 7 + 8 + public function getPriority() { 9 + return 350.0; 10 + } 11 + 12 + protected function addDoorkeeperTag(array $spec) { 13 + $key = self::KEY_TAGS; 14 + $engine = $this->getEngine(); 15 + $token = $engine->storeText(get_class($this)); 16 + 17 + $tags = $engine->getTextMetadata($key, array()); 18 + 19 + $tags[] = array( 20 + 'token' => $token, 21 + ) + $spec + array( 22 + 'extra' => array(), 23 + ); 24 + 25 + $engine->setTextMetadata($key, $tags); 26 + return $token; 27 + } 28 + 29 + public function didMarkupText() { 30 + $key = self::KEY_TAGS; 31 + $engine = $this->getEngine(); 32 + $tags = $engine->getTextMetadata($key, array()); 33 + 34 + if (!$tags) { 35 + return; 36 + } 37 + 38 + $refs = array(); 39 + foreach ($tags as $spec) { 40 + $tag_id = celerity_generate_unique_node_id(); 41 + 42 + $refs[] = array( 43 + 'id' => $tag_id, 44 + ) + $spec['tag']; 45 + 46 + if ($this->getEngine()->isTextMode()) { 47 + $view = $spec['href']; 48 + } else { 49 + $view = id(new PhabricatorTagView()) 50 + ->setID($tag_id) 51 + ->setName($spec['href']) 52 + ->setHref($spec['href']) 53 + ->setType(PhabricatorTagView::TYPE_OBJECT) 54 + ->setExternal(true); 55 + } 56 + 57 + $engine->overwriteStoredText($spec['token'], $view); 58 + } 59 + 60 + Javelin::initBehavior('doorkeeper-tag', array('tags' => $refs)); 61 + 62 + $engine->setTextMetadata($key, array()); 63 + } 64 + 65 + }
+15 -67
src/applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleAsana.php
··· 1 1 <?php 2 2 3 3 final class DoorkeeperRemarkupRuleAsana 4 - extends PhutilRemarkupRule { 5 - 6 - const KEY_TAGS = 'doorkeeper.tags'; 7 - 8 - public function getPriority() { 9 - return 350.0; 10 - } 4 + extends DoorkeeperRemarkupRule { 11 5 12 6 public function apply($text) { 13 7 return preg_replace_callback( ··· 17 11 } 18 12 19 13 public function markupAsanaLink($matches) { 20 - $key = self::KEY_TAGS; 21 - $engine = $this->getEngine(); 22 - $token = $engine->storeText('AsanaDoorkeeper'); 23 - 24 - $tags = $engine->getTextMetadata($key, array()); 25 - 26 - $tags[] = array( 27 - 'token' => $token, 28 - 'href' => $matches[0], 29 - 'tag' => array( 30 - 'ref' => array( 31 - DoorkeeperBridgeAsana::APPTYPE_ASANA, 32 - DoorkeeperBridgeAsana::APPDOMAIN_ASANA, 33 - DoorkeeperBridgeAsana::OBJTYPE_TASK, 34 - $matches[2], 14 + return $this->addDoorkeeperTag( 15 + array( 16 + 'href' => $matches[0], 17 + 'tag' => array( 18 + 'ref' => array( 19 + DoorkeeperBridgeAsana::APPTYPE_ASANA, 20 + DoorkeeperBridgeAsana::APPDOMAIN_ASANA, 21 + DoorkeeperBridgeAsana::OBJTYPE_TASK, 22 + $matches[2], 23 + ), 24 + 'extra' => array( 25 + 'asana.context' => $matches[1], 26 + ), 35 27 ), 36 - 'extra' => array( 37 - 'asana.context' => $matches[1], 38 - ), 39 - ), 40 - ); 41 - 42 - $engine->setTextMetadata($key, $tags); 43 - 44 - return $token; 45 - } 46 - 47 - public function didMarkupText() { 48 - $key = self::KEY_TAGS; 49 - $engine = $this->getEngine(); 50 - $tags = $engine->getTextMetadata($key, array()); 51 - 52 - if (!$tags) { 53 - return; 54 - } 55 - 56 - $refs = array(); 57 - foreach ($tags as $spec) { 58 - $tag_id = celerity_generate_unique_node_id(); 59 - 60 - $refs[] = array( 61 - 'id' => $tag_id, 62 - ) + $spec['tag']; 63 - 64 - if ($this->getEngine()->isTextMode()) { 65 - $view = $spec['href']; 66 - } else { 67 - $view = id(new PhabricatorTagView()) 68 - ->setID($tag_id) 69 - ->setName($spec['href']) 70 - ->setHref($spec['href']) 71 - ->setType(PhabricatorTagView::TYPE_OBJECT) 72 - ->setExternal(true); 73 - } 74 - 75 - $engine->overwriteStoredText($spec['token'], $view); 76 - } 77 - 78 - Javelin::initBehavior('doorkeeper-tag', array('tags' => $refs)); 79 - 80 - $engine->setTextMetadata($key, array()); 28 + )); 81 29 } 82 30 83 31 }
+44
src/applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleJIRA.php
··· 1 + <?php 2 + 3 + final class DoorkeeperRemarkupRuleJIRA 4 + extends DoorkeeperRemarkupRule { 5 + 6 + public function apply($text) { 7 + return preg_replace_callback( 8 + '@(https?://[^/]+)/browse/([A-Z]+-[1-9]\d*)@', 9 + array($this, 'markupJIRALink'), 10 + $text); 11 + } 12 + 13 + public function markupJIRALink($matches) { 14 + 15 + $match_domain = $matches[1]; 16 + $match_issue = $matches[2]; 17 + 18 + // TODO: When we support multiple instances, deal with them here. 19 + $provider = PhabricatorAuthProviderOAuth1JIRA::getJIRAProvider(); 20 + if (!$provider) { 21 + return $matches[0]; 22 + } 23 + 24 + $jira_base = $provider->getJIRABaseURI(); 25 + if ($match_domain != rtrim($jira_base, '/')) { 26 + return $matches[0]; 27 + } 28 + 29 + return $this->addDoorkeeperTag( 30 + array( 31 + 'href' => $matches[0], 32 + 'tag' => array( 33 + 'ref' => array( 34 + DoorkeeperBridgeJIRA::APPTYPE_JIRA, 35 + $provider->getProviderDomain(), 36 + DoorkeeperBridgeJIRA::OBJTYPE_ISSUE, 37 + $match_issue, 38 + ), 39 + ), 40 + )); 41 + } 42 + 43 + 44 + }