@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+297 -1
+132
src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
··· 808 808 pht('Engineering + Scan')); 809 809 } 810 810 811 + public function testTagAncestryConflicts() { 812 + $user = $this->createUser(); 813 + $user->save(); 814 + 815 + $stonework = $this->createProject($user); 816 + $stonework_masonry = $this->createProject($user, $stonework); 817 + $stonework_sculpting = $this->createProject($user, $stonework); 818 + 819 + $task = $this->newTask($user, array()); 820 + $this->assertEqual(array(), $this->getTaskProjects($task)); 821 + 822 + $this->addProjectTags($user, $task, array($stonework->getPHID())); 823 + $this->assertEqual( 824 + array( 825 + $stonework->getPHID(), 826 + ), 827 + $this->getTaskProjects($task)); 828 + 829 + // Adding a descendant should remove the parent. 830 + $this->addProjectTags($user, $task, array($stonework_masonry->getPHID())); 831 + $this->assertEqual( 832 + array( 833 + $stonework_masonry->getPHID(), 834 + ), 835 + $this->getTaskProjects($task)); 836 + 837 + // Adding an ancestor should remove the descendant. 838 + $this->addProjectTags($user, $task, array($stonework->getPHID())); 839 + $this->assertEqual( 840 + array( 841 + $stonework->getPHID(), 842 + ), 843 + $this->getTaskProjects($task)); 844 + 845 + // Adding two tags in the same hierarchy which are not mutual ancestors 846 + // should remove the ancestor but otherwise work fine. 847 + $this->addProjectTags( 848 + $user, 849 + $task, 850 + array( 851 + $stonework_masonry->getPHID(), 852 + $stonework_sculpting->getPHID(), 853 + )); 854 + 855 + $expect = array( 856 + $stonework_masonry->getPHID(), 857 + $stonework_sculpting->getPHID(), 858 + ); 859 + sort($expect); 860 + 861 + $this->assertEqual($expect, $this->getTaskProjects($task)); 862 + } 863 + 864 + public function testTagMilestoneConflicts() { 865 + $user = $this->createUser(); 866 + $user->save(); 867 + 868 + $stonework = $this->createProject($user); 869 + $stonework_1 = $this->createProject($user, $stonework, true); 870 + $stonework_2 = $this->createProject($user, $stonework, true); 871 + 872 + $task = $this->newTask($user, array()); 873 + $this->assertEqual(array(), $this->getTaskProjects($task)); 874 + 875 + $this->addProjectTags($user, $task, array($stonework->getPHID())); 876 + $this->assertEqual( 877 + array( 878 + $stonework->getPHID(), 879 + ), 880 + $this->getTaskProjects($task)); 881 + 882 + // Adding a milesone should remove the parent. 883 + $this->addProjectTags($user, $task, array($stonework_1->getPHID())); 884 + $this->assertEqual( 885 + array( 886 + $stonework_1->getPHID(), 887 + ), 888 + $this->getTaskProjects($task)); 889 + 890 + // Adding the parent should remove the milestone. 891 + $this->addProjectTags($user, $task, array($stonework->getPHID())); 892 + $this->assertEqual( 893 + array( 894 + $stonework->getPHID(), 895 + ), 896 + $this->getTaskProjects($task)); 897 + 898 + // First, add one milestone. 899 + $this->addProjectTags($user, $task, array($stonework_1->getPHID())); 900 + // Now, adding a second milestone should remove the first milestone. 901 + $this->addProjectTags($user, $task, array($stonework_2->getPHID())); 902 + $this->assertEqual( 903 + array( 904 + $stonework_2->getPHID(), 905 + ), 906 + $this->getTaskProjects($task)); 907 + } 908 + 909 + private function getTaskProjects(ManiphestTask $task) { 910 + $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( 911 + $task->getPHID(), 912 + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); 913 + 914 + sort($project_phids); 915 + 916 + return $project_phids; 917 + } 918 + 811 919 private function attemptProjectEdit( 812 920 PhabricatorProject $proj, 813 921 PhabricatorUser $user, ··· 826 934 return true; 827 935 } 828 936 937 + 938 + private function addProjectTags( 939 + PhabricatorUser $viewer, 940 + ManiphestTask $task, 941 + array $phids) { 942 + 943 + $xactions = array(); 944 + 945 + $xactions[] = id(new ManiphestTransaction()) 946 + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 947 + ->setMetadataValue( 948 + 'edge:type', 949 + PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) 950 + ->setNewValue( 951 + array( 952 + '+' => array_fuse($phids), 953 + )); 954 + 955 + $editor = id(new ManiphestTransactionEditor()) 956 + ->setActor($viewer) 957 + ->setContentSource(PhabricatorContentSource::newConsoleSource()) 958 + ->setContinueOnNoEffect(true) 959 + ->applyTransactions($task, $xactions); 960 + } 829 961 830 962 private function newTask( 831 963 PhabricatorUser $viewer,
+132 -1
src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
··· 400 400 return $space_phid; 401 401 } 402 402 case PhabricatorTransactions::TYPE_EDGE: 403 - return $this->getEdgeTransactionNewValue($xaction); 403 + $new_value = $this->getEdgeTransactionNewValue($xaction); 404 + 405 + $edge_type = $xaction->getMetadataValue('edge:type'); 406 + $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; 407 + if ($edge_type == $type_project) { 408 + $new_value = $this->applyProjectConflictRules($new_value); 409 + } 410 + 411 + return $new_value; 404 412 case PhabricatorTransactions::TYPE_CUSTOMFIELD: 405 413 $field = $this->getCustomFieldForTransaction($object, $xaction); 406 414 return $field->getNewValueFromApplicationTransactions($xaction); ··· 3344 3352 } 3345 3353 3346 3354 return $state; 3355 + } 3356 + 3357 + 3358 + /** 3359 + * Remove conflicts from a list of projects. 3360 + * 3361 + * Objects aren't allowed to be tagged with multiple milestones in the same 3362 + * group, nor projects such that one tag is the ancestor of any other tag. 3363 + * If the list of PHIDs include mutually exclusive projects, remove the 3364 + * conflicting projects. 3365 + * 3366 + * @param list<phid> List of project PHIDs. 3367 + * @return list<phid> List with conflicts removed. 3368 + */ 3369 + private function applyProjectConflictRules(array $phids) { 3370 + if (!$phids) { 3371 + return array(); 3372 + } 3373 + 3374 + // Overall, the last project in the list wins in cases of conflict (so when 3375 + // you add something, the thing you just added sticks and removes older 3376 + // values). 3377 + 3378 + // Beyond that, there are two basic cases: 3379 + 3380 + // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4". 3381 + // If multiple projects are milestones of the same parent, we only keep the 3382 + // last one. 3383 + 3384 + // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later 3385 + // in the list, we remove "A" and keep "A > B". If "A" comes later, we 3386 + // remove "A > B" and keep "A". 3387 + 3388 + // Note that it's OK to be in "A > B" and "A > C". There's only a conflict 3389 + // if one project is an ancestor of another. It's OK to have something 3390 + // tagged with multiple projects which share a common ancestor, so long as 3391 + // they are not mutual ancestors. 3392 + 3393 + $viewer = PhabricatorUser::getOmnipotentUser(); 3394 + 3395 + $projects = id(new PhabricatorProjectQuery()) 3396 + ->setViewer($viewer) 3397 + ->withPHIDs(array_keys($phids)) 3398 + ->execute(); 3399 + $projects = mpull($projects, null, 'getPHID'); 3400 + 3401 + // We're going to build a map from each project with milestones to the last 3402 + // milestone in the list. This last milestone is the milestone we'll keep. 3403 + $milestone_map = array(); 3404 + 3405 + // We're going to build a set of the projects which have no descendants 3406 + // later in the list. This allows us to apply both ancestor rules. 3407 + $ancestor_map = array(); 3408 + 3409 + foreach ($phids as $phid => $ignored) { 3410 + $project = idx($projects, $phid); 3411 + if (!$project) { 3412 + continue; 3413 + } 3414 + 3415 + // This is the last milestone we've seen, so set it as the selection for 3416 + // the project's parent. This might be setting a new value or overwriting 3417 + // an earlier value. 3418 + if ($project->isMilestone()) { 3419 + $parent_phid = $project->getParentProjectPHID(); 3420 + $milestone_map[$parent_phid] = $phid; 3421 + } 3422 + 3423 + // Since this is the last item in the list we've examined so far, add it 3424 + // to the set of projects with no later descendants. 3425 + $ancestor_map[$phid] = $phid; 3426 + 3427 + // Remove any ancestors from the set, since this is a later descendant. 3428 + foreach ($project->getAncestorProjects() as $ancestor) { 3429 + $ancestor_phid = $ancestor->getPHID(); 3430 + unset($ancestor_map[$ancestor_phid]); 3431 + } 3432 + } 3433 + 3434 + // Now that we've built the maps, we can throw away all the projects which 3435 + // have conflicts. 3436 + foreach ($phids as $phid => $ignored) { 3437 + $project = idx($projects, $phid); 3438 + 3439 + if (!$project) { 3440 + // If a PHID is invalid, we just leave it as-is. We could clean it up, 3441 + // but leaving it untouched is less likely to cause collateral damage. 3442 + continue; 3443 + } 3444 + 3445 + // If this was a milestone, check if it was the last milestone from its 3446 + // group in the list. If not, remove it from the list. 3447 + if ($project->isMilestone()) { 3448 + $parent_phid = $project->getParentProjectPHID(); 3449 + if ($milestone_map[$parent_phid] !== $phid) { 3450 + unset($phids[$phid]); 3451 + continue; 3452 + } 3453 + } 3454 + 3455 + // If a later project in the list is a subproject of this one, it will 3456 + // have removed ancestors from the map. If this project does not point 3457 + // at itself in the ancestor map, it should be discarded in favor of a 3458 + // subproject that comes later. 3459 + if (idx($ancestor_map, $phid) !== $phid) { 3460 + unset($phids[$phid]); 3461 + continue; 3462 + } 3463 + 3464 + // If a later project in the list is an ancestor of this one, it will 3465 + // have added itself to the map. If any ancestor of this project points 3466 + // at itself in the map, this project should be dicarded in favor of 3467 + // that later ancestor. 3468 + foreach ($project->getAncestorProjects() as $ancestor) { 3469 + $ancestor_phid = $ancestor->getPHID(); 3470 + if (isset($ancestor_map[$ancestor_phid])) { 3471 + unset($phids[$phid]); 3472 + continue 2; 3473 + } 3474 + } 3475 + } 3476 + 3477 + return $phids; 3347 3478 } 3348 3479 3349 3480 }
+33
src/docs/user/userguide/projects.diviner
··· 184 184 185 185 Subprojects can have normal workboards. 186 186 187 + The maximum subproject depth is 16. This limit is intended to grossly exceed 188 + the depth necessary in normal usage. 189 + 190 + Objects may not be tagged with multiple projects that are ancestors or 191 + descendants of one another. For example, a task may not be tagged with both 192 + {nav Stonework} and {nav Stonework > Masonry}. 193 + 194 + When a project tag is added that is the ancestor or descendant of one or more 195 + existing tags, the old tags are replaced. For example, adding 196 + {nav Stonework > Masonry} to a task tagged with {nav Stonework} will replace 197 + {nav Stonework} with the newer, more specific tag. 198 + 199 + This restriction does not apply to projects which share some common ancestor 200 + but are not themselves mutual ancestors. For example, a task may be tagged 201 + with both {nav Stonework > Masonry} and {nav Stonework > Sculpting}. 202 + 203 + This restriction //does// apply when the descendant is a milestone. For 204 + example, a task may not be tagged with both {nav Stonework} and 205 + {nav Stonework > Iteration II}. 206 + 187 207 188 208 Milestones 189 209 ========== ··· 203 223 By default, Milestones do not have their own hashtags. 204 224 205 225 Milestones can have normal workboards. 226 + 227 + Objects may not be tagged with two different milestones of the same parent 228 + project. For example, a task may not be tagged with both {nav Stonework > 229 + Iteration III} and {nav Stonework > Iteration V}. 230 + 231 + When a milestone tag is added to an object which already has a tag from the 232 + same series of milestones, the old tag is removed. For example, adding the 233 + {nav Stonework > Iteration V} tag to a task which already has the 234 + {nav Stonework > Iteration III} tag will remove the {nav Iteration III} tag. 235 + 236 + This restriction does not apply to milestones which are not part of the same 237 + series. For example, a task may be tagged with both 238 + {nav Stonework > Iteration V} and {nav Heraldry > Iteration IX}. 206 239 207 240 208 241 Parent Projects