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 %s.\n".
516 "\xE2\x98\xA0 Your changes will be destroyed the next time state ".
518 PlatformSymbols
::getPlatformServerName());
521 private function lookupAsanaUserIDs($all_phids) {
524 $all_phids = array_unique(array_filter($all_phids));
529 $accounts = $this->loadAsanaExternalAccounts($all_phids);
531 foreach ($accounts as $account) {
532 $phid_map[$account->getUserPHID()] = $this->getAsanaAccountID($account);
535 // Put this back in input order.
536 $phid_map = array_select_keys($phid_map, $all_phids);
541 private function loadAsanaExternalAccounts(array $user_phids) {
542 $provider = $this->getProvider();
543 $viewer = $this->getViewer();
549 $accounts = id(new PhabricatorExternalAccountQuery())
550 ->setViewer(PhabricatorUser
::getOmnipotentUser())
551 ->withUserPHIDs($user_phids)
552 ->withProviderConfigPHIDs(
554 $provider->getProviderConfigPHID(),
556 ->needAccountIdentifiers(true)
557 ->requireCapabilities(
559 PhabricatorPolicyCapability
::CAN_VIEW
,
560 PhabricatorPolicyCapability
::CAN_EDIT
,
567 private function findAnyValidAsanaAccessToken(array $user_phids) {
568 $provider = $this->getProvider();
569 $viewer = $this->getViewer();
572 return array(null, null, null);
575 $accounts = $this->loadAsanaExternalAccounts($user_phids);
577 // Reorder accounts in the original order.
578 // TODO: This needs to be adjusted if/when we allow you to link multiple
580 $accounts = mpull($accounts, null, 'getUserPHID');
581 $accounts = array_select_keys($accounts, $user_phids);
583 $workspace_id = $this->getWorkspaceID();
585 foreach ($accounts as $account) {
586 // Get a token if possible.
587 $token = $provider->getOAuthAccessToken($account);
592 // Verify we can actually make a call with the token, and that the user
593 // has access to the workspace in question.
595 id(new PhutilAsanaFuture())
596 ->setAccessToken($token)
597 ->setRawAsanaQuery("workspaces/{$workspace_id}")
599 } catch (Exception
$ex) {
600 // This token didn't make it through; try the next account.
604 $user = id(new PhabricatorPeopleQuery())
606 ->withPHIDs(array($account->getUserPHID()))
609 return array($user, $this->getAsanaAccountID($account), $token);
613 return array(null, null, null);
616 private function makeAsanaAPICall($token, $action, $method, array $params) {
617 foreach ($params as $key => $value) {
618 if ($value === null) {
619 unset($params[$key]);
620 } else if (is_array($value)) {
621 unset($params[$key]);
622 foreach ($value as $skey => $svalue) {
623 $params[$key.'['.$skey.']'] = $svalue;
628 return id(new PhutilAsanaFuture())
629 ->setAccessToken($token)
631 ->setRawAsanaQuery($action, $params)
635 private function newRefFromResult($type, $result) {
636 $ref = id(new DoorkeeperObjectRef())
637 ->setApplicationType(DoorkeeperBridgeAsana
::APPTYPE_ASANA
)
638 ->setApplicationDomain(DoorkeeperBridgeAsana
::APPDOMAIN_ASANA
)
639 ->setObjectType($type)
640 ->setObjectID($result['gid'])
641 ->setIsVisible(true);
643 $xobj = $ref->newExternalObject();
644 $ref->attachExternalObject($xobj);
646 $bridge = new DoorkeeperBridgeAsana();
647 $bridge->fillObjectFromData($xobj, $result);
654 private function addFollowers(
665 'followers' => $followers,
668 // NOTE: This uses a currently-undocumented API feature to suppress the
669 // follow notifications.
671 $data['silent'] = true;
674 $this->makeAsanaAPICall(
676 "tasks/{$task_id}/addFollowers",
681 private function getAsanaProjectIDs() {
682 $project_ids = array();
684 $publisher = $this->getPublisher();
685 $config = PhabricatorEnv
::getEnvConfig('asana.project-ids');
686 if (is_array($config)) {
687 $ids = idx($config, get_class($publisher));
688 if (is_array($ids)) {
689 foreach ($ids as $id) {
690 if (is_scalar($id)) {
691 $project_ids[] = $id;
700 private function addProjects(
703 array $project_ids) {
704 foreach ($project_ids as $project_id) {
705 $data = array('project' => $project_id);
706 $this->makeAsanaAPICall(
708 "tasks/{$task_id}/addProject",
714 private function getAsanaAccountID(PhabricatorExternalAccount
$account) {
715 $identifiers = $account->getAccountIdentifiers();
717 if (count($identifiers) !== 1) {
720 'Expected external Asana account to have exactly one external '.
721 'account identifier, found %s.',
722 phutil_count($identifiers)));
725 return head($identifiers)->getIdentifierRaw();