Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F5345095
D25772.1749995895.diff
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
14 KB
Referenced Files
None
Subscribers
None
D25772.1749995895.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
@@ -1273,6 +1273,120 @@
}
}
+ /**
+ * 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 relatives of project "C".
+ $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());
+
+ // Project "A" is restored as normal project.
+ // As such, people can now happily join and leave. No crashes.
+ $this->joinProject($a, $luigi);
+ $this->leaveProject($a, $luigi);
+
+ // Destroy "A". It works and nothing remains.
+ $engine->destroyObject($a);
+ $this->assertEqual(null, $this->refreshProject($a, $mario));
+ }
private function moveToColumn(
PhabricatorUser $viewer,
@@ -1516,6 +1630,12 @@
}
}
+ private function refreshProjectInPlace(
+ PhabricatorProject &$project,
+ PhabricatorUser $viewer): void {
+ $project = $this->refreshProject($project, $viewer);
+ }
+
private function refreshColumn(
PhabricatorUser $viewer,
PhabricatorProjectColumn $column) {
@@ -1611,6 +1731,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.
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Jun 15, 13:58 (7 h, 18 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1861828
Default Alt Text
D25772.1749995895.diff (14 KB)
Attached To
Mode
D25772: Projects: improve quality of destroy workflow
Attached
Detach File
Event Timeline
Log In to Comment