mirror of
https://we.phorge.it/source/phorge.git
synced 2024-12-21 13:00:56 +01:00
Implement subproject/milestone conflict resolution rules
Summary: Ref T10010. When you try to add "Sprint 35" to a task, remove "Sprint 34", etc. Briefly: - A task can't be in Sprint 3 and Sprint 4. - A task can't be in "A" and "A > B" (but "A > B" and "A > C" are fine). - When a user makes an edit which would violate one of these rules, preserve the last tag in each group of conflicts. Test Plan: - Added fairly comprehensive tests. - Added a bunch of different tags to things, saw them properly exclude conflicting tags. Reviewers: chad Reviewed By: chad Maniphest Tasks: T10010 Differential Revision: https://secure.phabricator.com/D15167
This commit is contained in:
parent
5263c5bea4
commit
268a9ced78
3 changed files with 297 additions and 1 deletions
|
@ -808,6 +808,114 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase {
|
||||||
pht('Engineering + Scan'));
|
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(
|
private function attemptProjectEdit(
|
||||||
PhabricatorProject $proj,
|
PhabricatorProject $proj,
|
||||||
PhabricatorUser $user,
|
PhabricatorUser $user,
|
||||||
|
@ -827,6 +935,30 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
private function newTask(
|
||||||
PhabricatorUser $viewer,
|
PhabricatorUser $viewer,
|
||||||
array $projects,
|
array $projects,
|
||||||
|
|
|
@ -400,7 +400,15 @@ abstract class PhabricatorApplicationTransactionEditor
|
||||||
return $space_phid;
|
return $space_phid;
|
||||||
}
|
}
|
||||||
case PhabricatorTransactions::TYPE_EDGE:
|
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:
|
case PhabricatorTransactions::TYPE_CUSTOMFIELD:
|
||||||
$field = $this->getCustomFieldForTransaction($object, $xaction);
|
$field = $this->getCustomFieldForTransaction($object, $xaction);
|
||||||
return $field->getNewValueFromApplicationTransactions($xaction);
|
return $field->getNewValueFromApplicationTransactions($xaction);
|
||||||
|
@ -3346,4 +3354,127 @@ abstract class PhabricatorApplicationTransactionEditor
|
||||||
return $state;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -184,6 +184,26 @@ subproject rules.
|
||||||
|
|
||||||
Subprojects can have normal workboards.
|
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 multiple 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}.
|
||||||
|
|
||||||
|
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
|
Milestones
|
||||||
==========
|
==========
|
||||||
|
@ -204,6 +224,19 @@ By default, Milestones do not have their own hashtags.
|
||||||
|
|
||||||
Milestones can have normal workboards.
|
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
|
Parent Projects
|
||||||
===============
|
===============
|
||||||
|
|
Loading…
Reference in a new issue