Page MenuHomePhorge

D25772.1746882528.diff
No OneTemporary

D25772.1746882528.diff

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,87 @@
}
}
+ public function testProjectDestroy() {
+ $author = $this->generateNewTestUser();
+ $mario = $this->generateNewTestUser();
+ $user_forever_alone = $this->generateNewTestUser();
+
+ // Create a root project that will have children.
+ $a = $this->createProject($author)->save();
+
+ // Add a member directly in the root project, successfully.
+ $this->joinProject($a, $mario);
+
+ // Current project tree:
+ // Project A
+ // \- Member Mario
+ $this->assertEqual(0, $a->getProjectDepth());
+
+ // Create a child project.
+ // Note that the members of "A" are automatically moved to "B".
+ $b = $this->createProject($author, $a)->save();
+
+ // Current project tree:
+ // Project A > Project B
+ // \- Member Mario
+ $this->assertEqual(0, (int)$a->getProjectDepth());
+ $this->assertEqual(1, (int)$b->getProjectDepth());
+
+ // Create a milestone under "B".
+ $m = $this->createProject($author, $b, true)->save();
+
+ // Current project tree:
+ // Project A > Project B > Milestone M
+ $this->assertEqual(0, (int)$a->getProjectDepth());
+ $this->assertEqual(1, (int)$b->getProjectDepth());
+ $this->assertEqual(2, (int)$m->getProjectDepth());
+
+ // Test the addition of another user directly in "A". It must fail.
+ $error_joining_a = false;
+ try {
+ $this->joinProject($a, $user_forever_alone);
+ } catch (PhabricatorApplicationTransactionValidationException $e) {
+ $error_joining_a = true;
+ }
+ $this->assertTrue($error_joining_a);
+
+ // Create a child-child project.
+ // Note that the members of "B" are automatically moved to "C".
+ $c = $this->createProject($author, $b)->save();
+
+ // Current project tree:
+ // Project A > Project B > Milestone M
+ // Project A > Project B > Project C
+ // \- Member Mario
+ $this->assertEqual(0, (int)$a->getProjectDepth());
+ $this->assertEqual(1, (int)$b->getProjectDepth());
+ $this->assertEqual(2, (int)$m->getProjectDepth());
+ $this->assertEqual(2, (int)$c->getProjectDepth());
+
+ // Test the destroy of the project in the middle (B).
+ $engine = new PhabricatorDestructionEngine();
+ $engine->destroyObject($b);
+ $this->refreshProjectInPlace($a, $author);
+ $this->refreshProjectInPlace($c, $author);
+
+ // Current project tree:
+ // Project A > Project C
+ // \- Member Mario
+ $this->assertEqual(0, (int)$a->getProjectDepth());
+ $this->assertEqual(1, (int)$c->getProjectDepth());
+ $this->assertEqual(null, $this->refreshProject($m, $author));
+
+ $engine->destroyObject($a);
+ $this->refreshProjectInPlace($c, $author);
+
+ // Current project tree:
+ // Project C
+ // \- Member Mario
+ $this->assertEqual(0, (int)$c->getProjectDepth());
+
+ // Test the destroy of the last leaf, no crashes.
+ $engine->destroyObject($b);
+ }
private function moveToColumn(
PhabricatorUser $viewer,
@@ -1516,6 +1597,12 @@
}
}
+ private function refreshProjectInPlace(
+ PhabricatorProject &$project,
+ PhabricatorUser $viewer): void {
+ $project = $this->refreshProject($project, $viewer);
+ }
+
private function refreshColumn(
PhabricatorUser $viewer,
PhabricatorProjectColumn $column) {
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,75 @@
$slug->delete();
}
+ // Destroy my milestones because they cannot live without me.
+ // Do not use PhabricatorProjectQuery to avoid a circular dependency,
+ // and, to do not have a load 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 holes in the tree.
+ $this->onDestroyTouchChildren(true);
+
+ // After the tree is fixed, update my parent 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.
+ // We must skip my direct milestones since they are under removal.
+ // Do not use PhabricatorProjectQuery to avoid a circular dependency.
+ $query_children = new self();
+ if ($close_my_hole) {
+ $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());
+ }
+
+ // The desired parent PHID for my children may become NULL,
+ // when we are closing my hole but my parent it's a root-project.
+ $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

Mime Type
text/plain
Expires
Sat, May 10, 13:08 (2 d, 4 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1498575
Default Alt Text
D25772.1746882528.diff (12 KB)

Event Timeline