Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2895269
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
48 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php b/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
index d24b997b1f..74f9117ced 100644
--- a/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
+++ b/src/applications/differential/doorkeeper/DifferentialDoorkeeperRevisionFeedStoryPublisher.php
@@ -1,110 +1,112 @@
<?php
final class DifferentialDoorkeeperRevisionFeedStoryPublisher
extends DoorkeeperFeedStoryPublisher {
public function canPublishStory(PhabricatorFeedStory $story, $object) {
return ($object instanceof DifferentialRevision);
}
public function isStoryAboutObjectCreation($object) {
$story = $this->getFeedStory();
$action = $story->getStoryData()->getValue('action');
return ($action == DifferentialAction::ACTION_CREATE);
}
public function isStoryAboutObjectClosure($object) {
$story = $this->getFeedStory();
$action = $story->getStoryData()->getValue('action');
return ($action == DifferentialAction::ACTION_CLOSE) ||
($action == DifferentialAction::ACTION_ABANDON);
}
public function willPublishStory($object) {
return id(new DifferentialRevisionQuery())
->setViewer($this->getViewer())
->withIDs(array($object->getID()))
->needRelationships(true)
->executeOne();
}
public function getOwnerPHID($object) {
return $object->getAuthorPHID();
}
public function getActiveUserPHIDs($object) {
$status = $object->getStatus();
if ($status == ArcanistDifferentialRevisionStatus::NEEDS_REVIEW) {
return $object->getReviewers();
} else {
return array();
}
}
public function getPassiveUserPHIDs($object) {
$status = $object->getStatus();
if ($status == ArcanistDifferentialRevisionStatus::NEEDS_REVIEW) {
return array();
} else {
return $object->getReviewers();
}
}
public function getCCUserPHIDs($object) {
return $object->getCCPHIDs();
}
public function getObjectTitle($object) {
$prefix = $this->getTitlePrefix($object);
$lines = new PhutilNumber($object->getLineCount());
$lines = pht('[Request, %d lines]', $lines);
$id = $object->getID();
$title = $object->getTitle();
return ltrim("{$prefix} {$lines} D{$id}: {$title}");
}
public function getObjectURI($object) {
return PhabricatorEnv::getProductionURI('/D'.$object->getID());
}
public function getObjectDescription($object) {
return $object->getSummary();
}
public function isObjectClosed($object) {
switch ($object->getStatus()) {
case ArcanistDifferentialRevisionStatus::CLOSED:
case ArcanistDifferentialRevisionStatus::ABANDONED:
return true;
default:
return false;
}
}
public function getResponsibilityTitle($object) {
$prefix = $this->getTitlePrefix($object);
return pht('%s Review Request', $prefix);
}
public function getStoryText($object) {
+ $implied_context = $this->getRenderWithImpliedContext();
+
$story = $this->getFeedStory();
if ($story instanceof PhabricatorFeedStoryDifferential) {
- $text = $story->renderForAsanaBridge();
+ $text = $story->renderForAsanaBridge($implied_context);
} else {
$text = $story->renderText();
}
return $text;
}
private function getTitlePrefix(DifferentialRevision $revision) {
$prefix_key = 'metamta.differential.subject-prefix';
return PhabricatorEnv::getEnvConfig($prefix_key);
}
}
diff --git a/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php b/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
index feae1628ad..0a5b6b2a21 100644
--- a/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
+++ b/src/applications/diffusion/doorkeeper/DiffusionDoorkeeperCommitFeedStoryPublisher.php
@@ -1,193 +1,195 @@
<?php
final class DiffusionDoorkeeperCommitFeedStoryPublisher
extends DoorkeeperFeedStoryPublisher {
private $auditRequests;
private $activePHIDs;
private $passivePHIDs;
private function getAuditRequests() {
return $this->auditRequests;
}
public function canPublishStory(PhabricatorFeedStory $story, $object) {
return ($object instanceof PhabricatorRepositoryCommit);
}
public function isStoryAboutObjectCreation($object) {
// TODO: Although creation stories exist, they currently don't have a
// primary object PHID set, so they'll never make it here because they
// won't pass `canPublishStory()`.
return false;
}
public function isStoryAboutObjectClosure($object) {
// TODO: This isn't quite accurate, but pretty close: check if this story
// is a close (which clearly is about object closure) or is an "Accept" and
// the commit is fully audited (which is almost certainly a closure).
// After ApplicationTransactions, we could annotate feed stories more
// explicitly.
$story = $this->getFeedStory();
$action = $story->getStoryData()->getValue('action');
if ($action == PhabricatorAuditActionConstants::CLOSE) {
return true;
}
$fully_audited = PhabricatorAuditCommitStatusConstants::FULLY_AUDITED;
if (($action == PhabricatorAuditActionConstants::ACCEPT) &&
$object->getAuditStatus() == $fully_audited) {
return true;
}
return false;
}
public function willPublishStory($commit) {
$requests = id(new PhabricatorAuditQuery())
->withCommitPHIDs(array($commit->getPHID()))
->execute();
// TODO: This is messy and should be generalized, but we don't have a good
// query for it yet. Since we run in the daemons, just do the easiest thing
// we can for the moment. Figure out who all of the "active" (need to
// audit) and "passive" (no action necessary) user are.
$auditor_phids = mpull($requests, 'getAuditorPHID');
$objects = id(new PhabricatorObjectHandleData($auditor_phids))
->setViewer($this->getViewer())
->loadObjects();
$active = array();
$passive = array();
foreach ($requests as $request) {
$status = $request->getAuditStatus();
if ($status == PhabricatorAuditStatusConstants::CC) {
// We handle these specially below.
continue;
}
$object = idx($objects, $request->getAuditorPHID());
if (!$object) {
continue;
}
$request_phids = array();
if ($object instanceof PhabricatorUser) {
$request_phids = array($object->getPHID());
} else if ($object instanceof PhabricatorOwnersPackage) {
$request_phids = PhabricatorOwnersOwner::loadAffiliatedUserPHIDs(
array($object->getID()));
} else if ($object instanceof PhabricatorProject) {
$request_phids = $object->loadMemberPHIDs();
} else {
// Dunno what this is.
$request_phids = array();
}
switch ($status) {
case PhabricatorAuditStatusConstants::AUDIT_REQUIRED:
case PhabricatorAuditStatusConstants::AUDIT_REQUESTED:
case PhabricatorAuditStatusConstants::CONCERNED:
$active += array_fuse($request_phids);
break;
default:
$passive += array_fuse($request_phids);
break;
}
}
// Remove "Active" users from the "Passive" list.
$passive = array_diff_key($passive, $active);
$this->activePHIDs = $active;
$this->passivePHIDs = $passive;
$this->auditRequests = $requests;
return $commit;
}
public function getOwnerPHID($object) {
return $object->getAuthorPHID();
}
public function getActiveUserPHIDs($object) {
return $this->activePHIDs;
}
public function getPassiveUserPHIDs($object) {
return $this->passivePHIDs;
}
public function getCCUserPHIDs($object) {
$ccs = array();
foreach ($this->getAuditRequests() as $request) {
if ($request->getAuditStatus() == PhabricatorAuditStatusConstants::CC) {
$ccs[] = $request->getAuditorPHID();
}
}
return $ccs;
}
public function getObjectTitle($object) {
$prefix = $this->getTitlePrefix($object);
$repository = $object->getRepository();
$name = $repository->formatCommitName($object->getCommitIdentifier());
$title = $object->getSummary();
return ltrim("{$prefix} {$name}: {$title}");
}
public function getObjectURI($object) {
$repository = $object->getRepository();
$name = $repository->formatCommitName($object->getCommitIdentifier());
return PhabricatorEnv::getProductionURI('/'.$name);
}
public function getObjectDescription($object) {
$data = $object->loadCommitData();
if ($data) {
return $data->getCommitMessage();
}
return null;
}
public function isObjectClosed($object) {
switch ($object->getAuditStatus()) {
case PhabricatorAuditCommitStatusConstants::NEEDS_AUDIT:
case PhabricatorAuditCommitStatusConstants::CONCERN_RAISED:
case PhabricatorAuditCommitStatusConstants::PARTIALLY_AUDITED:
return false;
default:
return true;
}
}
public function getResponsibilityTitle($object) {
$prefix = $this->getTitlePrefix($object);
return pht('%s Audit', $prefix);
}
public function getStoryText($object) {
+ $implied_context = $this->getRenderWithImpliedContext();
+
$story = $this->getFeedStory();
if ($story instanceof PhabricatorFeedStoryAudit) {
- $text = $story->renderForAsanaBridge();
+ $text = $story->renderForAsanaBridge($implied_context);
} else {
$text = $story->renderText();
}
return $text;
}
private function getTitlePrefix(PhabricatorRepositoryCommit $commit) {
$prefix_key = 'metamta.diffusion.subject-prefix';
return PhabricatorEnv::getEnvConfig($prefix_key);
}
}
diff --git a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
index bd6809d87f..13a00a6b44 100644
--- a/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
+++ b/src/applications/doorkeeper/engine/DoorkeeperFeedStoryPublisher.php
@@ -1,51 +1,97 @@
<?php
+/**
+ * @task config Configuration
+ */
abstract class DoorkeeperFeedStoryPublisher {
private $feedStory;
private $viewer;
+ private $renderWithImpliedContext;
+
+
+/* -( Configuration )------------------------------------------------------ */
+
+
+ /**
+ * Render story text using contextual langauge to identify the object the
+ * story is about, instead of the full object name. For example, without
+ * contextual language a story might render like this:
+ *
+ * alincoln created D123: Chop Wood for Log Cabin v2.0
+ *
+ * With contextual langauge, it will render like this instead:
+ *
+ * alincoln created this revision.
+ *
+ * If the interface where the text will be displayed is specific to an
+ * individual object (like Asana tasks that represent one review or commit
+ * are), it's generally more natural to use language that assumes context.
+ * If the target context may show information about several objects (like
+ * JIRA issues which can have several linked revisions), it's generally
+ * more useful not to assume context.
+ *
+ * @param bool True to assume object context when rendering.
+ * @return this
+ * @task config
+ */
+ public function setRenderWithImpliedContext($render_with_implied_context) {
+ $this->renderWithImpliedContext = $render_with_implied_context;
+ return $this;
+ }
+
+ /**
+ * Determine if rendering should assume object context. For discussion, see
+ * @{method:setRenderWithImpliedContext}.
+ *
+ * @return bool True if rendering should assume object context is implied.
+ * @task config
+ */
+ public function getRenderWithImpliedContext() {
+ return $this->renderWithImpliedContext;
+ }
public function setFeedStory(PhabricatorFeedStory $feed_story) {
$this->feedStory = $feed_story;
return $this;
}
public function getFeedStory() {
return $this->feedStory;
}
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
abstract public function canPublishStory(
PhabricatorFeedStory $story,
$object);
/**
* Hook for publishers to mutate the story object, particularly by loading
* and attaching additional data.
*/
public function willPublishStory($object) {
return $object;
}
abstract public function isStoryAboutObjectCreation($object);
abstract public function isStoryAboutObjectClosure($object);
abstract public function getOwnerPHID($object);
abstract public function getActiveUserPHIDs($object);
abstract public function getPassiveUserPHIDs($object);
abstract public function getCCUserPHIDs($object);
abstract public function getObjectTitle($object);
abstract public function getObjectURI($object);
abstract public function getObjectDescription($object);
abstract public function isObjectClosed($object);
abstract public function getResponsibilityTitle($object);
abstract public function getStoryText($object);
}
diff --git a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
index a389df3bd7..80df37905a 100644
--- a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
+++ b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerAsana.php
@@ -1,646 +1,648 @@
<?php
/**
* Publishes tasks representing work that needs to be done into Asana, and
* updates the tasks as the corresponding Phabricator objects are updated.
*/
final class DoorkeeperFeedWorkerAsana extends DoorkeeperFeedWorker {
private $provider;
/* -( Publishing Stories )------------------------------------------------- */
/**
* This worker is enabled when an Asana workspace ID is configured with
* `asana.workspace-id`.
*/
public function isEnabled() {
return (bool)$this->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(
'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(
'Unable to find any Asana user with valid credentials to '.
'pull an OAuth token out of.');
}
$etype_main = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANATASK;
$etype_sub = PhabricatorEdgeConfig::TYPE_PHOB_HAS_ASANASUBTASK;
$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,
);
$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(
'DoorkeeperExternalObject could not be loaded.');
}
if ($parent_ref->getSyncFailed()) {
throw new Exception(
'Synchronization of parent task from Asana failed!');
} else if (!$parent_ref->getIsVisible()) {
$this->log("Skipping main task update, object is no longer visible.\n");
$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("Updating main task.\n");
$task_id = $parent_ref->getObjectID();
$this->makeAsanaAPICall(
$oauth_token,
"tasks/".$parent_ref->getObjectID(),
'PUT',
$main_data);
} else {
$this->log(
"Skipping main task update, cursor is ahead of the story.\n");
}
}
} 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("Object has no followers or active/passive users.\n");
return;
}
$parent = $this->makeAsanaAPICall(
$oauth_token,
'tasks',
'POST',
array(
'workspace' => $workspace_id,
// 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.
$this->addFollowers($oauth_token, $task_id, $silent_followers, true);
$this->addFollowers($oauth_token, $task_id, $noisy_followers);
$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())
->setActor($viewer)
->addEdge($src_phid, $etype_main, $dst_phid, $edge_options)
->save();
if (!$parent_ref->getIsVisible()) {
throw new PhabricatorWorkerPermanentFailureException(
'DoorkeeperExternalObject has no visible object on the other side; '.
'this likely indicates the Asana task has been deleted.');
}
// Now, handle the subtasks.
$sub_editor = id(new PhabricatorEdgeEditor())
->setActor($viewer);
// 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(
'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(
"Removing subtask edge to %s, foreign object is not visible.\n",
$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' => $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' => $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->getStoryText($object);
+ $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 = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
if (!$provider) {
throw new PhabricatorWorkerPermanentFailureException(
'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' => $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
"\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 be destroyed the next time state ".
"is synchronized.";
}
private function lookupAsanaUserIDs($all_phids) {
$phid_map = array();
$all_phids = array_unique(array_filter($all_phids));
if (!$all_phids) {
return $phid_map;
}
$provider = PhabricatorAuthProviderOAuthAsana::getAsanaProvider();
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->withUserPHIDs($all_phids)
->withAccountTypes(array($provider->getProviderType()))
->withAccountDomains(array($provider->getProviderDomain()))
->execute();
foreach ($accounts as $account) {
$phid_map[$account->getUserPHID()] = $account->getAccountID();
}
// Put this back in input order.
$phid_map = array_select_keys($phid_map, $all_phids);
return $phid_map;
}
private function findAnyValidAsanaAccessToken(array $user_phids) {
if (!$user_phids) {
return array(null, null, null);
}
$provider = $this->getProvider();
$viewer = $this->getViewer();
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withUserPHIDs($user_phids)
->withAccountTypes(array($provider->getProviderType()))
->withAccountDomains(array($provider->getProviderDomain()))
->execute();
// 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, $account->getAccountID(), $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['id'])
->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);
}
}
diff --git a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerJIRA.php b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerJIRA.php
index e9e6a3fae9..d6686b7de7 100644
--- a/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerJIRA.php
+++ b/src/applications/doorkeeper/worker/DoorkeeperFeedWorkerJIRA.php
@@ -1,159 +1,169 @@
<?php
/**
* Publishes feed stories into JIRA, using the "JIRA Issues" field to identify
* linked issues.
*/
final class DoorkeeperFeedWorkerJIRA extends DoorkeeperFeedWorker {
private $provider;
/* -( Publishing Stories )------------------------------------------------- */
/**
* This worker is enabled when a JIRA authentication provider is active.
*/
public function isEnabled() {
return (bool)PhabricatorAuthProviderOAuth1JIRA::getJIRAProvider();
}
/**
* Publishes stories into JIRA using the JIRA API.
*/
protected function publishFeedStory() {
$story = $this->getFeedStory();
$viewer = $this->getViewer();
$provider = $this->getProvider();
$object = $this->getStoryObject();
$publisher = $this->getPublisher();
$jira_issue_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
$object->getPHID(),
PhabricatorEdgeConfig::TYPE_PHOB_HAS_JIRAISSUE);
if (!$jira_issue_phids) {
$this->log("Story is about an object with no linked JIRA issues.\n");
return;
}
$xobjs = id(new DoorkeeperExternalObjectQuery())
->setViewer($viewer)
->withPHIDs($jira_issue_phids)
->execute();
if (!$xobjs) {
$this->log("Story object has no corresponding external JIRA objects.\n");
return;
}
$try_users = $this->findUsersToPossess();
if (!$try_users) {
$this->log("No users to act on linked JIRA objects.\n");
return;
}
- $story_text = $publisher->getStoryText($object);
+ $story_text = $this->renderStoryText();
$xobjs = mgroup($xobjs, 'getApplicationDomain');
foreach ($xobjs as $domain => $xobj_list) {
$accounts = id(new PhabricatorExternalAccountQuery())
->setViewer($viewer)
->withUserPHIDs($try_users)
->withAccountTypes(array($provider->getProviderType()))
->withAccountDomains(array($domain))
->execute();
// 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, $try_users);
foreach ($xobj_list as $xobj) {
foreach ($accounts as $account) {
try {
$provider->newJIRAFuture(
$account,
'rest/api/2/issue/'.$xobj->getObjectID().'/comment',
'POST',
array(
'body' => $story_text,
))->resolveJSON();
break;
} catch (HTTPFutureResponseStatus $ex) {
phlog($ex);
$this->log(
"Failed to update object %s using user %s.\n",
$xobj->getObjectID(),
$account->getUserPHID());
}
}
}
}
}
/* -( Internals )---------------------------------------------------------- */
/**
* Get the active JIRA provider.
*
* @return PhabricatorAuthProviderOAuth1JIRA Active JIRA auth provider.
* @task internal
*/
private function getProvider() {
if (!$this->provider) {
$provider = PhabricatorAuthProviderOAuth1JIRA::getJIRAProvider();
if (!$provider) {
throw new PhabricatorWorkerPermanentFailureException(
'No JIRA provider configured.');
}
$this->provider = $provider;
}
return $this->provider;
}
/**
* Get a list of users to act as when publishing into JIRA.
*
* @return list<phid> Candidate user PHIDs to act as when publishing this
* story.
* @task internal
*/
private function findUsersToPossess() {
$object = $this->getStoryObject();
$publisher = $this->getPublisher();
$data = $this->getFeedStory()->getStoryData();
// Figure out all the users related to the object. Users go into one of
// four buckets. For JIRA integration, we don't care about which bucket
// a user is in, since we just want to publish an update to linked objects.
$owner_phid = $publisher->getOwnerPHID($object);
$active_phids = $publisher->getActiveUserPHIDs($object);
$passive_phids = $publisher->getPassiveUserPHIDs($object);
$follow_phids = $publisher->getCCUserPHIDs($object);
$all_phids = array_merge(
array($owner_phid),
$active_phids,
$passive_phids,
$follow_phids);
$all_phids = array_unique(array_filter($all_phids));
// 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()),
$all_phids);
$try_users = array_filter($try_users);
return $try_users;
}
+ private function renderStoryText() {
+ $object = $this->getStoryObject();
+ $publisher = $this->getPublisher();
+
+ $text = $publisher->getStoryText($object);
+ $uri = $publisher->getObjectURI($object);
+
+ return $text."\n\n".$uri;
+ }
+
}
diff --git a/src/applications/feed/story/PhabricatorFeedStoryAudit.php b/src/applications/feed/story/PhabricatorFeedStoryAudit.php
index e9f7f855a6..bade63e766 100644
--- a/src/applications/feed/story/PhabricatorFeedStoryAudit.php
+++ b/src/applications/feed/story/PhabricatorFeedStoryAudit.php
@@ -1,76 +1,84 @@
<?php
final class PhabricatorFeedStoryAudit extends PhabricatorFeedStory {
public function getPrimaryObjectPHID() {
return $this->getStoryData()->getValue('commitPHID');
}
public function renderView() {
$author_phid = $this->getAuthorPHID();
$commit_phid = $this->getPrimaryObjectPHID();
$view = $this->newStoryView();
$view->setAppIcon('audit-dark');
$action = $this->getValue('action');
$verb = PhabricatorAuditActionConstants::getActionPastTenseVerb($action);
$view->setTitle(hsprintf(
'%s %s commit %s.',
$this->linkTo($author_phid),
$verb,
$this->linkTo($commit_phid)));
$comments = $this->getValue('content');
$view->setImage($this->getHandle($author_phid)->getImageURI());
if ($comments) {
$content = $this->renderSummary($this->getValue('content'));
$view->appendChild($content);
}
return $view;
}
public function renderText() {
$author_name = $this->getHandle($this->getAuthorPHID())->getLinkName();
$commit_path = $this->getHandle($this->getPrimaryObjectPHID())->getURI();
$commit_uri = PhabricatorEnv::getURI($commit_path);
$action = $this->getValue('action');
$verb = PhabricatorAuditActionConstants::getActionPastTenseVerb($action);
$text = "{$author_name} {$verb} commit {$commit_uri}";
return $text;
}
// TODO: At some point, make feed rendering not terrible and remove this
// hacky mess.
- public function renderForAsanaBridge() {
+ public function renderForAsanaBridge($implied_context = false) {
$data = $this->getStoryData();
$comment = $data->getValue('content');
$author_name = $this->getHandle($this->getAuthorPHID())->getName();
$action = $this->getValue('action');
$verb = PhabricatorAuditActionConstants::getActionPastTenseVerb($action);
- $title = "{$author_name} {$verb} this commit.";
+ $commit_phid = $this->getPrimaryObjectPHID();
+ $commit_name = $this->getHandle($commit_phid)->getFullName();
+
+ if ($implied_context) {
+ $title = "{$author_name} {$verb} this commit.";
+ } else {
+ $title = "{$author_name} {$verb} commit {$commit_name}.";
+ }
+
if (strlen($comment)) {
$engine = PhabricatorMarkupEngine::newMarkupEngine(array())
->setConfig('viewer', new PhabricatorUser())
->setMode(PhutilRemarkupEngine::MODE_TEXT);
$comment = $engine->markupText($comment);
$title .= "\n\n";
$title .= $comment;
}
return $title;
}
}
diff --git a/src/applications/feed/story/PhabricatorFeedStoryDifferential.php b/src/applications/feed/story/PhabricatorFeedStoryDifferential.php
index 7cfcbe2e97..b144076f93 100644
--- a/src/applications/feed/story/PhabricatorFeedStoryDifferential.php
+++ b/src/applications/feed/story/PhabricatorFeedStoryDifferential.php
@@ -1,148 +1,156 @@
<?php
final class PhabricatorFeedStoryDifferential extends PhabricatorFeedStory {
public function getPrimaryObjectPHID() {
return $this->getValue('revision_phid');
}
public function renderView() {
$data = $this->getStoryData();
$view = $this->newStoryView();
$view->setAppIcon('differential-dark');
$line = $this->getLineForData($data);
$view->setTitle($line);
$href = $this->getHandle($data->getValue('revision_phid'))->getURI();
$view->setHref($href);
$action = $data->getValue('action');
switch ($action) {
case DifferentialAction::ACTION_CREATE:
case DifferentialAction::ACTION_CLOSE:
case DifferentialAction::ACTION_COMMENT:
$full_size = true;
break;
default:
$full_size = false;
break;
}
$view->setImage($this->getHandle($data->getAuthorPHID())->getImageURI());
if ($full_size) {
$content = $this->renderSummary($data->getValue('feedback_content'));
$view->appendChild($content);
}
return $view;
}
private function getLineForData($data) {
$actor_phid = $data->getAuthorPHID();
$revision_phid = $data->getValue('revision_phid');
$action = $data->getValue('action');
$actor_link = $this->linkTo($actor_phid);
$revision_link = $this->linkTo($revision_phid);
$verb = DifferentialAction::getActionPastTenseVerb($action);
$one_line = hsprintf(
'%s %s revision %s',
$actor_link,
$verb,
$revision_link);
return $one_line;
}
public function renderText() {
$author_name = $this->getHandle($this->getAuthorPHID())->getLinkName();
$revision_handle = $this->getHandle($this->getPrimaryObjectPHID());
$revision_title = $revision_handle->getLinkName();
$revision_uri = PhabricatorEnv::getURI($revision_handle->getURI());
$action = $this->getValue('action');
$verb = DifferentialAction::getActionPastTenseVerb($action);
$text = "{$author_name} {$verb} revision {$revision_title} {$revision_uri}";
return $text;
}
public function getNotificationAggregations() {
$class = get_class($this);
$phid = $this->getStoryData()->getValue('revision_phid');
$read = (int)$this->getHasViewed();
// Don't aggregate updates separated by more than 2 hours.
$block = (int)($this->getEpoch() / (60 * 60 * 2));
return array(
"{$class}:{$phid}:{$read}:{$block}"
=> 'PhabricatorFeedStoryDifferentialAggregate',
);
}
// TODO: At some point, make feed rendering not terrible and remove this
// hacky mess.
- public function renderForAsanaBridge() {
+ public function renderForAsanaBridge($implied_context = false) {
$data = $this->getStoryData();
$comment = $data->getValue('feedback_content');
$author_name = $this->getHandle($this->getAuthorPHID())->getName();
$action = $this->getValue('action');
$verb = DifferentialAction::getActionPastTenseVerb($action);
$engine = PhabricatorMarkupEngine::newMarkupEngine(array())
->setConfig('viewer', new PhabricatorUser())
->setMode(PhutilRemarkupEngine::MODE_TEXT);
- $title = "{$author_name} {$verb} this revision.";
+ $revision_phid = $this->getPrimaryObjectPHID();
+ $revision_name = $this->getHandle($revision_phid)->getFullName();
+
+ if ($implied_context) {
+ $title = "{$author_name} {$verb} this revision.";
+ } else {
+ $title = "{$author_name} {$verb} revision {$revision_name}.";
+ }
+
if (strlen($comment)) {
$comment = $engine->markupText($comment);
$title .= "\n\n";
$title .= $comment;
}
// Roughly render inlines into the comment.
$comment_id = $data->getValue('temporaryCommentID');
if ($comment_id) {
$inlines = id(new DifferentialInlineCommentQuery())
->withCommentIDs(array($comment_id))
->execute();
if ($inlines) {
$title .= "\n\n";
$title .= pht('Inline Comments');
$title .= "\n";
$changeset_ids = mpull($inlines, 'getChangesetID');
$changesets = id(new DifferentialChangeset())->loadAllWhere(
'id IN (%Ld)',
$changeset_ids);
foreach ($inlines as $inline) {
$changeset = idx($changesets, $inline->getChangesetID());
if (!$changeset) {
continue;
}
$filename = $changeset->getDisplayFilename();
$linenumber = $inline->getLineNumber();
$inline_text = $engine->markupText($inline->getContent());
$inline_text = rtrim($inline_text);
$title .= "{$filename}:{$linenumber} {$inline_text}\n";
}
}
}
return $title;
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Jan 19 2025, 21:10 (6 w, 1 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1128806
Default Alt Text
(48 KB)
Attached To
Mode
rP Phorge
Attached
Detach File
Event Timeline
Log In to Comment