diff --git a/src/applications/doorkeeper/bridge/DoorkeeperBridge.php b/src/applications/doorkeeper/bridge/DoorkeeperBridge.php index 4a4ee2667b..d25d48e857 100644 --- a/src/applications/doorkeeper/bridge/DoorkeeperBridge.php +++ b/src/applications/doorkeeper/bridge/DoorkeeperBridge.php @@ -1,79 +1,89 @@ timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } public function setThrowOnMissingLink($throw_on_missing_link) { $this->throwOnMissingLink = $throw_on_missing_link; return $this; } final public function setViewer($viewer) { $this->viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setContext($context) { $this->context = $context; return $this; } final public function getContextProperty($key, $default = null) { return idx($this->context, $key, $default); } public function isEnabled() { return true; } abstract public function canPullRef(DoorkeeperObjectRef $ref); abstract public function pullRefs(array $refs); public function fillObjectFromData(DoorkeeperExternalObject $obj, $result) { return; } public function didFailOnMissingLink() { if ($this->throwOnMissingLink) { throw new DoorkeeperMissingLinkException(); } return null; } final protected function saveExternalObject( DoorkeeperObjectRef $ref, DoorkeeperExternalObject $obj) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { $obj->save(); } catch (AphrontDuplicateKeyQueryException $ex) { // In various cases, we may race another process importing the same // data. If we do, we'll collide on the object key. Load the object // the other process created and use that. $obj = id(new DoorkeeperExternalObjectQuery()) ->setViewer($this->getViewer()) ->withObjectKeys(array($ref->getObjectKey())) ->executeOne(); if (!$obj) { throw new PhutilProxyException( pht('Failed to load external object after collision.'), $ex); } $ref->attachExternalObject($obj); } unset($unguarded); } } diff --git a/src/applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php b/src/applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php index ec604e158e..05ee786337 100644 --- a/src/applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php +++ b/src/applications/doorkeeper/bridge/DoorkeeperBridgeAsana.php @@ -1,126 +1,131 @@ getApplicationType() != self::APPTYPE_ASANA) { return false; } if ($ref->getApplicationDomain() != self::APPDOMAIN_ASANA) { return false; } $types = array( self::OBJTYPE_TASK => true, ); return isset($types[$ref->getObjectType()]); } public function pullRefs(array $refs) { $id_map = mpull($refs, 'getObjectID', 'getObjectKey'); $viewer = $this->getViewer(); $provider = PhabricatorAsanaAuthProvider::getAsanaProvider(); if (!$provider) { return; } $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withUserPHIDs(array($viewer->getPHID())) ->withAccountTypes(array($provider->getProviderType())) ->withAccountDomains(array($provider->getProviderDomain())) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); if (!$accounts) { return $this->didFailOnMissingLink(); } // TODO: If the user has several linked Asana accounts, we just pick the // first one arbitrarily. We might want to try using all of them or do // something with more finesse. There's no UI way to link multiple accounts // right now so this is currently moot. $account = head($accounts); $token = $provider->getOAuthAccessToken($account); if (!$token) { return; } $template = id(new PhutilAsanaFuture()) ->setAccessToken($token); + $timeout = $this->getTimeout(); + if ($timeout !== null) { + $template->setTimeout($timeout); + } + $futures = array(); foreach ($id_map as $key => $id) { $futures[$key] = id(clone $template) ->setRawAsanaQuery("tasks/{$id}"); } $results = array(); $failed = array(); foreach (new FutureIterator($futures) as $key => $future) { try { $results[$key] = $future->resolve(); } catch (Exception $ex) { if (($ex instanceof HTTPFutureResponseStatus) && ($ex->getStatusCode() == 404)) { // This indicates that the object has been deleted (or never existed, // or isn't visible to the current user) but it's a successful sync of // an object which isn't visible. } else { // This is something else, so consider it a synchronization failure. phlog($ex); $failed[$key] = $ex; } } } foreach ($refs as $ref) { $ref->setAttribute('name', pht('Asana Task %s', $ref->getObjectID())); $did_fail = idx($failed, $ref->getObjectKey()); if ($did_fail) { $ref->setSyncFailed(true); continue; } $result = idx($results, $ref->getObjectKey()); if (!$result) { continue; } $ref->setIsVisible(true); $ref->setAttribute('asana.data', $result); $ref->setAttribute('fullname', pht('Asana: %s', $result['name'])); $ref->setAttribute('title', $result['name']); $ref->setAttribute('description', $result['notes']); $obj = $ref->getExternalObject(); if ($obj->getID()) { continue; } $this->fillObjectFromData($obj, $result); $this->saveExternalObject($ref, $obj); } } public function fillObjectFromData(DoorkeeperExternalObject $obj, $result) { $id = $result['id']; $uri = "https://app.asana.com/0/{$id}/{$id}"; $obj->setObjectURI($uri); } } diff --git a/src/applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php b/src/applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php index 920f2eeb91..f82bb1ba25 100644 --- a/src/applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php +++ b/src/applications/doorkeeper/bridge/DoorkeeperBridgeJIRA.php @@ -1,146 +1,154 @@ getApplicationType() != self::APPTYPE_JIRA) { return false; } $types = array( self::OBJTYPE_ISSUE => true, ); return isset($types[$ref->getObjectType()]); } public function pullRefs(array $refs) { $id_map = mpull($refs, 'getObjectID', 'getObjectKey'); $viewer = $this->getViewer(); $provider = PhabricatorJIRAAuthProvider::getJIRAProvider(); if (!$provider) { return; } $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer($viewer) ->withUserPHIDs(array($viewer->getPHID())) ->withAccountTypes(array($provider->getProviderType())) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); if (!$accounts) { return $this->didFailOnMissingLink(); } // TODO: When we support multiple JIRA instances, we need to disambiguate // issues (perhaps with additional configuration) or cast a wide net // (by querying all instances). For now, just query the one instance. $account = head($accounts); + $timeout = $this->getTimeout(); + $futures = array(); foreach ($id_map as $key => $id) { - $futures[$key] = $provider->newJIRAFuture( + $future = $provider->newJIRAFuture( $account, 'rest/api/2/issue/'.phutil_escape_uri($id), 'GET'); + + if ($timeout !== null) { + $future->setTimeout($timeout); + } + + $futures[$key] = $future; } $results = array(); $failed = array(); foreach (new FutureIterator($futures) as $key => $future) { try { $results[$key] = $future->resolveJSON(); } catch (Exception $ex) { if (($ex instanceof HTTPFutureResponseStatus) && ($ex->getStatusCode() == 404)) { // This indicates that the object has been deleted (or never existed, // or isn't visible to the current user) but it's a successful sync of // an object which isn't visible. } else { // This is something else, so consider it a synchronization failure. phlog($ex); $failed[$key] = $ex; } } } foreach ($refs as $ref) { $ref->setAttribute('name', pht('JIRA %s', $ref->getObjectID())); $did_fail = idx($failed, $ref->getObjectKey()); if ($did_fail) { $ref->setSyncFailed(true); continue; } $result = idx($results, $ref->getObjectKey()); if (!$result) { continue; } $fields = idx($result, 'fields', array()); $ref->setIsVisible(true); $ref->setAttribute( 'fullname', pht('JIRA %s %s', $result['key'], idx($fields, 'summary'))); $ref->setAttribute('title', idx($fields, 'summary')); $ref->setAttribute('description', idx($result, 'description')); $ref->setAttribute('shortname', $result['key']); $obj = $ref->getExternalObject(); if ($obj->getID()) { continue; } $this->fillObjectFromData($obj, $result); $this->saveExternalObject($ref, $obj); } } public function fillObjectFromData(DoorkeeperExternalObject $obj, $result) { // Convert the "self" URI, which points at the REST endpoint, into a // browse URI. $self = idx($result, 'self'); $object_id = $obj->getObjectID(); $uri = self::getJIRAIssueBrowseURIFromJIRARestURI($self, $object_id); if ($uri !== null) { $obj->setObjectURI($uri); } } public static function getJIRAIssueBrowseURIFromJIRARestURI( $uri, $object_id) { $uri = new PhutilURI($uri); // The JIRA install might not be at the domain root, so we may need to // keep an initial part of the path, like "/jira/". Find the API specific // part of the URI, strip it off, then replace it with the web version. $path = $uri->getPath(); $pos = strrpos($path, 'rest/api/2/issue/'); if ($pos === false) { return null; } $path = substr($path, 0, $pos); $path = $path.'browse/'.$object_id; $uri->setPath($path); return (string)$uri; } } diff --git a/src/applications/doorkeeper/controller/DoorkeeperTagsController.php b/src/applications/doorkeeper/controller/DoorkeeperTagsController.php index 5a886cba3e..f4b4195f11 100644 --- a/src/applications/doorkeeper/controller/DoorkeeperTagsController.php +++ b/src/applications/doorkeeper/controller/DoorkeeperTagsController.php @@ -1,76 +1,77 @@ getViewer(); $tags = $request->getStr('tags'); try { $tags = phutil_json_decode($tags); } catch (PhutilJSONParserException $ex) { $tags = array(); } $refs = array(); foreach ($tags as $key => $tag_spec) { $tag = $tag_spec['ref']; $ref = id(new DoorkeeperObjectRef()) ->setApplicationType($tag[0]) ->setApplicationDomain($tag[1]) ->setObjectType($tag[2]) ->setObjectID($tag[3]); $refs[$key] = $ref; } $refs = id(new DoorkeeperImportEngine()) ->setViewer($viewer) ->setRefs($refs) + ->setTimeout(15) ->execute(); $results = array(); foreach ($refs as $key => $ref) { if (!$ref->getIsVisible()) { continue; } $uri = $ref->getExternalObject()->getObjectURI(); if (!$uri) { continue; } $tag_spec = $tags[$key]; $id = $tag_spec['id']; $view = idx($tag_spec, 'view'); $is_short = ($view == 'short'); if ($is_short) { $name = $ref->getShortName(); } else { $name = $ref->getFullName(); } $tag = id(new PHUITagView()) ->setID($id) ->setName($name) ->setHref($uri) ->setType(PHUITagView::TYPE_OBJECT) ->setExternal(true) ->render(); $results[] = array( 'id' => $id, 'markup' => $tag, ); } return id(new AphrontAjaxResponse())->setContent( array( 'tags' => $results, )); } } diff --git a/src/applications/doorkeeper/engine/DoorkeeperImportEngine.php b/src/applications/doorkeeper/engine/DoorkeeperImportEngine.php index 8c58f5bd88..02642cb994 100644 --- a/src/applications/doorkeeper/engine/DoorkeeperImportEngine.php +++ b/src/applications/doorkeeper/engine/DoorkeeperImportEngine.php @@ -1,129 +1,145 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setRefs(array $refs) { assert_instances_of($refs, 'DoorkeeperObjectRef'); $this->refs = $refs; return $this; } public function getRefs() { return $this->refs; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function needLocalOnly($local_only) { $this->localOnly = $local_only; return $this; } public function setContextProperty($key, $value) { $this->context[$key] = $value; return $this; } + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function getTimeout() { + return $this->timeout; + } + /** * Configure behavior if remote refs can not be retrieved because an * authentication link is missing. */ public function setThrowOnMissingLink($throw) { $this->throwOnMissingLink = $throw; return $this; } public function execute() { $refs = $this->getRefs(); $viewer = $this->getViewer(); $keys = mpull($refs, 'getObjectKey'); if ($keys) { $xobjs = id(new DoorkeeperExternalObjectQuery()) ->setViewer($viewer) ->withObjectKeys($keys) ->execute(); $xobjs = mpull($xobjs, null, 'getObjectKey'); foreach ($refs as $ref) { $xobj = idx($xobjs, $ref->getObjectKey()); if (!$xobj) { $xobj = $ref ->newExternalObject() ->setImporterPHID($viewer->getPHID()); // NOTE: Fill the new external object into the object map, so we'll // reference the same external object if more than one ref is the // same. This prevents issues later where we double-populate // external objects when handed duplicate refs. $xobjs[$ref->getObjectKey()] = $xobj; } $ref->attachExternalObject($xobj); } } if ($this->phids) { $xobjs = id(new DoorkeeperExternalObjectQuery()) ->setViewer($viewer) ->withPHIDs($this->phids) ->execute(); foreach ($xobjs as $xobj) { $ref = $xobj->getRef(); $ref->attachExternalObject($xobj); $refs[$ref->getObjectKey()] = $ref; } } if (!$this->localOnly) { $bridges = id(new PhutilClassMapQuery()) ->setAncestorClass('DoorkeeperBridge') ->setFilterMethod('isEnabled') ->execute(); + $timeout = $this->getTimeout(); foreach ($bridges as $key => $bridge) { - $bridge->setViewer($viewer); - $bridge->setThrowOnMissingLink($this->throwOnMissingLink); - $bridge->setContext($this->context); + $bridge + ->setViewer($viewer) + ->setThrowOnMissingLink($this->throwOnMissingLink) + ->setContext($this->context); + + if ($timeout !== null) { + $bridge->setTimeout($timeout); + } } $working_set = $refs; foreach ($bridges as $bridge) { $bridge_refs = array(); foreach ($working_set as $key => $ref) { if ($bridge->canPullRef($ref)) { $bridge_refs[$key] = $ref; unset($working_set[$key]); } } if ($bridge_refs) { $bridge->pullRefs($bridge_refs); } } } return $refs; } public function executeOne() { return head($this->execute()); } }