4 * Publishes tasks representing work that needs to be done into Asana, and
5 * updates the tasks as the corresponding Phabricator objects are updated.
7 final class DoorkeeperAsanaFeedWorker
extends DoorkeeperFeedWorker
{
12 /* -( Publishing Stories )------------------------------------------------- */
16 * This worker is enabled when an Asana workspace ID is configured with
17 * `asana.workspace-id`.
19 public function isEnabled() {
20 return (bool)$this->getWorkspaceID();
25 * Publish stories into Asana using the Asana API.
27 protected function publishFeedStory() {
28 $story = $this->getFeedStory();
29 $data = $story->getStoryData();
31 $viewer = $this->getViewer();
32 $provider = $this->getProvider();
33 $workspace_id = $this->getWorkspaceID();
35 $object = $this->getStoryObject();
36 $src_phid = $object->getPHID();
38 $publisher = $this->getPublisher();
40 // Figure out all the users related to the object. Users go into one of
43 // - Owner: the owner of the object. This user becomes the assigned owner
44 // of the parent task.
45 // - Active: users who are responsible for the object and need to act on
46 // it. For example, reviewers of a "needs review" revision.
47 // - Passive: users who are responsible for the object, but do not need
48 // to act on it right now. For example, reviewers of a "needs revision"
50 // - Follow: users who are following the object; generally CCs.
52 $owner_phid = $publisher->getOwnerPHID($object);
53 $active_phids = $publisher->getActiveUserPHIDs($object);
54 $passive_phids = $publisher->getPassiveUserPHIDs($object);
55 $follow_phids = $publisher->getCCUserPHIDs($object);
58 $all_phids = array_merge(
63 $all_phids = array_unique(array_filter($all_phids));
65 $phid_aid_map = $this->lookupAsanaUserIDs($all_phids);
67 throw new PhabricatorWorkerPermanentFailureException(
68 pht('No related users have linked Asana accounts.'));
71 $owner_asana_id = idx($phid_aid_map, $owner_phid);
72 $all_asana_ids = array_select_keys($phid_aid_map, $all_phids);
73 $all_asana_ids = array_values($all_asana_ids);
75 // Even if the actor isn't a reviewer, etc., try to use their account so
76 // we can post in the correct voice. If we miss, we'll try all the other
79 $try_users = array_merge(
80 array($data->getAuthorPHID()),
81 array_keys($phid_aid_map));
82 $try_users = array_filter($try_users);
84 $access_info = $this->findAnyValidAsanaAccessToken($try_users);
85 list($possessed_user, $possessed_asana_id, $oauth_token) = $access_info;
88 throw new PhabricatorWorkerPermanentFailureException(
90 'Unable to find any Asana user with valid credentials to '.
91 'pull an OAuth token out of.'));
94 $etype_main = PhabricatorObjectHasAsanaTaskEdgeType
::EDGECONST
;
95 $etype_sub = PhabricatorObjectHasAsanaSubtaskEdgeType
::EDGECONST
;
97 $equery = id(new PhabricatorEdgeQuery())
98 ->withSourcePHIDs(array($src_phid))
104 ->needEdgeData(true);
106 $edges = $equery->execute();
108 $main_edge = head($edges[$src_phid][$etype_main]);
110 $main_data = $this->getAsanaTaskData($object) +
array(
111 'assignee' => $owner_asana_id,
114 $projects = $this->getAsanaProjectIDs();
116 $extra_data = array();
118 $extra_data = $main_edge['data'];
120 $refs = id(new DoorkeeperImportEngine())
121 ->setViewer($possessed_user)
122 ->withPHIDs(array($main_edge['dst']))
125 $parent_ref = head($refs);
127 throw new PhabricatorWorkerPermanentFailureException(
128 pht('%s could not be loaded.', 'DoorkeeperExternalObject'));
131 if ($parent_ref->getSyncFailed()) {
133 pht('Synchronization of parent task from Asana failed!'));
134 } else if (!$parent_ref->getIsVisible()) {
137 pht('Skipping main task update, object is no longer visible.'));
138 $extra_data['gone'] = true;
140 $edge_cursor = idx($main_edge['data'], 'cursor', 0);
142 // TODO: This probably breaks, very rarely, on 32-bit systems.
143 if ($edge_cursor <= $story->getChronologicalKey()) {
144 $this->log("%s\n", pht('Updating main task.'));
145 $task_id = $parent_ref->getObjectID();
147 $this->makeAsanaAPICall(
149 'tasks/'.$parent_ref->getObjectID(),
155 pht('Skipping main task update, cursor is ahead of the story.'));
159 // If there are no followers (CCs), and no active or passive users
160 // (reviewers or auditors), and we haven't synchronized the object before,
161 // don't synchronize the object.
162 if (!$active_phids && !$passive_phids && !$follow_phids) {
165 pht('Object has no followers or active/passive users.'));
169 $parent = $this->makeAsanaAPICall(
174 'workspace' => $workspace_id,
175 'projects' => $projects,
176 // NOTE: We initially create parent tasks in the "Later" state but
177 // don't update it afterward, even if the corresponding object
178 // becomes actionable. The expectation is that users will prioritize
179 // tasks in responses to notifications of state changes, and that
180 // we should not overwrite their choices.
181 'assignee_status' => 'later',
184 $parent_ref = $this->newRefFromResult(
185 DoorkeeperBridgeAsana
::OBJTYPE_TASK
,
190 'workspace' => $workspace_id,
194 // Synchronize main task followers.
196 $task_id = $parent_ref->getObjectID();
198 // Reviewers are added as followers of the parent task silently, because
199 // they receive a notification when they are assigned as the owner of their
200 // subtask, so the follow notification is redundant / non-actionable.
201 $silent_followers = array_select_keys($phid_aid_map, $active_phids) +
202 array_select_keys($phid_aid_map, $passive_phids);
203 $silent_followers = array_values($silent_followers);
205 // CCs are added as followers of the parent task with normal notifications,
206 // since they won't get a secondary subtask notification.
207 $noisy_followers = array_select_keys($phid_aid_map, $follow_phids);
208 $noisy_followers = array_values($noisy_followers);
210 // To synchronize follower data, just add all the followers. The task might
211 // have additional followers, but we can't really tell how they got there:
212 // were they CC'd and then unsubscribed, or did they manually follow the
213 // task? Assume the latter since it's easier and less destructive and the
214 // former is rare. To be fully consistent, we should enumerate followers
215 // and remove unknown followers, but that's a fair amount of work for little
216 // benefit, and creates a wider window for race conditions.
218 // Add the silent followers first so that a user who is both a reviewer and
219 // a CC gets silently added and then implicitly skipped by then noisy add.
220 // They will get a subtask notification.
222 // We only do this if the task still exists.
223 if (empty($extra_data['gone'])) {
224 $this->addFollowers($oauth_token, $task_id, $silent_followers, true);
225 $this->addFollowers($oauth_token, $task_id, $noisy_followers);
227 // We're also going to synchronize project data here.
228 $this->addProjects($oauth_token, $task_id, $projects);
231 $dst_phid = $parent_ref->getExternalObject()->getPHID();
233 // Update the main edge.
236 'cursor' => $story->getChronologicalKey(),
239 $edge_options = array(
240 'data' => $edge_data,
243 id(new PhabricatorEdgeEditor())
244 ->addEdge($src_phid, $etype_main, $dst_phid, $edge_options)
247 if (!$parent_ref->getIsVisible()) {
248 throw new PhabricatorWorkerPermanentFailureException(
250 '%s has no visible object on the other side; this '.
251 'likely indicates the Asana task has been deleted.',
252 'DoorkeeperExternalObject'));
255 // Now, handle the subtasks.
257 $sub_editor = new PhabricatorEdgeEditor();
259 // First, find all the object references in Phabricator for tasks that we
260 // know about and import their objects from Asana.
261 $sub_edges = $edges[$src_phid][$etype_sub];
263 $subtask_data = $this->getAsanaSubtaskData($object);
264 $have_phids = array();
267 $refs = id(new DoorkeeperImportEngine())
268 ->setViewer($possessed_user)
269 ->withPHIDs(array_keys($sub_edges))
272 foreach ($refs as $ref) {
273 if ($ref->getSyncFailed()) {
275 pht('Synchronization of child task from Asana failed!'));
277 if (!$ref->getIsVisible()) {
278 $ref->getExternalObject()->delete();
281 $have_phids[$ref->getExternalObject()->getPHID()] = $ref;
285 // Remove any edges in Phabricator which don't have valid tasks in Asana.
286 // These are likely tasks which have been deleted. We're going to respawn
288 foreach ($sub_edges as $sub_phid => $sub_edge) {
289 if (isset($have_phids[$sub_phid])) {
296 'Removing subtask edge to %s, foreign object is not visible.',
298 $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid);
299 unset($sub_edges[$sub_phid]);
303 // For each active or passive user, we're looking for an existing, valid
304 // task. If we find one we're going to update it; if we don't, we'll
305 // create one. We ignore extra subtasks that we didn't create (we gain
306 // nothing by deleting them and might be nuking something important) and
307 // ignore subtasks which have been moved across workspaces or replanted
308 // under new parents (this stuff is too edge-casey to bother checking for
309 // and complicated to fix, as it needs extra API calls). However, we do
310 // clean up subtasks we created whose owners are no longer associated
313 $subtask_states = array_fill_keys($active_phids, false) +
314 array_fill_keys($passive_phids, true);
316 // Continue with only those users who have Asana credentials.
318 $subtask_states = array_select_keys(
320 array_keys($phid_aid_map));
322 $need_subtasks = $subtask_states;
324 $user_to_ref_map = array();
325 $nuke_refs = array();
326 foreach ($sub_edges as $sub_phid => $sub_edge) {
327 $user_phid = idx($sub_edge['data'], 'userPHID');
329 if (isset($need_subtasks[$user_phid])) {
330 unset($need_subtasks[$user_phid]);
331 $user_to_ref_map[$user_phid] = $have_phids[$sub_phid];
333 // This user isn't associated with the object anymore, so get rid
334 // of their task and edge.
335 $nuke_refs[$sub_phid] = $have_phids[$sub_phid];
339 // These are tasks we know about but which are no longer relevant -- for
340 // example, because a user has been removed as a reviewer. Remove them and
343 foreach ($nuke_refs as $sub_phid => $ref) {
344 $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid);
345 $this->makeAsanaAPICall(
347 'tasks/'.$ref->getObjectID(),
350 $ref->getExternalObject()->delete();
353 // For each user that we don't have a subtask for, create a new subtask.
354 foreach ($need_subtasks as $user_phid => $is_completed) {
355 $subtask = $this->makeAsanaAPICall(
359 $subtask_data +
array(
360 'assignee' => $phid_aid_map[$user_phid],
361 'completed' => (int)$is_completed,
362 'parent' => $parent_ref->getObjectID(),
365 $subtask_ref = $this->newRefFromResult(
366 DoorkeeperBridgeAsana
::OBJTYPE_TASK
,
369 $user_to_ref_map[$user_phid] = $subtask_ref;
371 // We don't need to synchronize this subtask's state because we just
372 // set it when we created it.
373 unset($subtask_states[$user_phid]);
375 // Add an edge to track this subtask.
376 $sub_editor->addEdge(
379 $subtask_ref->getExternalObject()->getPHID(),
382 'userPHID' => $user_phid,
387 // Synchronize all the previously-existing subtasks.
389 foreach ($subtask_states as $user_phid => $is_completed) {
390 $this->makeAsanaAPICall(
392 'tasks/'.$user_to_ref_map[$user_phid]->getObjectID(),
394 $subtask_data +
array(
395 'assignee' => $phid_aid_map[$user_phid],
396 'completed' => (int)$is_completed,
400 foreach ($user_to_ref_map as $user_phid => $ref) {
401 // For each subtask, if the acting user isn't the same user as the subtask
402 // owner, remove the acting user as a follower. Currently, the acting user
403 // will be added as a follower only when they create the task, but this
404 // may change in the future (e.g., closing the task may also mark them
405 // as a follower). Wipe every subtask to be sure. The intent here is to
406 // leave only the owner as a follower so that the acting user doesn't
407 // receive notifications about changes to subtask state. Note that
408 // removing followers is silent in all cases in Asana and never produces
409 // any kind of notification, so this isn't self-defeating.
410 if ($user_phid != $possessed_user->getPHID()) {
411 $this->makeAsanaAPICall(
413 'tasks/'.$ref->getObjectID().'/removeFollowers',
416 'followers' => array($possessed_asana_id),
421 // Update edges on our side.
425 // Don't publish the "create" story, since pushing the object into Asana
426 // naturally generates a notification which effectively serves the same
427 // purpose as the "create" story. Similarly, "close" stories generate a
428 // close notification.
429 if (!$publisher->isStoryAboutObjectCreation($object) &&
430 !$publisher->isStoryAboutObjectClosure($object)) {
431 // Post the feed story itself to the main Asana task. We do this last
432 // because everything else is idempotent, so this is the only effect we
433 // can't safely run more than once.
436 ->setRenderWithImpliedContext(true)
437 ->getStoryText($object);
439 $this->makeAsanaAPICall(
441 'tasks/'.$parent_ref->getObjectID().'/stories',
450 /* -( Internals )---------------------------------------------------------- */
452 private function getWorkspaceID() {
453 return PhabricatorEnv
::getEnvConfig('asana.workspace-id');
456 private function getProvider() {
457 if (!$this->provider
) {
458 $provider = PhabricatorAsanaAuthProvider
::getAsanaProvider();
460 throw new PhabricatorWorkerPermanentFailureException(
461 pht('No Asana provider configured.'));
463 $this->provider
= $provider;
465 return $this->provider
;
468 private function getAsanaTaskData($object) {
469 $publisher = $this->getPublisher();
471 $title = $publisher->getObjectTitle($object);
472 $uri = $publisher->getObjectURI($object);
473 $description = $publisher->getObjectDescription($object);
474 $is_completed = $publisher->isObjectClosed($object);
479 $this->getSynchronizationWarning(),
482 $notes = implode("\n\n", $notes);
487 'completed' => (int)$is_completed,
491 private function getAsanaSubtaskData($object) {
492 $publisher = $this->getPublisher();
494 $title = $publisher->getResponsibilityTitle($object);
495 $uri = $publisher->getObjectURI($object);
496 $description = $publisher->getObjectDescription($object);
501 $this->getSynchronizationWarning(),
504 $notes = implode("\n\n", $notes);
512 private function getSynchronizationWarning() {
514 "\xE2\x9A\xA0 DO NOT EDIT THIS TASK \xE2\x9A\xA0\n".
515 "\xE2\x98\xA0 Your changes will not be reflected in Phabricator.\n".
516 "\xE2\x98\xA0 Your changes will be destroyed the next time state ".
520 private function lookupAsanaUserIDs($all_phids) {
523 $all_phids = array_unique(array_filter($all_phids));
528 $accounts = $this->loadAsanaExternalAccounts($all_phids);
530 foreach ($accounts as $account) {
531 $phid_map[$account->getUserPHID()] = $this->getAsanaAccountID($account);
534 // Put this back in input order.
535 $phid_map = array_select_keys($phid_map, $all_phids);
540 private function loadAsanaExternalAccounts(array $user_phids) {
541 $provider = $this->getProvider();
542 $viewer = $this->getViewer();
548 $accounts = id(new PhabricatorExternalAccountQuery())
549 ->setViewer(PhabricatorUser
::getOmnipotentUser())
550 ->withUserPHIDs($user_phids)
551 ->withProviderConfigPHIDs(
553 $provider->getProviderConfigPHID(),
555 ->needAccountIdentifiers(true)
556 ->requireCapabilities(
558 PhabricatorPolicyCapability
::CAN_VIEW
,
559 PhabricatorPolicyCapability
::CAN_EDIT
,
566 private function findAnyValidAsanaAccessToken(array $user_phids) {
567 $provider = $this->getProvider();
568 $viewer = $this->getViewer();
571 return array(null, null, null);
574 $accounts = $this->loadAsanaExternalAccounts($user_phids);
576 // Reorder accounts in the original order.
577 // TODO: This needs to be adjusted if/when we allow you to link multiple
579 $accounts = mpull($accounts, null, 'getUserPHID');
580 $accounts = array_select_keys($accounts, $user_phids);
582 $workspace_id = $this->getWorkspaceID();
584 foreach ($accounts as $account) {
585 // Get a token if possible.
586 $token = $provider->getOAuthAccessToken($account);
591 // Verify we can actually make a call with the token, and that the user
592 // has access to the workspace in question.
594 id(new PhutilAsanaFuture())
595 ->setAccessToken($token)
596 ->setRawAsanaQuery("workspaces/{$workspace_id}")
598 } catch (Exception
$ex) {
599 // This token didn't make it through; try the next account.
603 $user = id(new PhabricatorPeopleQuery())
605 ->withPHIDs(array($account->getUserPHID()))
608 return array($user, $this->getAsanaAccountID($account), $token);
612 return array(null, null, null);
615 private function makeAsanaAPICall($token, $action, $method, array $params) {
616 foreach ($params as $key => $value) {
617 if ($value === null) {
618 unset($params[$key]);
619 } else if (is_array($value)) {
620 unset($params[$key]);
621 foreach ($value as $skey => $svalue) {
622 $params[$key.'['.$skey.']'] = $svalue;
627 return id(new PhutilAsanaFuture())
628 ->setAccessToken($token)
630 ->setRawAsanaQuery($action, $params)
634 private function newRefFromResult($type, $result) {
635 $ref = id(new DoorkeeperObjectRef())
636 ->setApplicationType(DoorkeeperBridgeAsana
::APPTYPE_ASANA
)
637 ->setApplicationDomain(DoorkeeperBridgeAsana
::APPDOMAIN_ASANA
)
638 ->setObjectType($type)
639 ->setObjectID($result['gid'])
640 ->setIsVisible(true);
642 $xobj = $ref->newExternalObject();
643 $ref->attachExternalObject($xobj);
645 $bridge = new DoorkeeperBridgeAsana();
646 $bridge->fillObjectFromData($xobj, $result);
653 private function addFollowers(
664 'followers' => $followers,
667 // NOTE: This uses a currently-undocumented API feature to suppress the
668 // follow notifications.
670 $data['silent'] = true;
673 $this->makeAsanaAPICall(
675 "tasks/{$task_id}/addFollowers",
680 private function getAsanaProjectIDs() {
681 $project_ids = array();
683 $publisher = $this->getPublisher();
684 $config = PhabricatorEnv
::getEnvConfig('asana.project-ids');
685 if (is_array($config)) {
686 $ids = idx($config, get_class($publisher));
687 if (is_array($ids)) {
688 foreach ($ids as $id) {
689 if (is_scalar($id)) {
690 $project_ids[] = $id;
699 private function addProjects(
702 array $project_ids) {
703 foreach ($project_ids as $project_id) {
704 $data = array('project' => $project_id);
705 $this->makeAsanaAPICall(
707 "tasks/{$task_id}/addProject",
713 private function getAsanaAccountID(PhabricatorExternalAccount
$account) {
714 $identifiers = $account->getAccountIdentifiers();
716 if (count($identifiers) !== 1) {
719 'Expected external Asana account to have exactly one external '.
720 'account identifier, found %s.',
721 phutil_count($identifiers)));
724 return head($identifiers)->getIdentifierRaw();