diff --git a/src/applications/doorkeeper/worker/DoorkeeperAsanaFeedWorker.php b/src/applications/doorkeeper/worker/DoorkeeperAsanaFeedWorker.php index bbc619ae51..8648f5d7f9 100644 --- a/src/applications/doorkeeper/worker/DoorkeeperAsanaFeedWorker.php +++ b/src/applications/doorkeeper/worker/DoorkeeperAsanaFeedWorker.php @@ -1,729 +1,730 @@ getWorkspaceID(); } /** * Publish stories into Asana using the Asana API. */ protected function publishFeedStory() { $story = $this->getFeedStory(); $data = $story->getStoryData(); $viewer = $this->getViewer(); $provider = $this->getProvider(); $workspace_id = $this->getWorkspaceID(); $object = $this->getStoryObject(); $src_phid = $object->getPHID(); $publisher = $this->getPublisher(); // Figure out all the users related to the object. Users go into one of // four buckets: // // - Owner: the owner of the object. This user becomes the assigned owner // of the parent task. // - Active: users who are responsible for the object and need to act on // it. For example, reviewers of a "needs review" revision. // - Passive: users who are responsible for the object, but do not need // to act on it right now. For example, reviewers of a "needs revision" // revision. // - Follow: users who are following the object; generally CCs. $owner_phid = $publisher->getOwnerPHID($object); $active_phids = $publisher->getActiveUserPHIDs($object); $passive_phids = $publisher->getPassiveUserPHIDs($object); $follow_phids = $publisher->getCCUserPHIDs($object); $all_phids = array(); $all_phids = array_merge( array($owner_phid), $active_phids, $passive_phids, $follow_phids); $all_phids = array_unique(array_filter($all_phids)); $phid_aid_map = $this->lookupAsanaUserIDs($all_phids); if (!$phid_aid_map) { throw new PhabricatorWorkerPermanentFailureException( pht('No related users have linked Asana accounts.')); } $owner_asana_id = idx($phid_aid_map, $owner_phid); $all_asana_ids = array_select_keys($phid_aid_map, $all_phids); $all_asana_ids = array_values($all_asana_ids); // Even if the actor isn't a reviewer, etc., try to use their account so // we can post in the correct voice. If we miss, we'll try all the other // related users. $try_users = array_merge( array($data->getAuthorPHID()), array_keys($phid_aid_map)); $try_users = array_filter($try_users); $access_info = $this->findAnyValidAsanaAccessToken($try_users); list($possessed_user, $possessed_asana_id, $oauth_token) = $access_info; if (!$oauth_token) { throw new PhabricatorWorkerPermanentFailureException( pht( 'Unable to find any Asana user with valid credentials to '. 'pull an OAuth token out of.')); } $etype_main = PhabricatorObjectHasAsanaTaskEdgeType::EDGECONST; $etype_sub = PhabricatorObjectHasAsanaSubtaskEdgeType::EDGECONST; $equery = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($src_phid)) ->withEdgeTypes( array( $etype_main, $etype_sub, )) ->needEdgeData(true); $edges = $equery->execute(); $main_edge = head($edges[$src_phid][$etype_main]); $main_data = $this->getAsanaTaskData($object) + array( 'assignee' => $owner_asana_id, ); $projects = $this->getAsanaProjectIDs(); $extra_data = array(); if ($main_edge) { $extra_data = $main_edge['data']; $refs = id(new DoorkeeperImportEngine()) ->setViewer($possessed_user) ->withPHIDs(array($main_edge['dst'])) ->execute(); $parent_ref = head($refs); if (!$parent_ref) { throw new PhabricatorWorkerPermanentFailureException( pht('%s could not be loaded.', 'DoorkeeperExternalObject')); } if ($parent_ref->getSyncFailed()) { throw new Exception( pht('Synchronization of parent task from Asana failed!')); } else if (!$parent_ref->getIsVisible()) { $this->log( "%s\n", pht('Skipping main task update, object is no longer visible.')); $extra_data['gone'] = true; } else { $edge_cursor = idx($main_edge['data'], 'cursor', 0); // TODO: This probably breaks, very rarely, on 32-bit systems. if ($edge_cursor <= $story->getChronologicalKey()) { $this->log("%s\n", pht('Updating main task.')); $task_id = $parent_ref->getObjectID(); $this->makeAsanaAPICall( $oauth_token, 'tasks/'.$parent_ref->getObjectID(), 'PUT', $main_data); } else { $this->log( "%s\n", pht('Skipping main task update, cursor is ahead of the story.')); } } } else { // If there are no followers (CCs), and no active or passive users // (reviewers or auditors), and we haven't synchronized the object before, // don't synchronize the object. if (!$active_phids && !$passive_phids && !$follow_phids) { $this->log( "%s\n", pht('Object has no followers or active/passive users.')); return; } $parent = $this->makeAsanaAPICall( $oauth_token, 'tasks', 'POST', array( 'workspace' => $workspace_id, 'projects' => $projects, // NOTE: We initially create parent tasks in the "Later" state but // don't update it afterward, even if the corresponding object // becomes actionable. The expectation is that users will prioritize // tasks in responses to notifications of state changes, and that // we should not overwrite their choices. 'assignee_status' => 'later', ) + $main_data); $parent_ref = $this->newRefFromResult( DoorkeeperBridgeAsana::OBJTYPE_TASK, $parent); $extra_data = array( 'workspace' => $workspace_id, ); } // Synchronize main task followers. $task_id = $parent_ref->getObjectID(); // Reviewers are added as followers of the parent task silently, because // they receive a notification when they are assigned as the owner of their // subtask, so the follow notification is redundant / non-actionable. $silent_followers = array_select_keys($phid_aid_map, $active_phids) + array_select_keys($phid_aid_map, $passive_phids); $silent_followers = array_values($silent_followers); // CCs are added as followers of the parent task with normal notifications, // since they won't get a secondary subtask notification. $noisy_followers = array_select_keys($phid_aid_map, $follow_phids); $noisy_followers = array_values($noisy_followers); // To synchronize follower data, just add all the followers. The task might // have additional followers, but we can't really tell how they got there: // were they CC'd and then unsubscribed, or did they manually follow the // task? Assume the latter since it's easier and less destructive and the // former is rare. To be fully consistent, we should enumerate followers // and remove unknown followers, but that's a fair amount of work for little // benefit, and creates a wider window for race conditions. // Add the silent followers first so that a user who is both a reviewer and // a CC gets silently added and then implicitly skipped by then noisy add. // They will get a subtask notification. // We only do this if the task still exists. if (empty($extra_data['gone'])) { $this->addFollowers($oauth_token, $task_id, $silent_followers, true); $this->addFollowers($oauth_token, $task_id, $noisy_followers); // We're also going to synchronize project data here. $this->addProjects($oauth_token, $task_id, $projects); } $dst_phid = $parent_ref->getExternalObject()->getPHID(); // Update the main edge. $edge_data = array( 'cursor' => $story->getChronologicalKey(), ) + $extra_data; $edge_options = array( 'data' => $edge_data, ); id(new PhabricatorEdgeEditor()) ->addEdge($src_phid, $etype_main, $dst_phid, $edge_options) ->save(); if (!$parent_ref->getIsVisible()) { throw new PhabricatorWorkerPermanentFailureException( pht( '%s has no visible object on the other side; this '. 'likely indicates the Asana task has been deleted.', 'DoorkeeperExternalObject')); } // Now, handle the subtasks. $sub_editor = new PhabricatorEdgeEditor(); // First, find all the object references in Phabricator for tasks that we // know about and import their objects from Asana. $sub_edges = $edges[$src_phid][$etype_sub]; $sub_refs = array(); $subtask_data = $this->getAsanaSubtaskData($object); $have_phids = array(); if ($sub_edges) { $refs = id(new DoorkeeperImportEngine()) ->setViewer($possessed_user) ->withPHIDs(array_keys($sub_edges)) ->execute(); foreach ($refs as $ref) { if ($ref->getSyncFailed()) { throw new Exception( pht('Synchronization of child task from Asana failed!')); } if (!$ref->getIsVisible()) { $ref->getExternalObject()->delete(); continue; } $have_phids[$ref->getExternalObject()->getPHID()] = $ref; } } // Remove any edges in Phabricator which don't have valid tasks in Asana. // These are likely tasks which have been deleted. We're going to respawn // them. foreach ($sub_edges as $sub_phid => $sub_edge) { if (isset($have_phids[$sub_phid])) { continue; } $this->log( "%s\n", pht( 'Removing subtask edge to %s, foreign object is not visible.', $sub_phid)); $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid); unset($sub_edges[$sub_phid]); } // For each active or passive user, we're looking for an existing, valid // task. If we find one we're going to update it; if we don't, we'll // create one. We ignore extra subtasks that we didn't create (we gain // nothing by deleting them and might be nuking something important) and // ignore subtasks which have been moved across workspaces or replanted // under new parents (this stuff is too edge-casey to bother checking for // and complicated to fix, as it needs extra API calls). However, we do // clean up subtasks we created whose owners are no longer associated // with the object. $subtask_states = array_fill_keys($active_phids, false) + array_fill_keys($passive_phids, true); // Continue with only those users who have Asana credentials. $subtask_states = array_select_keys( $subtask_states, array_keys($phid_aid_map)); $need_subtasks = $subtask_states; $user_to_ref_map = array(); $nuke_refs = array(); foreach ($sub_edges as $sub_phid => $sub_edge) { $user_phid = idx($sub_edge['data'], 'userPHID'); if (isset($need_subtasks[$user_phid])) { unset($need_subtasks[$user_phid]); $user_to_ref_map[$user_phid] = $have_phids[$sub_phid]; } else { // This user isn't associated with the object anymore, so get rid // of their task and edge. $nuke_refs[$sub_phid] = $have_phids[$sub_phid]; } } // These are tasks we know about but which are no longer relevant -- for // example, because a user has been removed as a reviewer. Remove them and // their edges. foreach ($nuke_refs as $sub_phid => $ref) { $sub_editor->removeEdge($src_phid, $etype_sub, $sub_phid); $this->makeAsanaAPICall( $oauth_token, 'tasks/'.$ref->getObjectID(), 'DELETE', array()); $ref->getExternalObject()->delete(); } // For each user that we don't have a subtask for, create a new subtask. foreach ($need_subtasks as $user_phid => $is_completed) { $subtask = $this->makeAsanaAPICall( $oauth_token, 'tasks', 'POST', $subtask_data + array( 'assignee' => $phid_aid_map[$user_phid], 'completed' => (int)$is_completed, 'parent' => $parent_ref->getObjectID(), )); $subtask_ref = $this->newRefFromResult( DoorkeeperBridgeAsana::OBJTYPE_TASK, $subtask); $user_to_ref_map[$user_phid] = $subtask_ref; // We don't need to synchronize this subtask's state because we just // set it when we created it. unset($subtask_states[$user_phid]); // Add an edge to track this subtask. $sub_editor->addEdge( $src_phid, $etype_sub, $subtask_ref->getExternalObject()->getPHID(), array( 'data' => array( 'userPHID' => $user_phid, ), )); } // Synchronize all the previously-existing subtasks. foreach ($subtask_states as $user_phid => $is_completed) { $this->makeAsanaAPICall( $oauth_token, 'tasks/'.$user_to_ref_map[$user_phid]->getObjectID(), 'PUT', $subtask_data + array( 'assignee' => $phid_aid_map[$user_phid], 'completed' => (int)$is_completed, )); } foreach ($user_to_ref_map as $user_phid => $ref) { // For each subtask, if the acting user isn't the same user as the subtask // owner, remove the acting user as a follower. Currently, the acting user // will be added as a follower only when they create the task, but this // may change in the future (e.g., closing the task may also mark them // as a follower). Wipe every subtask to be sure. The intent here is to // leave only the owner as a follower so that the acting user doesn't // receive notifications about changes to subtask state. Note that // removing followers is silent in all cases in Asana and never produces // any kind of notification, so this isn't self-defeating. if ($user_phid != $possessed_user->getPHID()) { $this->makeAsanaAPICall( $oauth_token, 'tasks/'.$ref->getObjectID().'/removeFollowers', 'POST', array( 'followers' => array($possessed_asana_id), )); } } // Update edges on our side. $sub_editor->save(); // Don't publish the "create" story, since pushing the object into Asana // naturally generates a notification which effectively serves the same // purpose as the "create" story. Similarly, "close" stories generate a // close notification. if (!$publisher->isStoryAboutObjectCreation($object) && !$publisher->isStoryAboutObjectClosure($object)) { // Post the feed story itself to the main Asana task. We do this last // because everything else is idempotent, so this is the only effect we // can't safely run more than once. $text = $publisher ->setRenderWithImpliedContext(true) ->getStoryText($object); $this->makeAsanaAPICall( $oauth_token, 'tasks/'.$parent_ref->getObjectID().'/stories', 'POST', array( 'text' => $text, )); } } /* -( Internals )---------------------------------------------------------- */ private function getWorkspaceID() { return PhabricatorEnv::getEnvConfig('asana.workspace-id'); } private function getProvider() { if (!$this->provider) { $provider = PhabricatorAsanaAuthProvider::getAsanaProvider(); if (!$provider) { throw new PhabricatorWorkerPermanentFailureException( pht('No Asana provider configured.')); } $this->provider = $provider; } return $this->provider; } private function getAsanaTaskData($object) { $publisher = $this->getPublisher(); $title = $publisher->getObjectTitle($object); $uri = $publisher->getObjectURI($object); $description = $publisher->getObjectDescription($object); $is_completed = $publisher->isObjectClosed($object); $notes = array( $description, $uri, $this->getSynchronizationWarning(), ); $notes = implode("\n\n", $notes); return array( 'name' => $title, 'notes' => $notes, 'completed' => (int)$is_completed, ); } private function getAsanaSubtaskData($object) { $publisher = $this->getPublisher(); $title = $publisher->getResponsibilityTitle($object); $uri = $publisher->getObjectURI($object); $description = $publisher->getObjectDescription($object); $notes = array( $description, $uri, $this->getSynchronizationWarning(), ); $notes = implode("\n\n", $notes); return array( 'name' => $title, 'notes' => $notes, ); } private function getSynchronizationWarning() { return pht( "\xE2\x9A\xA0 DO NOT EDIT THIS TASK \xE2\x9A\xA0\n". - "\xE2\x98\xA0 Your changes will not be reflected in Phabricator.\n". + "\xE2\x98\xA0 Your changes will not be reflected in %s.\n". "\xE2\x98\xA0 Your changes will be destroyed the next time state ". - "is synchronized."); + "is synchronized.", + PlatformSymbols::getPlatformServerName()); } private function lookupAsanaUserIDs($all_phids) { $phid_map = array(); $all_phids = array_unique(array_filter($all_phids)); if (!$all_phids) { return $phid_map; } $accounts = $this->loadAsanaExternalAccounts($all_phids); foreach ($accounts as $account) { $phid_map[$account->getUserPHID()] = $this->getAsanaAccountID($account); } // Put this back in input order. $phid_map = array_select_keys($phid_map, $all_phids); return $phid_map; } private function loadAsanaExternalAccounts(array $user_phids) { $provider = $this->getProvider(); $viewer = $this->getViewer(); if (!$user_phids) { return array(); } $accounts = id(new PhabricatorExternalAccountQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUserPHIDs($user_phids) ->withProviderConfigPHIDs( array( $provider->getProviderConfigPHID(), )) ->needAccountIdentifiers(true) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); return $accounts; } private function findAnyValidAsanaAccessToken(array $user_phids) { $provider = $this->getProvider(); $viewer = $this->getViewer(); if (!$user_phids) { return array(null, null, null); } $accounts = $this->loadAsanaExternalAccounts($user_phids); // Reorder accounts in the original order. // TODO: This needs to be adjusted if/when we allow you to link multiple // accounts. $accounts = mpull($accounts, null, 'getUserPHID'); $accounts = array_select_keys($accounts, $user_phids); $workspace_id = $this->getWorkspaceID(); foreach ($accounts as $account) { // Get a token if possible. $token = $provider->getOAuthAccessToken($account); if (!$token) { continue; } // Verify we can actually make a call with the token, and that the user // has access to the workspace in question. try { id(new PhutilAsanaFuture()) ->setAccessToken($token) ->setRawAsanaQuery("workspaces/{$workspace_id}") ->resolve(); } catch (Exception $ex) { // This token didn't make it through; try the next account. continue; } $user = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($account->getUserPHID())) ->executeOne(); if ($user) { return array($user, $this->getAsanaAccountID($account), $token); } } return array(null, null, null); } private function makeAsanaAPICall($token, $action, $method, array $params) { foreach ($params as $key => $value) { if ($value === null) { unset($params[$key]); } else if (is_array($value)) { unset($params[$key]); foreach ($value as $skey => $svalue) { $params[$key.'['.$skey.']'] = $svalue; } } } return id(new PhutilAsanaFuture()) ->setAccessToken($token) ->setMethod($method) ->setRawAsanaQuery($action, $params) ->resolve(); } private function newRefFromResult($type, $result) { $ref = id(new DoorkeeperObjectRef()) ->setApplicationType(DoorkeeperBridgeAsana::APPTYPE_ASANA) ->setApplicationDomain(DoorkeeperBridgeAsana::APPDOMAIN_ASANA) ->setObjectType($type) ->setObjectID($result['gid']) ->setIsVisible(true); $xobj = $ref->newExternalObject(); $ref->attachExternalObject($xobj); $bridge = new DoorkeeperBridgeAsana(); $bridge->fillObjectFromData($xobj, $result); $xobj->save(); return $ref; } private function addFollowers( $oauth_token, $task_id, array $followers, $silent = false) { if (!$followers) { return; } $data = array( 'followers' => $followers, ); // NOTE: This uses a currently-undocumented API feature to suppress the // follow notifications. if ($silent) { $data['silent'] = true; } $this->makeAsanaAPICall( $oauth_token, "tasks/{$task_id}/addFollowers", 'POST', $data); } private function getAsanaProjectIDs() { $project_ids = array(); $publisher = $this->getPublisher(); $config = PhabricatorEnv::getEnvConfig('asana.project-ids'); if (is_array($config)) { $ids = idx($config, get_class($publisher)); if (is_array($ids)) { foreach ($ids as $id) { if (is_scalar($id)) { $project_ids[] = $id; } } } } return $project_ids; } private function addProjects( $oauth_token, $task_id, array $project_ids) { foreach ($project_ids as $project_id) { $data = array('project' => $project_id); $this->makeAsanaAPICall( $oauth_token, "tasks/{$task_id}/addProject", 'POST', $data); } } private function getAsanaAccountID(PhabricatorExternalAccount $account) { $identifiers = $account->getAccountIdentifiers(); if (count($identifiers) !== 1) { throw new Exception( pht( 'Expected external Asana account to have exactly one external '. 'account identifier, found %s.', phutil_count($identifiers))); } return head($identifiers)->getIdentifierRaw(); } } diff --git a/src/applications/drydock/operation/DrydockLandRepositoryOperation.php b/src/applications/drydock/operation/DrydockLandRepositoryOperation.php index acb48f6f0b..f44d6a1db2 100644 --- a/src/applications/drydock/operation/DrydockLandRepositoryOperation.php +++ b/src/applications/drydock/operation/DrydockLandRepositoryOperation.php @@ -1,442 +1,443 @@ getRepositoryTarget(); $repository = $operation->getRepository(); switch ($operation->getOperationState()) { case DrydockRepositoryOperation::STATE_WAIT: return pht( 'Waiting to land revision into %s on %s...', $repository->getMonogram(), $target); case DrydockRepositoryOperation::STATE_WORK: return pht( 'Landing revision into %s on %s...', $repository->getMonogram(), $target); case DrydockRepositoryOperation::STATE_DONE: return pht( 'Revision landed into %s.', $repository->getMonogram()); } } public function getWorkingCopyMerges(DrydockRepositoryOperation $operation) { $repository = $operation->getRepository(); $merges = array(); $object = $operation->getObject(); if ($object instanceof DifferentialRevision) { $diff = $this->loadDiff($operation); $merges[] = array( 'src.uri' => $repository->getStagingURI(), 'src.ref' => $diff->getStagingRef(), ); } else { throw new Exception( pht( 'Invalid or unknown object ("%s") for land operation, expected '. 'Differential Revision.', $operation->getObjectPHID())); } return $merges; } public function applyOperation( DrydockRepositoryOperation $operation, DrydockInterface $interface) { $viewer = $this->getViewer(); $repository = $operation->getRepository(); $cmd = array(); $arg = array(); $object = $operation->getObject(); if ($object instanceof DifferentialRevision) { $revision = $object; $diff = $this->loadDiff($operation); $dict = $diff->getDiffAuthorshipDict(); $author_name = idx($dict, 'authorName'); $author_email = idx($dict, 'authorEmail'); $api_method = 'differential.getcommitmessage'; $api_params = array( 'revision_id' => $revision->getID(), ); $commit_message = id(new ConduitCall($api_method, $api_params)) ->setUser($viewer) ->execute(); } else { throw new Exception( pht( 'Invalid or unknown object ("%s") for land operation, expected '. 'Differential Revision.', $operation->getObjectPHID())); } $target = $operation->getRepositoryTarget(); list($type, $name) = explode(':', $target, 2); switch ($type) { case 'branch': $push_dst = 'refs/heads/'.$name; break; default: throw new Exception( pht( 'Unknown repository operation target type "%s" (in target "%s").', $type, $target)); } $committer_info = $this->getCommitterInfo($operation); // NOTE: We're doing this commit with "-F -" so we don't run into trouble // with enormous commit messages which might otherwise exceed the maximum // size of a command. $future = $interface->getExecFuture( 'git -c user.name=%s -c user.email=%s commit --author %s -F - --', $committer_info['name'], $committer_info['email'], "{$author_name} <{$author_email}>"); $future->write($commit_message); try { $future->resolvex(); } catch (CommandException $ex) { $display_command = csprintf('git commit'); // TODO: One reason this can fail is if the changes have already been // merged. We could try to detect that. $error = DrydockCommandError::newFromCommandException($ex) ->setPhase(self::PHASE_COMMIT) ->setDisplayCommand($display_command); $operation->setCommandError($error->toDictionary()); throw $ex; } try { $interface->execx( 'git push origin -- %s:%s', 'HEAD', $push_dst); } catch (CommandException $ex) { $display_command = csprintf( 'git push origin %R:%R', 'HEAD', $push_dst); $error = DrydockCommandError::newFromCommandException($ex) ->setPhase(self::PHASE_PUSH) ->setDisplayCommand($display_command); $operation->setCommandError($error->toDictionary()); throw $ex; } } private function getCommitterInfo(DrydockRepositoryOperation $operation) { $viewer = $this->getViewer(); $committer_name = null; $author_phid = $operation->getAuthorPHID(); $object = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs(array($author_phid)) ->executeOne(); if ($object) { if ($object instanceof PhabricatorUser) { $committer_name = $object->getUsername(); } } if (!strlen($committer_name)) { $committer_name = pht('autocommitter'); } // TODO: Probably let users choose a VCS email address in settings. For // now just make something up so we don't leak anyone's stuff. return array( 'name' => $committer_name, 'email' => 'autocommitter@example.com', ); } private function loadDiff(DrydockRepositoryOperation $operation) { $viewer = $this->getViewer(); $revision = $operation->getObject(); $diff_phid = $operation->getProperty('differential.diffPHID'); $diff = id(new DifferentialDiffQuery()) ->setViewer($viewer) ->withPHIDs(array($diff_phid)) ->executeOne(); if (!$diff) { throw new Exception( pht( 'Unable to load diff "%s".', $diff_phid)); } $diff_revid = $diff->getRevisionID(); $revision_id = $revision->getID(); if ($diff_revid != $revision_id) { throw new Exception( pht( 'Diff ("%s") has wrong revision ID ("%s", expected "%s").', $diff_phid, $diff_revid, $revision_id)); } return $diff; } public function getBarrierToLanding( PhabricatorUser $viewer, DifferentialRevision $revision) { $repository = $revision->getRepository(); if (!$repository) { return array( 'title' => pht('No Repository'), 'body' => pht( 'This revision is not associated with a known repository. Only '. 'revisions associated with a tracked repository can be landed '. 'automatically.'), ); } if (!$repository->canPerformAutomation()) { return array( 'title' => pht('No Repository Automation'), 'body' => pht( 'The repository this revision is associated with ("%s") is not '. 'configured to support automation. Configure automation for the '. 'repository to enable revisions to be landed automatically.', $repository->getMonogram()), ); } // Check if this diff was pushed to a staging area. $diff = id(new DifferentialDiffQuery()) ->setViewer($viewer) ->withIDs(array($revision->getActiveDiff()->getID())) ->needProperties(true) ->executeOne(); // Older diffs won't have this property. They may still have been pushed. // At least for now, assume staging changes are present if the property // is missing. This should smooth the transition to the more formal // approach. $has_staging = $diff->hasDiffProperty('arc.staging'); if ($has_staging) { $staging = $diff->getProperty('arc.staging'); if (!is_array($staging)) { $staging = array(); } $status = idx($staging, 'status'); if ($status != ArcanistDiffWorkflow::STAGING_PUSHED) { return $this->getBarrierToLandingFromStagingStatus($status); } } // TODO: At some point we should allow installs to give "land reviewed // code" permission to more users than "push any commit", because it is // a much less powerful operation. For now, just require push so this // doesn't do anything users can't do on their own. $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionPushCapability::CAPABILITY); if (!$can_push) { return array( 'title' => pht('Unable to Push'), 'body' => pht( 'You do not have permission to push to the repository this '. 'revision is associated with ("%s"), so you can not land it.', $repository->getMonogram()), ); } if ($revision->isAccepted()) { // We can land accepted revisions, so continue below. Otherwise, raise // an error with tailored messaging for the most common cases. } else if ($revision->isAbandoned()) { return array( 'title' => pht('Revision Abandoned'), 'body' => pht( 'This revision has been abandoned. Only accepted revisions '. 'may land.'), ); } else if ($revision->isClosed()) { return array( 'title' => pht('Revision Closed'), 'body' => pht( 'This revision has already been closed. Only open, accepted '. 'revisions may land.'), ); } else { return array( 'title' => pht('Revision Not Accepted'), 'body' => pht( 'This revision is still under review. Only revisions which '. 'have been accepted may land.'), ); } // Check for other operations. Eventually this should probably be more // general (e.g., it's OK to land to multiple different branches // simultaneously) but just put this in as a sanity check for now. $other_operations = id(new DrydockRepositoryOperationQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($revision->getPHID())) ->withOperationTypes( array( $this->getOperationConstant(), )) ->withOperationStates( array( DrydockRepositoryOperation::STATE_WAIT, DrydockRepositoryOperation::STATE_WORK, DrydockRepositoryOperation::STATE_DONE, )) ->execute(); if ($other_operations) { $any_done = false; foreach ($other_operations as $operation) { if ($operation->isDone()) { $any_done = true; break; } } if ($any_done) { return array( 'title' => pht('Already Complete'), 'body' => pht('This revision has already landed.'), ); } else { return array( 'title' => pht('Already In Flight'), 'body' => pht('This revision is already landing.'), ); } } return null; } private function getBarrierToLandingFromStagingStatus($status) { switch ($status) { case ArcanistDiffWorkflow::STAGING_USER_SKIP: return array( 'title' => pht('Staging Area Skipped'), 'body' => pht( 'The diff author used the %s flag to skip pushing this change to '. 'staging. Changes must be pushed to staging before they can be '. 'landed from the web.', phutil_tag('tt', array(), '--skip-staging')), ); case ArcanistDiffWorkflow::STAGING_DIFF_RAW: return array( 'title' => pht('Raw Diff Source'), 'body' => pht( 'The diff was generated from a raw input source, so the change '. 'could not be pushed to staging. Changes must be pushed to '. 'staging before they can be landed from the web.'), ); case ArcanistDiffWorkflow::STAGING_REPOSITORY_UNKNOWN: return array( 'title' => pht('Unknown Repository'), 'body' => pht( 'When the diff was generated, the client was not able to '. 'determine which repository it belonged to, so the change '. 'was not pushed to staging. Changes must be pushed to staging '. 'before they can be landed from the web.'), ); case ArcanistDiffWorkflow::STAGING_REPOSITORY_UNAVAILABLE: return array( 'title' => pht('Staging Unavailable'), 'body' => pht( 'When this diff was generated, the server was running an older '. - 'version of Phabricator which did not support staging areas, so '. + 'version of the software which did not support staging areas, so '. 'the change was not pushed to staging. Changes must be pushed '. 'to staging before they can be landed from the web.'), ); case ArcanistDiffWorkflow::STAGING_REPOSITORY_UNSUPPORTED: return array( 'title' => pht('Repository Unsupported'), 'body' => pht( 'When this diff was generated, the server was running an older '. - 'version of Phabricator which did not support staging areas for '. + 'version of the software which did not support staging areas for '. 'this version control system, so the change was not pushed to '. 'staging. Changes must be pushed to staging before they can be '. 'landed from the web.'), ); case ArcanistDiffWorkflow::STAGING_REPOSITORY_UNCONFIGURED: return array( 'title' => pht('Repository Unconfigured'), 'body' => pht( 'When this diff was generated, the repository was not configured '. 'with a staging area, so the change was not pushed to staging. '. 'Changes must be pushed to staging before they can be landed '. 'from the web.'), ); case ArcanistDiffWorkflow::STAGING_CLIENT_UNSUPPORTED: return array( 'title' => pht('Client Support Unavailable'), 'body' => pht( 'When this diff was generated, the client did not support '. 'staging areas for this version control system, so the change '. 'was not pushed to staging. Changes must be pushed to staging '. 'before they can be landed from the web. Updating the client '. 'may resolve this issue.'), ); default: return array( 'title' => pht('Unknown Error'), 'body' => pht( 'When this diff was generated, it was not pushed to staging for '. 'an unknown reason (the status code was "%s"). Changes must be '. 'pushed to staging before they can be landed from the web. '. - 'The server may be running an out-of-date version of Phabricator, '. - 'and updating may provide more information about this error.', + 'The server may be running an out-of-date version of this '. + 'software, and updating may provide more information about this '. + 'error.', $status), ); } } } diff --git a/src/applications/files/config/PhabricatorFilesConfigOptions.php b/src/applications/files/config/PhabricatorFilesConfigOptions.php index 735ddfcb0d..7ed96a412b 100644 --- a/src/applications/files/config/PhabricatorFilesConfigOptions.php +++ b/src/applications/files/config/PhabricatorFilesConfigOptions.php @@ -1,217 +1,217 @@ 'image/jpeg', 'image/jpg' => 'image/jpg', 'image/png' => 'image/png', 'image/gif' => 'image/gif', 'text/plain' => 'text/plain; charset=utf-8', 'text/x-diff' => 'text/plain; charset=utf-8', // ".ico" favicon files, which have mime type diversity. See: // http://en.wikipedia.org/wiki/ICO_(file_format)#MIME_type 'image/x-ico' => 'image/x-icon', 'image/x-icon' => 'image/x-icon', 'image/vnd.microsoft.icon' => 'image/x-icon', // This is a generic type for both OGG video and OGG audio. 'application/ogg' => 'application/ogg', 'audio/x-wav' => 'audio/x-wav', 'audio/mpeg' => 'audio/mpeg', 'audio/ogg' => 'audio/ogg', 'video/mp4' => 'video/mp4', 'video/ogg' => 'video/ogg', 'video/webm' => 'video/webm', 'video/quicktime' => 'video/quicktime', 'application/pdf' => 'application/pdf', ); $image_default = array( 'image/jpeg' => true, 'image/jpg' => true, 'image/png' => true, 'image/gif' => true, 'image/x-ico' => true, 'image/x-icon' => true, 'image/vnd.microsoft.icon' => true, ); // The "application/ogg" type is listed as both an audio and video type, // because it may contain either type of content. $audio_default = array( 'audio/x-wav' => true, 'audio/mpeg' => true, 'audio/ogg' => true, // These are video or ambiguous types, but can be forced to render as // audio with `media=audio`, which seems to work properly in browsers. // (For example, you can embed a music video as audio if you just want // to set the mood for your task without distracting viewers.) 'video/mp4' => true, 'video/ogg' => true, 'video/quicktime' => true, 'application/ogg' => true, ); $video_default = array( 'video/mp4' => true, 'video/ogg' => true, 'video/webm' => true, 'video/quicktime' => true, 'application/ogg' => true, ); // largely lifted from http://en.wikipedia.org/wiki/Internet_media_type $icon_default = array( // audio file icon 'audio/basic' => 'fa-file-audio-o', 'audio/L24' => 'fa-file-audio-o', 'audio/mp4' => 'fa-file-audio-o', 'audio/mpeg' => 'fa-file-audio-o', 'audio/ogg' => 'fa-file-audio-o', 'audio/vorbis' => 'fa-file-audio-o', 'audio/vnd.rn-realaudio' => 'fa-file-audio-o', 'audio/vnd.wave' => 'fa-file-audio-o', 'audio/webm' => 'fa-file-audio-o', // movie file icon 'video/mpeg' => 'fa-file-movie-o', 'video/mp4' => 'fa-file-movie-o', 'application/ogg' => 'fa-file-movie-o', 'video/ogg' => 'fa-file-movie-o', 'video/quicktime' => 'fa-file-movie-o', 'video/webm' => 'fa-file-movie-o', 'video/x-matroska' => 'fa-file-movie-o', 'video/x-ms-wmv' => 'fa-file-movie-o', 'video/x-flv' => 'fa-file-movie-o', // pdf file icon 'application/pdf' => 'fa-file-pdf-o', // zip file icon 'application/zip' => 'fa-file-zip-o', // msword icon 'application/msword' => 'fa-file-word-o', // msexcel 'application/vnd.ms-excel' => 'fa-file-excel-o', // mspowerpoint 'application/vnd.ms-powerpoint' => 'fa-file-powerpoint-o', ) + array_fill_keys(array_keys($image_default), 'fa-file-image-o'); // NOTE: These options are locked primarily because adding "text/plain" // as an image MIME type increases SSRF vulnerability by allowing users // to load text files from remote servers as "images" (see T6755 for // discussion). return array( $this->newOption('files.viewable-mime-types', 'wild', $viewable_default) ->setLocked(true) ->setSummary( pht('Configure which MIME types are viewable in the browser.')) ->setDescription( pht( "Configure which uploaded file types may be viewed directly ". "in the browser. Other file types will be downloaded instead ". "of displayed. This is mainly a usability consideration, since ". "browsers tend to freak out when viewing very large binary files.". "\n\n". "The keys in this map are viewable MIME types; the values are ". "the MIME types they are delivered as when they are viewed in ". "the browser.")), $this->newOption('files.image-mime-types', 'set', $image_default) ->setLocked(true) ->setSummary(pht('Configure which MIME types are images.')) ->setDescription( pht( 'List of MIME types which can be used as the `%s` for an `%s` tag.', 'src', '')), $this->newOption('files.audio-mime-types', 'set', $audio_default) ->setLocked(true) ->setSummary(pht('Configure which MIME types are audio.')) ->setDescription( pht( 'List of MIME types which can be rendered with an `%s` tag.', '