[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
1 <?php 2 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 DoorkeeperFeedWorkerAsana extends DoorkeeperFeedWorker { 8 9 private $provider; 10 11 12 /* -( Publishing Stories )------------------------------------------------- */ 13 14 15 /** 16 * This worker is enabled when an Asana workspace ID is configured with 17 * `asana.workspace-id`. 18 */ 19 public function isEnabled() { 20 return (bool)$this->getWorkspaceID(); 21 } 22 23 24 /** 25 * Publish stories into Asana using the Asana API. 26 */ 27 protected function publishFeedStory() { 28 $story = $this->getFeedStory(); 29 $data = $story->getStoryData(); 30 31 $viewer = $this->getViewer(); 32 $provider = $this->getProvider(); 33 $workspace_id = $this->getWorkspaceID(); 34 35 $object = $this->getStoryObject(); 36 $src_phid = $object->getPHID(); 37 38 $publisher = $this->getPublisher(); 39 40 // Figure out all the users related to the object. Users go into one of 41 // four buckets: 42 // 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. 51 52 $owner_phid = $publisher->getOwnerPHID($object); 53 $active_phids = $publisher->getActiveUserPHIDs($object); 54 $passive_phids = $publisher->getPassiveUserPHIDs($object); 55 $follow_phids = $publisher->getCCUserPHIDs($object); 56 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)); 64 65 $phid_aid_map = $this->lookupAsanaUserIDs($all_phids); 66 if (!$phid_aid_map) { 67 throw new PhabricatorWorkerPermanentFailureException( 68 'No related users have linked Asana accounts.'); 69 } 70 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); 74 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. 78 79 $try_users = array_merge( 80 array($data->getAuthorPHID()), 81 array_keys($phid_aid_map)); 82 $try_users = array_filter($try_users); 83 84 $access_info = $this->findAnyValidAsanaAccessToken($try_users); 85 list($possessed_user, $possessed_asana_id, $oauth_token) = $access_info; 86 87 if (!$oauth_token) { 88 throw new PhabricatorWorkerPermanentFailureException( 89 'Unable to find any Asana user with valid credentials to '. 90 'pull an OAuth token out of.'); 91 } 92 93 $etype_main = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANATASK; 94 $etype_sub = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANASUBTASK; 95 96 $equery = id(new PhabricatorEdgeQuery()) 97 ->withSourcePHIDs(array($src_phid)) 98 ->withEdgeTypes( 99 array( 100 $etype_main, 101 $etype_sub, 102 )) 103 ->needEdgeData(true); 104 105 $edges = $equery->execute(); 106 107 $main_edge = head($edges[$src_phid][$etype_main]); 108 109 $main_data = $this->getAsanaTaskData($object) + array( 110 'assignee' => $owner_asana_id, 111 ); 112 113 $projects = $this->getAsanaProjectIDs(); 114 115 $extra_data = array(); 116 if ($main_edge) { 117 $extra_data = $main_edge['data']; 118 119 $refs = id(new DoorkeeperImportEngine()) 120 ->setViewer($possessed_user) 121 ->withPHIDs(array($main_edge['dst'])) 122 ->execute(); 123 124 $parent_ref = head($refs); 125 if (!$parent_ref) { 126 throw new PhabricatorWorkerPermanentFailureException( 127 'DoorkeeperExternalObject could not be loaded.'); 128 } 129 130 if ($parent_ref->getSyncFailed()) { 131 throw new Exception( 132 'Synchronization of parent task from Asana failed!'); 133 } else if (!$parent_ref->getIsVisible()) { 134 $this->log("Skipping main task update, object is no longer visible.\n"); 135 $extra_data['gone'] = true; 136 } else { 137 $edge_cursor = idx($main_edge['data'], 'cursor', 0); 138 139 // TODO: This probably breaks, very rarely, on 32-bit systems. 140 if ($edge_cursor <= $story->getChronologicalKey()) { 141 $this->log("Updating main task.\n"); 142 $task_id = $parent_ref->getObjectID(); 143 144 $this->makeAsanaAPICall( 145 $oauth_token, 146 'tasks/'.$parent_ref->getObjectID(), 147 'PUT', 148 $main_data); 149 } else { 150 $this->log( 151 "Skipping main task update, cursor is ahead of the story.\n"); 152 } 153 } 154 } else { 155 // If there are no followers (CCs), and no active or passive users 156 // (reviewers or auditors), and we haven't synchronized the object before, 157 // don't synchronize the object. 158 if (!$active_phids && !$passive_phids && !$follow_phids) { 159 $this->log("Object has no followers or active/passive users.\n"); 160 return; 161 } 162 163 $parent = $this->makeAsanaAPICall( 164 $oauth_token, 165 'tasks', 166 'POST', 167 array( 168 'workspace' => $workspace_id, 169 'projects' => $projects, 170 // NOTE: We initially create parent tasks in the "Later" state but 171 // don't update it afterward, even if the corresponding object 172 // becomes actionable. The expectation is that users will prioritize 173 // tasks in responses to notifications of state changes, and that 174 // we should not overwrite their choices. 175 'assignee_status' => 'later', 176 ) + $main_data); 177 178 $parent_ref = $this->newRefFromResult( 179 DoorkeeperBridgeAsana::OBJTYPE_TASK, 180 $parent); 181 182 183 $extra_data = array( 184 'workspace' => $workspace_id, 185 ); 186 } 187 188 // Synchronize main task followers. 189 190 $task_id = $parent_ref->getObjectID(); 191 192 // Reviewers are added as followers of the parent task silently, because 193 // they receive a notification when they are assigned as the owner of their 194 // subtask, so the follow notification is redundant / non-actionable. 195 $silent_followers = array_select_keys($phid_aid_map, $active_phids) + 196 array_select_keys($phid_aid_map, $passive_phids); 197 $silent_followers = array_values($silent_followers); 198 199 // CCs are added as followers of the parent task with normal notifications, 200 // since they won't get a secondary subtask notification. 201 $noisy_followers = array_select_keys($phid_aid_map, $follow_phids); 202 $noisy_followers = array_values($noisy_followers); 203 204 // To synchronize follower data, just add all the followers. The task might 205 // have additional followers, but we can't really tell how they got there: 206 // were they CC'd and then unsubscribed, or did they manually follow the 207 // task? Assume the latter since it's easier and less destructive and the 208 // former is rare. To be fully consistent, we should enumerate followers 209 // and remove unknown followers, but that's a fair amount of work for little 210 // benefit, and creates a wider window for race conditions. 211 212 // Add the silent followers first so that a user who is both a reviewer and 213 // a CC gets silently added and then implicitly skipped by then noisy add. 214 // They will get a subtask notification. 215 216 // We only do this if the task still exists. 217 if (empty($extra_data['gone'])) { 218 $this->addFollowers($oauth_token, $task_id, $silent_followers, true); 219 $this->addFollowers($oauth_token, $task_id, $noisy_followers); 220 221 // We're also going to synchronize project data here. 222 $this->addProjects($oauth_token, $task_id, $projects); 223 } 224 225 $dst_phid = $parent_ref->getExternalObject()->getPHID(); 226 227 // Update the main edge. 228 229 $edge_data = array( 230 'cursor' => $story->getChronologicalKey(), 231 ) + $extra_data; 232 233 $edge_options = array( 234 'data' => $edge_data, 235 ); 236 237 id(new PhabricatorEdgeEditor()) 238 ->addEdge($src_phid, $etype_main, $dst_phid, $edge_options) 239 ->save(); 240 241 if (!$parent_ref->getIsVisible()) { 242 throw new PhabricatorWorkerPermanentFailureException( 243 'DoorkeeperExternalObject has no visible object on the other side; '. 244 'this likely indicates the Asana task has been deleted.'); 245 } 246 247 // Now, handle the subtasks. 248 249 $sub_editor = new PhabricatorEdgeEditor(); 250 251 // First, find all the object references in Phabricator for tasks that we 252 // know about and import their objects from Asana. 253 $sub_edges = $edges[$src_phid][$etype_sub]; 254 $sub_refs = array(); 255 $subtask_data = $this->getAsanaSubtaskData($object); 256 $have_phids = array(); 257 258 if ($sub_edges) { 259 $refs = id(new DoorkeeperImportEngine()) 260 ->setViewer($possessed_user) 261 ->withPHIDs(array_keys($sub_edges)) 262 ->execute(); 263 264 foreach ($refs as $ref) { 265 if ($ref->getSyncFailed()) { 266 throw new Exception( 267 'Synchronization of child task from Asana failed!'); 268 } 269 if (!$ref->getIsVisible()) { 270 $ref->getExternalObject()->delete(); 271 continue; 272 } 273 $have_phids[$ref->getExternalObject()->getPHID()] = $ref; 274 } 275 } 276 277 // Remove any edges in Phabricator which don't have valid tasks in Asana. 278 // These are likely tasks which have been deleted. We're going to respawn 279 // them. 280 foreach ($sub_edges as $sub_phid => $sub_edge) { 281 if (isset($have_phids[$sub_phid])) { 282 continue; 283 } 284 285 $this->log( 286 "Removing subtask edge to %s, foreign object is not visible.\n", 287 $sub_phid); 288 $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid); 289 unset($sub_edges[$sub_phid]); 290 } 291 292 293 // For each active or passive user, we're looking for an existing, valid 294 // task. If we find one we're going to update it; if we don't, we'll 295 // create one. We ignore extra subtasks that we didn't create (we gain 296 // nothing by deleting them and might be nuking something important) and 297 // ignore subtasks which have been moved across workspaces or replanted 298 // under new parents (this stuff is too edge-casey to bother checking for 299 // and complicated to fix, as it needs extra API calls). However, we do 300 // clean up subtasks we created whose owners are no longer associated 301 // with the object. 302 303 $subtask_states = array_fill_keys($active_phids, false) + 304 array_fill_keys($passive_phids, true); 305 306 // Continue with only those users who have Asana credentials. 307 308 $subtask_states = array_select_keys( 309 $subtask_states, 310 array_keys($phid_aid_map)); 311 312 $need_subtasks = $subtask_states; 313 314 $user_to_ref_map = array(); 315 $nuke_refs = array(); 316 foreach ($sub_edges as $sub_phid => $sub_edge) { 317 $user_phid = idx($sub_edge['data'], 'userPHID'); 318 319 if (isset($need_subtasks[$user_phid])) { 320 unset($need_subtasks[$user_phid]); 321 $user_to_ref_map[$user_phid] = $have_phids[$sub_phid]; 322 } else { 323 // This user isn't associated with the object anymore, so get rid 324 // of their task and edge. 325 $nuke_refs[$sub_phid] = $have_phids[$sub_phid]; 326 } 327 } 328 329 // These are tasks we know about but which are no longer relevant -- for 330 // example, because a user has been removed as a reviewer. Remove them and 331 // their edges. 332 333 foreach ($nuke_refs as $sub_phid => $ref) { 334 $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid); 335 $this->makeAsanaAPICall( 336 $oauth_token, 337 'tasks/'.$ref->getObjectID(), 338 'DELETE', 339 array()); 340 $ref->getExternalObject()->delete(); 341 } 342 343 // For each user that we don't have a subtask for, create a new subtask. 344 foreach ($need_subtasks as $user_phid => $is_completed) { 345 $subtask = $this->makeAsanaAPICall( 346 $oauth_token, 347 'tasks', 348 'POST', 349 $subtask_data + array( 350 'assignee' => $phid_aid_map[$user_phid], 351 'completed' => $is_completed, 352 'parent' => $parent_ref->getObjectID(), 353 )); 354 355 $subtask_ref = $this->newRefFromResult( 356 DoorkeeperBridgeAsana::OBJTYPE_TASK, 357 $subtask); 358 359 $user_to_ref_map[$user_phid] = $subtask_ref; 360 361 // We don't need to synchronize this subtask's state because we just 362 // set it when we created it. 363 unset($subtask_states[$user_phid]); 364 365 // Add an edge to track this subtask. 366 $sub_editor->addEdge( 367 $src_phid, 368 $etype_sub, 369 $subtask_ref->getExternalObject()->getPHID(), 370 array( 371 'data' => array( 372 'userPHID' => $user_phid, 373 ), 374 )); 375 } 376 377 // Synchronize all the previously-existing subtasks. 378 379 foreach ($subtask_states as $user_phid => $is_completed) { 380 $this->makeAsanaAPICall( 381 $oauth_token, 382 'tasks/'.$user_to_ref_map[$user_phid]->getObjectID(), 383 'PUT', 384 $subtask_data + array( 385 'assignee' => $phid_aid_map[$user_phid], 386 'completed' => $is_completed, 387 )); 388 } 389 390 foreach ($user_to_ref_map as $user_phid => $ref) { 391 // For each subtask, if the acting user isn't the same user as the subtask 392 // owner, remove the acting user as a follower. Currently, the acting user 393 // will be added as a follower only when they create the task, but this 394 // may change in the future (e.g., closing the task may also mark them 395 // as a follower). Wipe every subtask to be sure. The intent here is to 396 // leave only the owner as a follower so that the acting user doesn't 397 // receive notifications about changes to subtask state. Note that 398 // removing followers is silent in all cases in Asana and never produces 399 // any kind of notification, so this isn't self-defeating. 400 if ($user_phid != $possessed_user->getPHID()) { 401 $this->makeAsanaAPICall( 402 $oauth_token, 403 'tasks/'.$ref->getObjectID().'/removeFollowers', 404 'POST', 405 array( 406 'followers' => array($possessed_asana_id), 407 )); 408 } 409 } 410 411 // Update edges on our side. 412 413 $sub_editor->save(); 414 415 // Don't publish the "create" story, since pushing the object into Asana 416 // naturally generates a notification which effectively serves the same 417 // purpose as the "create" story. Similarly, "close" stories generate a 418 // close notification. 419 if (!$publisher->isStoryAboutObjectCreation($object) && 420 !$publisher->isStoryAboutObjectClosure($object)) { 421 // Post the feed story itself to the main Asana task. We do this last 422 // because everything else is idempotent, so this is the only effect we 423 // can't safely run more than once. 424 425 $text = $publisher 426 ->setRenderWithImpliedContext(true) 427 ->getStoryText($object); 428 429 $this->makeAsanaAPICall( 430 $oauth_token, 431 'tasks/'.$parent_ref->getObjectID().'/stories', 432 'POST', 433 array( 434 'text' => $text, 435 )); 436 } 437 } 438 439 440 /* -( Internals )---------------------------------------------------------- */ 441 442 private function getWorkspaceID() { 443 return PhabricatorEnv::getEnvConfig('asana.workspace-id'); 444 } 445 446 private function getProvider() { 447 if (!$this->provider) { 448 $provider = PhabricatorAsanaAuthProvider::getAsanaProvider(); 449 if (!$provider) { 450 throw new PhabricatorWorkerPermanentFailureException( 451 'No Asana provider configured.'); 452 } 453 $this->provider = $provider; 454 } 455 return $this->provider; 456 } 457 458 private function getAsanaTaskData($object) { 459 $publisher = $this->getPublisher(); 460 461 $title = $publisher->getObjectTitle($object); 462 $uri = $publisher->getObjectURI($object); 463 $description = $publisher->getObjectDescription($object); 464 $is_completed = $publisher->isObjectClosed($object); 465 466 $notes = array( 467 $description, 468 $uri, 469 $this->getSynchronizationWarning(), 470 ); 471 472 $notes = implode("\n\n", $notes); 473 474 return array( 475 'name' => $title, 476 'notes' => $notes, 477 'completed' => $is_completed, 478 ); 479 } 480 481 private function getAsanaSubtaskData($object) { 482 $publisher = $this->getPublisher(); 483 484 $title = $publisher->getResponsibilityTitle($object); 485 $uri = $publisher->getObjectURI($object); 486 $description = $publisher->getObjectDescription($object); 487 488 $notes = array( 489 $description, 490 $uri, 491 $this->getSynchronizationWarning(), 492 ); 493 494 $notes = implode("\n\n", $notes); 495 496 return array( 497 'name' => $title, 498 'notes' => $notes, 499 ); 500 } 501 502 private function getSynchronizationWarning() { 503 return 504 "\xE2\x9A\xA0 DO NOT EDIT THIS TASK \xE2\x9A\xA0\n". 505 "\xE2\x98\xA0 Your changes will not be reflected in Phabricator.\n". 506 "\xE2\x98\xA0 Your changes will be destroyed the next time state ". 507 "is synchronized."; 508 } 509 510 private function lookupAsanaUserIDs($all_phids) { 511 $phid_map = array(); 512 513 $all_phids = array_unique(array_filter($all_phids)); 514 if (!$all_phids) { 515 return $phid_map; 516 } 517 518 $provider = PhabricatorAsanaAuthProvider::getAsanaProvider(); 519 520 $accounts = id(new PhabricatorExternalAccountQuery()) 521 ->setViewer(PhabricatorUser::getOmnipotentUser()) 522 ->withUserPHIDs($all_phids) 523 ->withAccountTypes(array($provider->getProviderType())) 524 ->withAccountDomains(array($provider->getProviderDomain())) 525 ->requireCapabilities( 526 array( 527 PhabricatorPolicyCapability::CAN_VIEW, 528 PhabricatorPolicyCapability::CAN_EDIT, 529 )) 530 ->execute(); 531 532 foreach ($accounts as $account) { 533 $phid_map[$account->getUserPHID()] = $account->getAccountID(); 534 } 535 536 // Put this back in input order. 537 $phid_map = array_select_keys($phid_map, $all_phids); 538 539 return $phid_map; 540 } 541 542 private function findAnyValidAsanaAccessToken(array $user_phids) { 543 if (!$user_phids) { 544 return array(null, null, null); 545 } 546 547 $provider = $this->getProvider(); 548 $viewer = $this->getViewer(); 549 550 $accounts = id(new PhabricatorExternalAccountQuery()) 551 ->setViewer($viewer) 552 ->withUserPHIDs($user_phids) 553 ->withAccountTypes(array($provider->getProviderType())) 554 ->withAccountDomains(array($provider->getProviderDomain())) 555 ->requireCapabilities( 556 array( 557 PhabricatorPolicyCapability::CAN_VIEW, 558 PhabricatorPolicyCapability::CAN_EDIT, 559 )) 560 ->execute(); 561 562 // Reorder accounts in the original order. 563 // TODO: This needs to be adjusted if/when we allow you to link multiple 564 // accounts. 565 $accounts = mpull($accounts, null, 'getUserPHID'); 566 $accounts = array_select_keys($accounts, $user_phids); 567 568 $workspace_id = $this->getWorkspaceID(); 569 570 foreach ($accounts as $account) { 571 // Get a token if possible. 572 $token = $provider->getOAuthAccessToken($account); 573 if (!$token) { 574 continue; 575 } 576 577 // Verify we can actually make a call with the token, and that the user 578 // has access to the workspace in question. 579 try { 580 id(new PhutilAsanaFuture()) 581 ->setAccessToken($token) 582 ->setRawAsanaQuery("workspaces/{$workspace_id}") 583 ->resolve(); 584 } catch (Exception $ex) { 585 // This token didn't make it through; try the next account. 586 continue; 587 } 588 589 $user = id(new PhabricatorPeopleQuery()) 590 ->setViewer($viewer) 591 ->withPHIDs(array($account->getUserPHID())) 592 ->executeOne(); 593 if ($user) { 594 return array($user, $account->getAccountID(), $token); 595 } 596 } 597 598 return array(null, null, null); 599 } 600 601 private function makeAsanaAPICall($token, $action, $method, array $params) { 602 foreach ($params as $key => $value) { 603 if ($value === null) { 604 unset($params[$key]); 605 } else if (is_array($value)) { 606 unset($params[$key]); 607 foreach ($value as $skey => $svalue) { 608 $params[$key.'['.$skey.']'] = $svalue; 609 } 610 } 611 } 612 613 return id(new PhutilAsanaFuture()) 614 ->setAccessToken($token) 615 ->setMethod($method) 616 ->setRawAsanaQuery($action, $params) 617 ->resolve(); 618 } 619 620 private function newRefFromResult($type, $result) { 621 $ref = id(new DoorkeeperObjectRef()) 622 ->setApplicationType(DoorkeeperBridgeAsana::APPTYPE_ASANA) 623 ->setApplicationDomain(DoorkeeperBridgeAsana::APPDOMAIN_ASANA) 624 ->setObjectType($type) 625 ->setObjectID($result['id']) 626 ->setIsVisible(true); 627 628 $xobj = $ref->newExternalObject(); 629 $ref->attachExternalObject($xobj); 630 631 $bridge = new DoorkeeperBridgeAsana(); 632 $bridge->fillObjectFromData($xobj, $result); 633 634 $xobj->save(); 635 636 return $ref; 637 } 638 639 private function addFollowers( 640 $oauth_token, 641 $task_id, 642 array $followers, 643 $silent = false) { 644 645 if (!$followers) { 646 return; 647 } 648 649 $data = array( 650 'followers' => $followers, 651 ); 652 653 // NOTE: This uses a currently-undocumented API feature to suppress the 654 // follow notifications. 655 if ($silent) { 656 $data['silent'] = true; 657 } 658 659 $this->makeAsanaAPICall( 660 $oauth_token, 661 "tasks/{$task_id}/addFollowers", 662 'POST', 663 $data); 664 } 665 666 private function getAsanaProjectIDs() { 667 $project_ids = array(); 668 669 $publisher = $this->getPublisher(); 670 $config = PhabricatorEnv::getEnvConfig('asana.project-ids'); 671 if (is_array($config)) { 672 $ids = idx($config, get_class($publisher)); 673 if (is_array($ids)) { 674 foreach ($ids as $id) { 675 if (is_scalar($id)) { 676 $project_ids[] = $id; 677 } 678 } 679 } 680 } 681 682 return $project_ids; 683 } 684 685 private function addProjects( 686 $oauth_token, 687 $task_id, 688 array $project_ids) { 689 foreach ($project_ids as $project_id) { 690 $data = array('project' => $project_id); 691 $this->makeAsanaAPICall( 692 $oauth_token, 693 "tasks/{$task_id}/addProject", 694 'POST', 695 $data); 696 } 697 } 698 699 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |