Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F14351738
D15167.id36618.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
12 KB
Referenced Files
None
Subscribers
None
D15167.id36618.diff
View Options
diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
--- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
+++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
@@ -808,6 +808,114 @@
pht('Engineering + Scan'));
}
+ public function testTagAncestryConflicts() {
+ $user = $this->createUser();
+ $user->save();
+
+ $stonework = $this->createProject($user);
+ $stonework_masonry = $this->createProject($user, $stonework);
+ $stonework_sculpting = $this->createProject($user, $stonework);
+
+ $task = $this->newTask($user, array());
+ $this->assertEqual(array(), $this->getTaskProjects($task));
+
+ $this->addProjectTags($user, $task, array($stonework->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // Adding a descendant should remove the parent.
+ $this->addProjectTags($user, $task, array($stonework_masonry->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework_masonry->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // Adding an ancestor should remove the descendant.
+ $this->addProjectTags($user, $task, array($stonework->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // Adding two tags in the same hierarchy which are not mutual ancestors
+ // should remove the ancestor but otherwise work fine.
+ $this->addProjectTags(
+ $user,
+ $task,
+ array(
+ $stonework_masonry->getPHID(),
+ $stonework_sculpting->getPHID(),
+ ));
+
+ $expect = array(
+ $stonework_masonry->getPHID(),
+ $stonework_sculpting->getPHID(),
+ );
+ sort($expect);
+
+ $this->assertEqual($expect, $this->getTaskProjects($task));
+ }
+
+ public function testTagMilestoneConflicts() {
+ $user = $this->createUser();
+ $user->save();
+
+ $stonework = $this->createProject($user);
+ $stonework_1 = $this->createProject($user, $stonework, true);
+ $stonework_2 = $this->createProject($user, $stonework, true);
+
+ $task = $this->newTask($user, array());
+ $this->assertEqual(array(), $this->getTaskProjects($task));
+
+ $this->addProjectTags($user, $task, array($stonework->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // Adding a milesone should remove the parent.
+ $this->addProjectTags($user, $task, array($stonework_1->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework_1->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // Adding the parent should remove the milestone.
+ $this->addProjectTags($user, $task, array($stonework->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+
+ // First, add one milestone.
+ $this->addProjectTags($user, $task, array($stonework_1->getPHID()));
+ // Now, adding a second milestone should remove the first milestone.
+ $this->addProjectTags($user, $task, array($stonework_2->getPHID()));
+ $this->assertEqual(
+ array(
+ $stonework_2->getPHID(),
+ ),
+ $this->getTaskProjects($task));
+ }
+
+ private function getTaskProjects(ManiphestTask $task) {
+ $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
+ $task->getPHID(),
+ PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
+
+ sort($project_phids);
+
+ return $project_phids;
+ }
+
private function attemptProjectEdit(
PhabricatorProject $proj,
PhabricatorUser $user,
@@ -827,6 +935,30 @@
}
+ private function addProjectTags(
+ PhabricatorUser $viewer,
+ ManiphestTask $task,
+ array $phids) {
+
+ $xactions = array();
+
+ $xactions[] = id(new ManiphestTransaction())
+ ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
+ ->setMetadataValue(
+ 'edge:type',
+ PhabricatorProjectObjectHasProjectEdgeType::EDGECONST)
+ ->setNewValue(
+ array(
+ '+' => array_fuse($phids),
+ ));
+
+ $editor = id(new ManiphestTransactionEditor())
+ ->setActor($viewer)
+ ->setContentSource(PhabricatorContentSource::newConsoleSource())
+ ->setContinueOnNoEffect(true)
+ ->applyTransactions($task, $xactions);
+ }
+
private function newTask(
PhabricatorUser $viewer,
array $projects,
diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
--- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
+++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
@@ -400,7 +400,15 @@
return $space_phid;
}
case PhabricatorTransactions::TYPE_EDGE:
- return $this->getEdgeTransactionNewValue($xaction);
+ $new_value = $this->getEdgeTransactionNewValue($xaction);
+
+ $edge_type = $xaction->getMetadataValue('edge:type');
+ $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
+ if ($edge_type == $type_project) {
+ $new_value = $this->applyProjectConflictRules($new_value);
+ }
+
+ return $new_value;
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
$field = $this->getCustomFieldForTransaction($object, $xaction);
return $field->getNewValueFromApplicationTransactions($xaction);
@@ -3346,4 +3354,127 @@
return $state;
}
+
+ /**
+ * Remove conflicts from a list of projects.
+ *
+ * Objects aren't allowed to be tagged with multiple milestones in the same
+ * group, nor projects such that one tag is the ancestor of any other tag.
+ * If the list of PHIDs include mutually exclusive projects, remove the
+ * conflicting projects.
+ *
+ * @param list<phid> List of project PHIDs.
+ * @return list<phid> List with conflicts removed.
+ */
+ private function applyProjectConflictRules(array $phids) {
+ if (!$phids) {
+ return array();
+ }
+
+ // Overall, the last project in the list wins in cases of conflict (so when
+ // you add something, the thing you just added sticks and removes older
+ // values).
+
+ // Beyond that, there are two basic cases:
+
+ // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
+ // If multiple projects are milestones of the same parent, we only keep the
+ // last one.
+
+ // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
+ // in the list, we remove "A" and keep "A > B". If "A" comes later, we
+ // remove "A > B" and keep "A".
+
+ // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
+ // if one project is an ancestor of another. It's OK to have something
+ // tagged with multiple projects which share a common ancestor, so long as
+ // they are not mutual ancestors.
+
+ $viewer = PhabricatorUser::getOmnipotentUser();
+
+ $projects = id(new PhabricatorProjectQuery())
+ ->setViewer($viewer)
+ ->withPHIDs(array_keys($phids))
+ ->execute();
+ $projects = mpull($projects, null, 'getPHID');
+
+ // We're going to build a map from each project with milestones to the last
+ // milestone in the list. This last milestone is the milestone we'll keep.
+ $milestone_map = array();
+
+ // We're going to build a set of the projects which have no descendants
+ // later in the list. This allows us to apply both ancestor rules.
+ $ancestor_map = array();
+
+ foreach ($phids as $phid => $ignored) {
+ $project = idx($projects, $phid);
+ if (!$project) {
+ continue;
+ }
+
+ // This is the last milestone we've seen, so set it as the selection for
+ // the project's parent. This might be setting a new value or overwriting
+ // an earlier value.
+ if ($project->isMilestone()) {
+ $parent_phid = $project->getParentProjectPHID();
+ $milestone_map[$parent_phid] = $phid;
+ }
+
+ // Since this is the last item in the list we've examined so far, add it
+ // to the set of projects with no later descendants.
+ $ancestor_map[$phid] = $phid;
+
+ // Remove any ancestors from the set, since this is a later descendant.
+ foreach ($project->getAncestorProjects() as $ancestor) {
+ $ancestor_phid = $ancestor->getPHID();
+ unset($ancestor_map[$ancestor_phid]);
+ }
+ }
+
+ // Now that we've built the maps, we can throw away all the projects which
+ // have conflicts.
+ foreach ($phids as $phid => $ignored) {
+ $project = idx($projects, $phid);
+
+ if (!$project) {
+ // If a PHID is invalid, we just leave it as-is. We could clean it up,
+ // but leaving it untouched is less likely to cause collateral damage.
+ continue;
+ }
+
+ // If this was a milestone, check if it was the last milestone from its
+ // group in the list. If not, remove it from the list.
+ if ($project->isMilestone()) {
+ $parent_phid = $project->getParentProjectPHID();
+ if ($milestone_map[$parent_phid] !== $phid) {
+ unset($phids[$phid]);
+ continue;
+ }
+ }
+
+ // If a later project in the list is a subproject of this one, it will
+ // have removed ancestors from the map. If this project does not point
+ // at itself in the ancestor map, it should be discarded in favor of a
+ // subproject that comes later.
+ if (idx($ancestor_map, $phid) !== $phid) {
+ unset($phids[$phid]);
+ continue;
+ }
+
+ // If a later project in the list is an ancestor of this one, it will
+ // have added itself to the map. If any ancestor of this project points
+ // at itself in the map, this project should be dicarded in favor of
+ // that later ancestor.
+ foreach ($project->getAncestorProjects() as $ancestor) {
+ $ancestor_phid = $ancestor->getPHID();
+ if (isset($ancestor_map[$ancestor_phid])) {
+ unset($phids[$phid]);
+ continue 2;
+ }
+ }
+ }
+
+ return $phids;
+ }
+
}
diff --git a/src/docs/user/userguide/projects.diviner b/src/docs/user/userguide/projects.diviner
--- a/src/docs/user/userguide/projects.diviner
+++ b/src/docs/user/userguide/projects.diviner
@@ -184,6 +184,27 @@
Subprojects can have normal workboards.
+The maximum subproject depth is 16. This limit is intended to grossly exceed
+the depth necessary in normal usage.
+
+Objects may not be tagged with projects that are ancestors or descendants of
+one another. For example, a task may not be tagged with both {nav Stonework}
+and {nav Stonework > Masonry}, because the ancestor tag is redundant and makes
+tag management more complex.
+
+When a project tag is added that is the ancestor or descendant of one or more
+existing tags, the old tags are replaced. For example, adding
+{nav Stonework > Masonry} to a task tagged with {nav Stonework} will replace
+{nav Stonework} with the newer, more specific tag.
+
+This restriction does not apply to projects which share some common ancestor
+but are not themselves mutual ancestors. For example, a task may be tagged
+with both {nav Stonework > Masonry} and {nav Stonework > Sculpting}.
+
+This restriction //does// apply when the descendant is a milestone. For
+example, a task may not be tagged with both {nav Stonework} and
+{nav Stonework > Iteration II}.
+
Milestones
==========
@@ -204,6 +225,19 @@
Milestones can have normal workboards.
+Objects may not be tagged with two different milestones of the same parent
+project. For example, a task may not be tagged with both {nav Stonework >
+Iteration III} and {nav Stonework > Iteration V}.
+
+When a milestone tag is added to an object which already has a tag from the
+same series of milestones, the old tag is removed. For example, adding the
+{nav Stonework > Iteration V} tag to a task which already has the
+{nav Stonework > Iteration III} tag will remove the {nav Iteration III} tag.
+
+This restriction does not apply to milestones which are not part of the same
+series. For example, a task may be tagged with both
+{nav Stonework > Iteration V} and {nav Heraldry > Iteration IX}.
+
Parent Projects
===============
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Dec 20, 9:56 AM (25 m, 27 s)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6910120
Default Alt Text
D15167.id36618.diff (12 KB)
Attached To
Mode
D15167: Implement subproject/milestone conflict resolution rules
Attached
Detach File
Event Timeline
Log In to Comment