Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / doorkeeper / worker / DoorkeeperAsanaFeedWorker.php
blob8648f5d7f9a4da9767c5010e1ba251ec8001e042
1 <?php
3 /**
4 * Publishes tasks representing work that needs to be done into Asana, and
5 * updates the tasks as the corresponding Phabricator objects are updated.
6 */
7 final class DoorkeeperAsanaFeedWorker extends DoorkeeperFeedWorker {
9 private $provider;
12 /* -( Publishing Stories )------------------------------------------------- */
15 /**
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();
24 /**
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
41 // four buckets:
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"
49 // 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);
57 $all_phids = array();
58 $all_phids = array_merge(
59 array($owner_phid),
60 $active_phids,
61 $passive_phids,
62 $follow_phids);
63 $all_phids = array_unique(array_filter($all_phids));
65 $phid_aid_map = $this->lookupAsanaUserIDs($all_phids);
66 if (!$phid_aid_map) {
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
77 // related users.
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;
87 if (!$oauth_token) {
88 throw new PhabricatorWorkerPermanentFailureException(
89 pht(
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))
99 ->withEdgeTypes(
100 array(
101 $etype_main,
102 $etype_sub,
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();
117 if ($main_edge) {
118 $extra_data = $main_edge['data'];
120 $refs = id(new DoorkeeperImportEngine())
121 ->setViewer($possessed_user)
122 ->withPHIDs(array($main_edge['dst']))
123 ->execute();
125 $parent_ref = head($refs);
126 if (!$parent_ref) {
127 throw new PhabricatorWorkerPermanentFailureException(
128 pht('%s could not be loaded.', 'DoorkeeperExternalObject'));
131 if ($parent_ref->getSyncFailed()) {
132 throw new Exception(
133 pht('Synchronization of parent task from Asana failed!'));
134 } else if (!$parent_ref->getIsVisible()) {
135 $this->log(
136 "%s\n",
137 pht('Skipping main task update, object is no longer visible.'));
138 $extra_data['gone'] = true;
139 } else {
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(
148 $oauth_token,
149 'tasks/'.$parent_ref->getObjectID(),
150 'PUT',
151 $main_data);
152 } else {
153 $this->log(
154 "%s\n",
155 pht('Skipping main task update, cursor is ahead of the story.'));
158 } else {
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) {
163 $this->log(
164 "%s\n",
165 pht('Object has no followers or active/passive users.'));
166 return;
169 $parent = $this->makeAsanaAPICall(
170 $oauth_token,
171 'tasks',
172 'POST',
173 array(
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',
182 ) + $main_data);
184 $parent_ref = $this->newRefFromResult(
185 DoorkeeperBridgeAsana::OBJTYPE_TASK,
186 $parent);
189 $extra_data = array(
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.
235 $edge_data = array(
236 'cursor' => $story->getChronologicalKey(),
237 ) + $extra_data;
239 $edge_options = array(
240 'data' => $edge_data,
243 id(new PhabricatorEdgeEditor())
244 ->addEdge($src_phid, $etype_main, $dst_phid, $edge_options)
245 ->save();
247 if (!$parent_ref->getIsVisible()) {
248 throw new PhabricatorWorkerPermanentFailureException(
249 pht(
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];
262 $sub_refs = array();
263 $subtask_data = $this->getAsanaSubtaskData($object);
264 $have_phids = array();
266 if ($sub_edges) {
267 $refs = id(new DoorkeeperImportEngine())
268 ->setViewer($possessed_user)
269 ->withPHIDs(array_keys($sub_edges))
270 ->execute();
272 foreach ($refs as $ref) {
273 if ($ref->getSyncFailed()) {
274 throw new Exception(
275 pht('Synchronization of child task from Asana failed!'));
277 if (!$ref->getIsVisible()) {
278 $ref->getExternalObject()->delete();
279 continue;
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
287 // them.
288 foreach ($sub_edges as $sub_phid => $sub_edge) {
289 if (isset($have_phids[$sub_phid])) {
290 continue;
293 $this->log(
294 "%s\n",
295 pht(
296 'Removing subtask edge to %s, foreign object is not visible.',
297 $sub_phid));
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
311 // with the object.
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(
319 $subtask_states,
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];
332 } else {
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
341 // their edges.
343 foreach ($nuke_refs as $sub_phid => $ref) {
344 $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid);
345 $this->makeAsanaAPICall(
346 $oauth_token,
347 'tasks/'.$ref->getObjectID(),
348 'DELETE',
349 array());
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(
356 $oauth_token,
357 'tasks',
358 'POST',
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,
367 $subtask);
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(
377 $src_phid,
378 $etype_sub,
379 $subtask_ref->getExternalObject()->getPHID(),
380 array(
381 'data' => array(
382 'userPHID' => $user_phid,
387 // Synchronize all the previously-existing subtasks.
389 foreach ($subtask_states as $user_phid => $is_completed) {
390 $this->makeAsanaAPICall(
391 $oauth_token,
392 'tasks/'.$user_to_ref_map[$user_phid]->getObjectID(),
393 'PUT',
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(
412 $oauth_token,
413 'tasks/'.$ref->getObjectID().'/removeFollowers',
414 'POST',
415 array(
416 'followers' => array($possessed_asana_id),
421 // Update edges on our side.
423 $sub_editor->save();
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.
435 $text = $publisher
436 ->setRenderWithImpliedContext(true)
437 ->getStoryText($object);
439 $this->makeAsanaAPICall(
440 $oauth_token,
441 'tasks/'.$parent_ref->getObjectID().'/stories',
442 'POST',
443 array(
444 'text' => $text,
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();
459 if (!$provider) {
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);
476 $notes = array(
477 $description,
478 $uri,
479 $this->getSynchronizationWarning(),
482 $notes = implode("\n\n", $notes);
484 return array(
485 'name' => $title,
486 'notes' => $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);
498 $notes = array(
499 $description,
500 $uri,
501 $this->getSynchronizationWarning(),
504 $notes = implode("\n\n", $notes);
506 return array(
507 'name' => $title,
508 'notes' => $notes,
512 private function getSynchronizationWarning() {
513 return pht(
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 ".
517 "is synchronized.",
518 PlatformSymbols::getPlatformServerName());
521 private function lookupAsanaUserIDs($all_phids) {
522 $phid_map = array();
524 $all_phids = array_unique(array_filter($all_phids));
525 if (!$all_phids) {
526 return $phid_map;
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);
538 return $phid_map;
541 private function loadAsanaExternalAccounts(array $user_phids) {
542 $provider = $this->getProvider();
543 $viewer = $this->getViewer();
545 if (!$user_phids) {
546 return array();
549 $accounts = id(new PhabricatorExternalAccountQuery())
550 ->setViewer(PhabricatorUser::getOmnipotentUser())
551 ->withUserPHIDs($user_phids)
552 ->withProviderConfigPHIDs(
553 array(
554 $provider->getProviderConfigPHID(),
556 ->needAccountIdentifiers(true)
557 ->requireCapabilities(
558 array(
559 PhabricatorPolicyCapability::CAN_VIEW,
560 PhabricatorPolicyCapability::CAN_EDIT,
562 ->execute();
564 return $accounts;
567 private function findAnyValidAsanaAccessToken(array $user_phids) {
568 $provider = $this->getProvider();
569 $viewer = $this->getViewer();
571 if (!$user_phids) {
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
579 // accounts.
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);
588 if (!$token) {
589 continue;
592 // Verify we can actually make a call with the token, and that the user
593 // has access to the workspace in question.
594 try {
595 id(new PhutilAsanaFuture())
596 ->setAccessToken($token)
597 ->setRawAsanaQuery("workspaces/{$workspace_id}")
598 ->resolve();
599 } catch (Exception $ex) {
600 // This token didn't make it through; try the next account.
601 continue;
604 $user = id(new PhabricatorPeopleQuery())
605 ->setViewer($viewer)
606 ->withPHIDs(array($account->getUserPHID()))
607 ->executeOne();
608 if ($user) {
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)
630 ->setMethod($method)
631 ->setRawAsanaQuery($action, $params)
632 ->resolve();
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);
649 $xobj->save();
651 return $ref;
654 private function addFollowers(
655 $oauth_token,
656 $task_id,
657 array $followers,
658 $silent = false) {
660 if (!$followers) {
661 return;
664 $data = array(
665 'followers' => $followers,
668 // NOTE: This uses a currently-undocumented API feature to suppress the
669 // follow notifications.
670 if ($silent) {
671 $data['silent'] = true;
674 $this->makeAsanaAPICall(
675 $oauth_token,
676 "tasks/{$task_id}/addFollowers",
677 'POST',
678 $data);
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;
697 return $project_ids;
700 private function addProjects(
701 $oauth_token,
702 $task_id,
703 array $project_ids) {
704 foreach ($project_ids as $project_id) {
705 $data = array('project' => $project_id);
706 $this->makeAsanaAPICall(
707 $oauth_token,
708 "tasks/{$task_id}/addProject",
709 'POST',
710 $data);
714 private function getAsanaAccountID(PhabricatorExternalAccount $account) {
715 $identifiers = $account->getAccountIdentifiers();
717 if (count($identifiers) !== 1) {
718 throw new Exception(
719 pht(
720 'Expected external Asana account to have exactly one external '.
721 'account identifier, found %s.',
722 phutil_count($identifiers)));
725 return head($identifiers)->getIdentifierRaw();