[ Index ]

PHP Cross Reference of Phabricator

title

Body

[close]

/src/applications/doorkeeper/worker/ -> DoorkeeperFeedWorkerAsana.php (source)

   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  }


Generated: Sun Nov 30 09:20:46 2014 Cross-referenced by PHPXref 0.7.1