1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-23 05:50:55 +01:00

Initial Asana sync for Differential

Summary:
Ref T2852. This is highly incomplete but seems structurally sound. Some additional context is available in the Google doc.

  - Add a workspace ID configuration. Without it, nothing else activates.
  - Add a worker which reacts to feed stories.
  - Feed stories about things which aren't Differential objects are ignored.
  - We load the revision, or fail permanently if we can't.
  - We get all the related user PHIDs (author, reviewers, CCs).
  - We check if any of them have linked Asana accounts, or fail permanently if they don't.
  - We check for an "ASANATASK" edge from the revision.
    - If we do not find one, we create a new task.
    - If we do find one, we load the task.
      - If we succeed, we check the chronological key of the most recent synchronized feed story ("cursor").
        - If this story is the same or newer, we update the task to synchronize it to the current state of the revision.
      - If we fail to load the task, we fail permanently ("asana task has been deleted").
  - We then publish the actual story text to the task.

Not in yet:

  - Updating followers requires separate API calls which we don't do yet.
  - No subtasks yet.
  - No sync of open/closed state.

Test Plan: {F47546}

Reviewers: btrahan

Reviewed By: btrahan

CC: aran

Maniphest Tasks: T2852

Differential Revision: https://secure.phabricator.com/D6302
This commit is contained in:
epriestley 2013-06-25 16:33:16 -07:00
parent 97dc484854
commit 5a6044dbaa
5 changed files with 427 additions and 0 deletions

View file

@ -538,6 +538,7 @@ phutil_register_library_map(array(
'DoorkeeperDAO' => 'applications/doorkeeper/storage/DoorkeeperDAO.php', 'DoorkeeperDAO' => 'applications/doorkeeper/storage/DoorkeeperDAO.php',
'DoorkeeperExternalObject' => 'applications/doorkeeper/storage/DoorkeeperExternalObject.php', 'DoorkeeperExternalObject' => 'applications/doorkeeper/storage/DoorkeeperExternalObject.php',
'DoorkeeperExternalObjectQuery' => 'applications/doorkeeper/query/DoorkeeperExternalObjectQuery.php', 'DoorkeeperExternalObjectQuery' => 'applications/doorkeeper/query/DoorkeeperExternalObjectQuery.php',
'DoorkeeperFeedWorkerAsana' => 'applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php',
'DoorkeeperImportEngine' => 'applications/doorkeeper/engine/DoorkeeperImportEngine.php', 'DoorkeeperImportEngine' => 'applications/doorkeeper/engine/DoorkeeperImportEngine.php',
'DoorkeeperObjectRef' => 'applications/doorkeeper/engine/DoorkeeperObjectRef.php', 'DoorkeeperObjectRef' => 'applications/doorkeeper/engine/DoorkeeperObjectRef.php',
'DoorkeeperRemarkupRuleAsana' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleAsana.php', 'DoorkeeperRemarkupRuleAsana' => 'applications/doorkeeper/remarkup/DoorkeeperRemarkupRuleAsana.php',
@ -811,6 +812,7 @@ phutil_register_library_map(array(
'PhabricatorApplicationXHProf' => 'applications/xhprof/application/PhabricatorApplicationXHProf.php', 'PhabricatorApplicationXHProf' => 'applications/xhprof/application/PhabricatorApplicationXHProf.php',
'PhabricatorApplicationsController' => 'applications/meta/controller/PhabricatorApplicationsController.php', 'PhabricatorApplicationsController' => 'applications/meta/controller/PhabricatorApplicationsController.php',
'PhabricatorApplicationsListController' => 'applications/meta/controller/PhabricatorApplicationsListController.php', 'PhabricatorApplicationsListController' => 'applications/meta/controller/PhabricatorApplicationsListController.php',
'PhabricatorAsanaConfigOptions' => 'applications/doorkeeper/option/PhabricatorAsanaConfigOptions.php',
'PhabricatorAuditActionConstants' => 'applications/audit/constants/PhabricatorAuditActionConstants.php', 'PhabricatorAuditActionConstants' => 'applications/audit/constants/PhabricatorAuditActionConstants.php',
'PhabricatorAuditAddCommentController' => 'applications/audit/controller/PhabricatorAuditAddCommentController.php', 'PhabricatorAuditAddCommentController' => 'applications/audit/controller/PhabricatorAuditAddCommentController.php',
'PhabricatorAuditComment' => 'applications/audit/storage/PhabricatorAuditComment.php', 'PhabricatorAuditComment' => 'applications/audit/storage/PhabricatorAuditComment.php',
@ -2428,6 +2430,7 @@ phutil_register_library_map(array(
1 => 'PhabricatorPolicyInterface', 1 => 'PhabricatorPolicyInterface',
), ),
'DoorkeeperExternalObjectQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'DoorkeeperExternalObjectQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'DoorkeeperFeedWorkerAsana' => 'FeedPushWorker',
'DoorkeeperImportEngine' => 'Phobject', 'DoorkeeperImportEngine' => 'Phobject',
'DoorkeeperObjectRef' => 'Phobject', 'DoorkeeperObjectRef' => 'Phobject',
'DoorkeeperRemarkupRuleAsana' => 'PhutilRemarkupRule', 'DoorkeeperRemarkupRuleAsana' => 'PhutilRemarkupRule',
@ -2690,6 +2693,7 @@ phutil_register_library_map(array(
'PhabricatorApplicationXHProf' => 'PhabricatorApplication', 'PhabricatorApplicationXHProf' => 'PhabricatorApplication',
'PhabricatorApplicationsController' => 'PhabricatorController', 'PhabricatorApplicationsController' => 'PhabricatorController',
'PhabricatorApplicationsListController' => 'PhabricatorApplicationsController', 'PhabricatorApplicationsListController' => 'PhabricatorApplicationsController',
'PhabricatorAsanaConfigOptions' => 'PhabricatorApplicationConfigOptions',
'PhabricatorAuditAddCommentController' => 'PhabricatorAuditController', 'PhabricatorAuditAddCommentController' => 'PhabricatorAuditController',
'PhabricatorAuditComment' => 'PhabricatorAuditComment' =>
array( array(

View file

@ -0,0 +1,21 @@
<?php
final class PhabricatorAsanaConfigOptions
extends PhabricatorApplicationConfigOptions {
public function getName() {
return pht("Integration with Asana");
}
public function getDescription() {
return pht("Asana integration options.");
}
public function getOptions() {
return array(
$this->newOption('asana.workspace-id', 'string', null)
->setSummary(pht("Workspace ID to publish into.")),
);
}
}

View file

@ -0,0 +1,381 @@
<?php
final class DoorkeeperFeedWorkerAsana extends FeedPushWorker {
private $provider;
private $workspaceID;
private $feedStory;
private $storyObject;
private function getProvider() {
if (!$this->provider) {
$provider = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
if (!$provider) {
throw new PhabricatorWorkerPermanentFailureException(
'No Asana provider configured.');
}
$this->provider = $provider;
}
return $this->provider;
}
private function getWorkspaceID() {
if (!$this->workspaceID) {
$workspace_id = PhabricatorEnv::getEnvConfig('asana.workspace-id');
if (!$workspace_id) {
throw new PhabricatorWorkerPermanentFailureException(
'No workspace Asana ID configured.');
}
$this->workspaceID = $workspace_id;
}
return $this->workspaceID;
}
private function getFeedStory() {
if (!$this->feedStory) {
$story = $this->loadFeedStory();
$this->feedStory = $story;
}
return $this->feedStory;
}
private function getViewer() {
return PhabricatorUser::getOmnipotentUser();
}
private function getStoryObject() {
if (!$this->storyObject) {
$story = $this->getFeedStory();
try {
$object = $story->getPrimaryObject();
} catch (Exception $ex) {
throw new PhabricatorWorkerPermanentFailureException(
$ex->getMessage());
}
$this->storyObject = $object;
}
return $this->storyObject;
}
private function isObjectSupported($object) {
return ($object instanceof DifferentialRevision);
}
private function getRelatedUserPHIDs($object) {
$revision = $object;
$revision->loadRelationships();
$author_phid = $revision->getAuthorPHID();
$reviewer_phids = $revision->getReviewers();
$cc_phids = $revision->getCCPHIDs();
return array(
$author_phid,
$reviewer_phids,
array(),
$cc_phids);
}
private function getAsanaTaskData($object) {
$revision = $object;
$name = '[Differential] D'.$revision->getID().': '.$revision->getTitle();
$uri = PhabricatorEnv::getProductionURI('/D'.$revision->getID());
$notes = array(
$revision->getSummary(),
$uri,
"\xE2\x9A\xA0 DO NOT EDIT THIS TASK \xE2\x9A\xA0\n".
"Your changes will not be reflected in Phabricator.",
);
$notes = implode("\n\n", $notes);
return array(
'name' => $name,
'notes' => $notes,
);
}
protected function doWork() {
$story = $this->getFeedStory();
$data = $story->getStoryData();
$viewer = $this->getViewer();
$provider = $this->getProvider();
$workspace_id = $this->getWorkspaceID();
$object = $this->getStoryObject();
$src_phid = $object->getPHID();
if (!$this->isObjectSupported($object)) {
$this->log("Story is about an unsupported object type.\n");
return;
}
// Figure out all the users related to the object. Users go into one of
// four buckets:
//
// - Owner: the owner of the object. This user becomes the assigned owner
// of the parent task.
// - Active: users who are responsible for the object and need to act on
// it. For example, reviewers of a "needs review" revision.
// - Passive: users who are responsible for the object, but do not need
// to act on it right now. For example, reviewers of a "needs revision"
// revision.
// - Follow: users who are following the object; generally CCs.
$phids = $this->getRelatedUserPHIDs($object);
list($owner_phid, $active_phids, $passive_phids, $follow_phids) = $phids;
$all_follow_phids = array_merge(
$active_phids,
$passive_phids,
$follow_phids);
$all_follow_phids = array_unique(array_filter($all_follow_phids));
$all_phids = $all_follow_phids;
$all_phids[] = $owner_phid;
$all_phids = array_unique(array_filter($all_phids));
$phid_aid_map = $this->lookupAsanaUserIDs($all_phids);
$owner_asana_id = idx($phid_aid_map, $owner_phid);
$all_follow_asana_ids = array_select_keys($phid_aid_map, $all_follow_phids);
$all_follow_asana_ids = array_values($all_follow_asana_ids);
list($possessed_user, $oauth_token) = $this->findAnyValidAsanaAccessToken(
array_keys($phid_aid_map));
if (!$oauth_token) {
throw new PhabricatorWorkerPermanentFailureException(
'Unable to find any Asana user with valid credentials to '.
'pull an OAuth token out of.');
}
$etype_main = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANATASK;
$etype_sub = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANASUBTASK;
$equery = id(new PhabricatorEdgeQuery())
->withSourcePHIDs(array($src_phid))
->withEdgeTypes(
array(
$etype_main,
$etype_sub,
))
->needEdgeData(true);
$edges = $equery->execute();
$main_edge = head($edges[$src_phid][$etype_main]);
$main_data = $this->getAsanaTaskData($object) + array(
'assignee' => $owner_asana_id,
'followers' => $all_follow_asana_ids,
);
$extra_data = array();
if ($main_edge) {
$refs = id(new DoorkeeperImportEngine())
->setViewer($possessed_user)
->withPHIDs(array($main_edge['dst']))
->execute();
$parent_ref = head($refs);
if (!$parent_ref) {
throw new PhabricatorWorkerPermanentFailureException(
'DoorkeeperExternalObject could not be loaded.');
}
if (!$parent_ref->getIsVisible()) {
$this->log("Skipping main task update, object is no longer visible.\n");
$extra_data['gone'] = true;
} else {
$edge_cursor = idx($main_edge['data'], 'cursor', 0);
// TODO: This probably breaks, very rarely, on 32-bit systems.
if ($edge_cursor <= $story->getChronologicalKey()) {
$this->log("Updating main task.\n");
// TODO: We need to synchronize 'followers' separately.
$put_data = $main_data;
unset($put_data['followers']);
$this->makeAsanaAPICall(
$oauth_token,
"tasks/".$parent_ref->getObjectID(),
'PUT',
$put_data);
} else {
$this->log(
"Skipping main task update, cursor is ahead of the story.\n");
}
}
$extra_data = $main_edge['data'];
} else {
$parent = $this->makeAsanaAPICall(
$oauth_token,
'tasks',
'POST',
array(
'workspace' => $workspace_id,
) + $main_data);
$parent_ref = $this->newRefFromResult(
DoorkeeperBridgeAsana::OBJTYPE_TASK,
$parent);
$extra_data = array(
'workspace' => $workspace_id,
);
}
$dst_phid = $parent_ref->getExternalObject()->getPHID();
// Update the main edge.
$edge_data = array(
'cursor' => $story->getChronologicalKey(),
) + $extra_data;
$edge_options = array(
'data' => $edge_data,
);
id(new PhabricatorEdgeEditor())
->setActor($viewer)
->addEdge($src_phid, $etype_main, $dst_phid, $edge_options)
->save();
if (!$parent_ref->getIsVisible()) {
throw new PhabricatorWorkerPermanentFailureException(
'DoorkeeperExternalObject has no visible object on the other side; '.
'this likely indicates the Asana task has been deleted.');
}
// Post the feed story itself to the main Asana task.
$this->makeAsanaAPICall(
$oauth_token,
'tasks/'.$parent_ref->getObjectID().'/stories',
'POST',
array(
'text' => $story->renderText(),
));
}
private function lookupAsanaUserIDs($all_phids) {
$phid_map = array();
$all_phids = array_unique(array_filter($all_phids));
if (!$all_phids) {
return $phid_map;
}
$provider = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withUserPHIDs($all_phids)
->withAccountTypes(array($provider->getProviderType()))
->withAccountDomains(array($provider->getProviderDomain()))
->execute();
foreach ($accounts as $account) {
$phid_map[$account->getUserPHID()] = $account->getAccountID();
}
// Put this back in input order.
$phid_map = array_select_keys($phid_map, $all_phids);
return $phid_map;
}
private function findAnyValidAsanaAccessToken(array $user_phids) {
if (!$user_phids) {
return array(null, null);
}
$provider = $this->getProvider();
$viewer = $this->getViewer();
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withUserPHIDs($user_phids)
->withAccountTypes(array($provider->getProviderType()))
->withAccountDomains(array($provider->getProviderDomain()))
->execute();
$workspace_id = $this->getWorkspaceID();
foreach ($accounts as $account) {
// Get a token if possible.
$token = $provider->getOAuthAccessToken($account);
if (!$token) {
continue;
}
// Verify we can actually make a call with the token, and that the user
// has access to the workspace in question.
try {
id(new PhutilAsanaFuture())
->setAccessToken($token)
->setRawAsanaQuery("workspaces/{$workspace_id}")
->resolve();
} catch (Exception $ex) {
// This token didn't make it through; try the next account.
continue;
}
$user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($account->getUserPHID()))
->executeOne();
if ($user) {
return array($user, $token);
}
}
return array(null, null);
}
private function makeAsanaAPICall($token, $action, $method, array $params) {
foreach ($params as $key => $value) {
if ($value === null) {
unset($params[$key]);
} else if (is_array($value)) {
unset($params[$key]);
foreach ($value as $skey => $svalue) {
$params[$key.'['.$skey.']'] = $svalue;
}
}
}
return id(new PhutilAsanaFuture())
->setAccessToken($token)
->setMethod($method)
->setRawAsanaQuery($action, $params)
->resolve();
}
private function newRefFromResult($type, $result) {
$ref = id(new DoorkeeperObjectRef())
->setApplicationType(DoorkeeperBridgeAsana::APPTYPE_ASANA)
->setApplicationDomain(DoorkeeperBridgeAsana::APPDOMAIN_ASANA)
->setObjectType($type)
->setObjectID($result['id'])
->setIsVisible(true);
$xobj = $ref->newExternalObject();
$ref->attachExternalObject($xobj);
$bridge = new DoorkeeperBridgeAsana();
$bridge->fillObjectFromData($xobj, $result);
$xobj->save();
return $ref;
}
}

View file

@ -15,6 +15,14 @@ final class FeedPublisherWorker extends FeedPushWorker {
)); ));
} }
if (PhabricatorEnv::getEnvConfig('asana.workspace-id')) {
PhabricatorWorker::scheduleTask(
'DoorkeeperFeedWorkerAsana',
array(
'key' => $story->getChronologicalKey(),
));
}
} }

View file

@ -53,6 +53,11 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants {
const TYPE_TEST_NO_CYCLE = 9000; const TYPE_TEST_NO_CYCLE = 9000;
const TYPE_PHOB_HAS_ASANATASK = 80001;
const TYPE_ASANATASK_HAS_PHOB = 80000;
const TYPE_PHOB_HAS_ASANASUBTASK = 80003;
const TYPE_ASANASUBTASK_HAS_PHOB = 80002;
public static function getInverse($edge_type) { public static function getInverse($edge_type) {
static $map = array( static $map = array(
@ -100,6 +105,13 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants {
self::TYPE_DREV_HAS_COMMIT => self::TYPE_COMMIT_HAS_DREV, self::TYPE_DREV_HAS_COMMIT => self::TYPE_COMMIT_HAS_DREV,
self::TYPE_COMMIT_HAS_DREV => self::TYPE_DREV_HAS_COMMIT, self::TYPE_COMMIT_HAS_DREV => self::TYPE_DREV_HAS_COMMIT,
self::TYPE_PHOB_HAS_ASANATASK => self::TYPE_ASANATASK_HAS_PHOB,
self::TYPE_ASANATASK_HAS_PHOB => self::TYPE_PHOB_HAS_ASANATASK,
self::TYPE_PHOB_HAS_ASANASUBTASK => self::TYPE_ASANASUBTASK_HAS_PHOB,
self::TYPE_ASANASUBTASK_HAS_PHOB => self::TYPE_PHOB_HAS_ASANASUBTASK,
); );
return idx($map, $edge_type); return idx($map, $edge_type);
@ -136,6 +148,7 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants {
PhabricatorPHIDConstants::PHID_TYPE_ACNT => 'PhortuneAccount', PhabricatorPHIDConstants::PHID_TYPE_ACNT => 'PhortuneAccount',
PhabricatorPHIDConstants::PHID_TYPE_PRCH => 'PhortunePurchase', PhabricatorPHIDConstants::PHID_TYPE_PRCH => 'PhortunePurchase',
PhabricatorPHIDConstants::PHID_TYPE_CHRG => 'PhortuneCharge', PhabricatorPHIDConstants::PHID_TYPE_CHRG => 'PhortuneCharge',
PhabricatorPHIDConstants::PHID_TYPE_XOBJ => 'DoorkeeperExternalObject',
); );
$class = idx($class_map, $phid_type); $class = idx($class_map, $phid_type);