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 @@ -1273,6 +1273,117 @@ } } + /** + * Test that you can successfully destroy a complex projects tree, + * leaf by leaf, and the depths and the join policies remain consistent. + */ + public function testProjectDestroy() { + // Create test actors. + $author = $this->createUser()->save(); + $mario = $this->createUser()->save(); + $luigi = $this->createUser()->save(); + + // Create a project "A". It will have children later. + $a = $this->createProject($author)->save(); + + // Add "Mario" in the project "A", successfully. + $this->joinProject($a, $mario); + + // Under project "A", create "B". + // Note that the members of "A" are not editable anymore. + // Note that the members of "A" are moved to "B". + $b = $this->createProject($author, $a)->save(); + + // Under project "B", create the milestone "M". + // Note that the members of "B" are also members of "M". + $m = $this->createProject($author, $b, true)->save(); + + // Under project "B", create "C". + // Note that the members of "B" are not editable anymore. + // Note that the members of "B" are automatically moved to "C". + $c = $this->createProject($author, $b)->save(); + + // Refresh the perspective of Mario. + $this->refreshProjectInPlace($a, $mario); + $this->refreshProjectInPlace($b, $mario); + $this->refreshProjectInPlace($c, $mario); + $this->refreshProjectInPlace($m, $mario); + + // Current project tree: + // + // Project A > Project B > Milestone M + // Project A > Project B > Project C + // \ Depth: 0 \ \ + // \ Depth: 1 \ + // \ Depth: 2 + // + // - Mario is an indirect member of "A" + // - Mario is an indirect member of "B" + // - Mario is an indirect member of "M" + // - Mario is a direct member of "C" + // - Users cannot join "A" directly. + // - Users cannot join "B" directly. + // + $this->assertEqual(0, (int)$a->getProjectDepth()); + $this->assertEqual(1, (int)$b->getProjectDepth()); + $this->assertEqual(2, (int)$m->getProjectDepth()); + $this->assertEqual(2, (int)$c->getProjectDepth()); + $this->assertTrue($a->isUserMember($mario->getPHID())); + $this->assertTrue($b->isUserMember($mario->getPHID())); + $this->assertTrue($c->isUserMember($mario->getPHID())); + $this->assertTrue($m->isUserMember($mario->getPHID())); + $this->assertCannotJoinProject($a, $luigi, pht( + 'Test users cannot join project A, because B exists')); + $this->assertCannotJoinProject($b, $luigi, pht( + 'Test users cannot join project B, because C exists')); + + // Destroy project "B" and refresh its relatives. + // Note that its milestone "M" is also nuked automatically. + $engine = new PhabricatorDestructionEngine(); + $engine->destroyObject($b); + $this->refreshProjectInPlace($a, $mario); + $this->refreshProjectInPlace($c, $mario); + + // Current project tree: + // + // Project A > Project C + // \ Depth: 0 \ + // \ Depth: 1 + // + // - Mario is an indirect member of "A" + // - Mario is an direct member of "C" + // - Users cannot join "A" directly. + // + $this->assertEqual(null, $this->refreshProject($b, $mario)); + $this->assertEqual(null, $this->refreshProject($m, $mario)); + $this->assertEqual(0, (int)$a->getProjectDepth()); + $this->assertEqual(1, (int)$c->getProjectDepth()); + $this->assertTrue($a->isUserMember($mario->getPHID())); + $this->assertTrue($c->isUserMember($mario->getPHID())); + $this->assertCannotJoinProject($a, $luigi, pht( + 'Test users cannot join project A, because C exists')); + + // Destroy project "C" and refresh relatives. + $engine->destroyObject($c); + $this->refreshProjectInPlace($a, $author); + + // Current project tree: + // + // Project A + // \- Depth: 0 + // + // - Mario is a direct member of "A" + // - Users can join and leave "A" directly again \o/ + // + $this->assertEqual(null, $this->refreshProject($c, $mario)); + $this->assertEqual(0, (int)$a->getProjectDepth()); + $this->joinProject($a, $luigi); // No crash + $this->leaveProject($a, $luigi); // No crash + + // Destroy "A". It works and nothing remains. + $engine->destroyObject($a); + $this->assertEqual(null, $this->refreshProject($a, $mario)); + } private function moveToColumn( PhabricatorUser $viewer, @@ -1516,6 +1627,12 @@ } } + private function refreshProjectInPlace( + PhabricatorProject &$project, + PhabricatorUser $viewer): void { + $project = $this->refreshProject($project, $viewer); + } + private function refreshColumn( PhabricatorUser $viewer, PhabricatorProjectColumn $column) { @@ -1611,6 +1728,27 @@ return $user; } + /** + * Assert that the specified project is not joinable by the specified user. + * + * @param $project PhabricatorProject Test project + * @param $user PhabricatorUser Test user + * @param $assert_message string Test assert message + */ + private function assertCannotJoinProject( + PhabricatorProject $project, + PhabricatorUser $user, + string $assert_message) { + $join_crashed = false; + try { + $this->joinProject($project, $user); + } catch (PhabricatorApplicationTransactionValidationException $e) { + // You cannot join this project. + $join_crashed = true; + } + $this->assertTrue($join_crashed, $assert_message); + } + private function joinProject( PhabricatorProject $project, PhabricatorUser $user) { diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -748,9 +748,73 @@ $slug->delete(); } + // Destroy my milestones because they cannot live without me. + // Do not use PhabricatorProjectQuery to avoid a circular dependency, + // and to avoid a silent fail, since these milestones do not + // have their parent anymore. + $milestones = id(new self()) + ->loadAllWhere('parentProjectPHID = %s AND milestoneNumber IS NOT NULL', + $this->getPHID()); + foreach ($milestones as $milestone) { + $milestone->attachParentProject($this); + $engine->destroyObject($milestone); + } + + // Update my children to eventually fix gaps in the tree. + $this->onDestroyTouchChildren(true); + + // After the tree is fixed, update my parent's hasSubProjects field. + if ($this->getParentProject()) { + id(new PhabricatorProjectsMembershipIndexEngineExtension()) + ->rematerialize($this->getParentProject()); + } + $this->saveTransaction(); } + /** + * On destroy, eventually bubble up my direct children, to take my place. + * Refresh all remaining children, to consolidate depth, path key, etc. + */ + private function onDestroyTouchChildren($close_my_hole) { + // Micro-optimization. + if (!$this->supportsSubprojects() && !$this->supportsMilestones()) { + return; + } + + // Get direct sub-projects and milestones and their new desired parent. + // Do not use PhabricatorProjectQuery to avoid a circular dependency. + $query_children = new self(); + if ($close_my_hole) { + // We must skip my direct milestones since they are under removal. + $desired_parent = $this->getParentProject(); + $children = $query_children->loadAllWhere( + 'parentProjectPHID = %s AND milestoneNumber IS NULL', + $this->getPHID()); + } else { + $desired_parent = $this; + $children = $query_children->loadAllWhere( + 'parentProjectPHID = %s', + $this->getPHID()); + } + + $desired_parent_phid = null; + if ($desired_parent) { + $desired_parent_phid = $desired_parent->getPHID(); + } + + // Eventually bubble up my direct children. Update the others. + foreach ($children as $child) { + $child->attachParentProject($desired_parent); + $child->setParentProjectPHID($desired_parent_phid); + $child->setProjectPathKey(null); // Force a new path key and depth. + $child->save(); + + // Descend the tree. + $child->onDestroyTouchChildren(false); + } + } + /* -( PhabricatorFulltextInterface )--------------------------------------- */ 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 @@ -337,3 +337,116 @@ Form customization also provides a powerful tool for making many policy management tasks easier (see @{article:User Guide: Customizing Forms}). + +Archiving +========= + +In Phorge, you can both destroy a project (dangerous) or archive it (very safe). + +Archiving a project (or a milestone, which is a kind of project) +is very much recommended, for example, to archive a project when it stops +being useful, when the project reaches its deadline, or when its investor turns +out to be a scam. You might be surprised how useful it is to know which +colleagues have worked on a certain very old archived project, which fortunately +somebody decided to archive rather than fully destroy. + +The {nav icon=ban,name=Archive} action is visible to all people who +can {nav icon=pencil,name=Edit} a project. As usual in Phorge, +there is a confirmation dialog. + +After you confirm the archiving, these things will happen: + +- wherever the tag or its hashtag are mentioned, the corresponding badge is + appropriately de-colorized or struck-through. +- the archived project stops appearing in the active projects list at + [ /project/query/active/ ]( /project/query/active/ ) +- the archived project is de-prioritized from most search results and selectors, + including the top search bar, the tag pickers, etc. +- the archived project is muted, and does not cause "watch" notifications. +- the performer of this action is logged in the recent actions. + +All these consequences are reversible. You can bring a project back +to life anytime using the {nav icon=check,name=Activate project} action. + +After archiving a project, all tagged objects, tagged tasks, etc. will be +intentionally kept as-is. In particular, no special read-only policy is +enforced on these tagged objects. This is in line with the above section +about "Policies In Depth". In fact, an object can have many tags, and +if a specific team group ceases its operations, that does not mean that +others should stop working on the same tasks, etc. + +In case additional hiding of information is needed, you can reduce the +visibility policy of that project. For example, the visibility policy +"No One" makes the project effectively invisible to others. +Mastering the visibility policies helps a lot in making sure your cleanup +requests are managed professionally and in a secure way, still allowing +future auditing, when needed. + +If you still decide against archiving a project in a recoverable way, +continue to the following scary and dangerous section about +permanent destruction. + +Permanently Destroying +====================== + +Phorge is designed as a safe collaborative platform that rarely +requires @{article:Permanently Destroying Data}. + +If you have read that article, and if you have done a backup, and if +you have access to the command line, and if you still want to permanently +destroy a project (or a milestone), these will be the consequences: + +- the project is destroyed permanently from the database, forever + (unless you have a good backup and sufficient recovery skills) +- all objects, including tasks, repositories, wiki documents, calendars, + secrets, other projects, etc. to which you have set visibility restrictions + involving that project (example: "visible to project members") + will be broken, and everyone will be locked out of them. + That means these objects will become completely invisible from the web + interface and API results. + You can still recover from these particular policy problems, + by reading the @{article:User Guide: Unlocking Objects}. +- users that were members or watchers will lose that relationship. +- tagged items will lose that relationship, including tasks, commits, + wiki documents, calendar events, repositories, etc.; these objects + will simply not be associated anymore with that project tag + (but will remain associated with other tags, of course). +- comments and texts mentioning the hashtag of that `#project` will no longer + render that project link. + You will still be able to add that hashtag to another project, + to revive these links. +- if the project has a workboard, that workboard will be destroyed as well + (tasks in that workboard will always be kept and will remain associated + with other workboards). +- if the project has direct milestones, these milestones will be destroyed + as well + (recall that milestones are technically projects, so consider reading this + list again to understand what will happen to these milestones, and to items + associated to them, etc.) +- if the project has a parent project, and if that parent will end up with + no other child projects, that parent can be promoted to root-project again. + This means membership of the parent project will be editable again. +- if the project has subprojects, all subprojects and all their descendant + subprojects will climb the tree depth by one level, to fill the gap + you caused. Grandchildren become children, children become parents, + etc. - a real mess for family photos. +- you increase the risk of something completely unexpected happening, + such as the destruction of your entire datacenter by our Slugma Pokemons + out of recursion control. + +To permanently destroy a project, you will need to execute a command like this, +from the root directory of the Phorge repository on your server: + +``` +./bin/remove destroy PHID-PROJ-abcdef123456 +``` + +The command needs the "PHID" code of the project. +Every project has a PHID which can be retrieved in multiple ways, +including the {nav icon=cog,name=Manage} menu of that project, or hovering +the cursor on the {nav icon=flag,name=Flag For Later} feature. + +This command requires a manual confirmation. Before proceeding, +take the disclaimer seriously and read again the previous section about +archiving projects (safe), instead of permanently destroying them (unsafe), +to hopefully change your mind.