diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index 186ac7dea4..a31bf8853c 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1,1695 +1,1694 @@ true, ); } public function testViewProject() { $user = $this->createUser(); $user->save(); $user2 = $this->createUser(); $user2->save(); $proj = $this->createProject($user); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj->setViewPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $can_view = PhabricatorPolicyCapability::CAN_VIEW; // When the view policy is set to "users", any user can see the project. $this->assertTrue((bool)$this->refreshProject($proj, $user)); $this->assertTrue((bool)$this->refreshProject($proj, $user2)); // When the view policy is set to "no one", members can still see the // project. $proj->setViewPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $this->assertTrue((bool)$this->refreshProject($proj, $user)); $this->assertFalse((bool)$this->refreshProject($proj, $user2)); } public function testApplicationPolicy() { $user = $this->createUser() ->save(); $proj = $this->createProject($user); $this->assertTrue( PhabricatorPolicyFilter::hasCapability( $user, $proj, PhabricatorPolicyCapability::CAN_VIEW)); // This object is visible so its handle should load normally. $handle = id(new PhabricatorHandleQuery()) ->setViewer($user) ->withPHIDs(array($proj->getPHID())) ->executeOne(); $this->assertEqual($proj->getPHID(), $handle->getPHID()); // Change the "Can Use Application" policy for Projecs to "No One". This // should cause filtering checks to fail even when they are executed // directly rather than via a Query. $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig( 'phabricator.application-settings', array( 'PHID-APPS-PhabricatorProjectApplication' => array( 'policy' => array( 'view' => PhabricatorPolicies::POLICY_NOONE, ), ), )); // Application visibility is cached because it does not normally change // over the course of a single request. Drop the cache so the next filter // test uses the new visibility. PhabricatorCaches::destroyRequestCache(); $this->assertFalse( PhabricatorPolicyFilter::hasCapability( $user, $proj, PhabricatorPolicyCapability::CAN_VIEW)); // We should still be able to load a handle for the project, even if we // can not see the application. $handle = id(new PhabricatorHandleQuery()) ->setViewer($user) ->withPHIDs(array($proj->getPHID())) ->executeOne(); // The handle should load... $this->assertEqual($proj->getPHID(), $handle->getPHID()); // ...but be policy filtered. $this->assertTrue($handle->getPolicyFiltered()); unset($env); } public function testIsViewerMemberOrWatcher() { $user1 = $this->createUser() ->save(); $user2 = $this->createUser() ->save(); $user3 = $this->createUser() ->save(); $proj1 = $this->createProject($user1); $proj1 = $this->refreshProject($proj1, $user1); $this->joinProject($proj1, $user1); $this->joinProject($proj1, $user3); $this->watchProject($proj1, $user3); $proj1 = $this->refreshProject($proj1, $user1); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, false, true); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserWatcher($user1->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, true, false); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserMember($user2->getPHID())); $this->assertTrue($proj1->isUserMember($user3->getPHID())); $proj1 = $this->refreshProject($proj1, $user1, true, true); $this->assertTrue($proj1->isUserMember($user1->getPHID())); $this->assertFalse($proj1->isUserMember($user2->getPHID())); $this->assertTrue($proj1->isUserMember($user3->getPHID())); $this->assertFalse($proj1->isUserWatcher($user1->getPHID())); $this->assertFalse($proj1->isUserWatcher($user2->getPHID())); $this->assertTrue($proj1->isUserWatcher($user3->getPHID())); } public function testEditProject() { $user = $this->createUser(); $user->save(); $user->setAllowInlineCacheGeneration(true); $proj = $this->createProject($user); // When edit and view policies are set to "user", anyone can edit. $proj->setViewPolicy(PhabricatorPolicies::POLICY_USER); $proj->setEditPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $this->assertTrue($this->attemptProjectEdit($proj, $user)); // When edit policy is set to "no one", no one can edit. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $caught = null; try { $this->attemptProjectEdit($proj, $user); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); } public function testAncestorMembers() { $user1 = $this->createUser(); $user1->save(); $user2 = $this->createUser(); $user2->save(); $parent = $this->createProject($user1); $child = $this->createProject($user1, $parent); $this->joinProject($child, $user1); $this->joinProject($child, $user2); $project = id(new PhabricatorProjectQuery()) ->setViewer($user1) ->withPHIDs(array($child->getPHID())) ->needAncestorMembers(true) ->executeOne(); $members = array_fuse($project->getParentProject()->getMemberPHIDs()); ksort($members); $expect = array_fuse( array( $user1->getPHID(), $user2->getPHID(), )); ksort($expect); $this->assertEqual($expect, $members); } public function testAncestryQueries() { $user = $this->createUser(); $user->save(); $ancestor = $this->createProject($user); $parent = $this->createProject($user, $ancestor); $child = $this->createProject($user, $parent); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(2, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withParentProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(1, count($projects)); $this->assertEqual( $parent->getPHID(), head($projects)->getPHID()); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(2, null) ->execute(); $this->assertEqual(1, count($projects)); $this->assertEqual( $child->getPHID(), head($projects)->getPHID()); $parent2 = $this->createProject($user, $ancestor); $child2 = $this->createProject($user, $parent2); $grandchild2 = $this->createProject($user, $child2); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(5, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withParentProjectPHIDs(array($ancestor->getPHID())) ->execute(); $this->assertEqual(2, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(2, null) ->execute(); $this->assertEqual(3, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withAncestorProjectPHIDs(array($ancestor->getPHID())) ->withDepthBetween(3, null) ->execute(); $this->assertEqual(1, count($projects)); $projects = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs( array( $child->getPHID(), $grandchild2->getPHID(), )) ->execute(); $this->assertEqual(2, count($projects)); } public function testMemberMaterialization() { $material_type = PhabricatorProjectMaterializedMemberEdgeType::EDGECONST; $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $child = $this->createProject($user, $parent); $this->joinProject($child, $user); $parent_material = PhabricatorEdgeQuery::loadDestinationPHIDs( $parent->getPHID(), $material_type); $this->assertEqual( array($user->getPHID()), $parent_material); } public function testMilestones() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $m1 = $this->createProject($user, $parent, true); $m2 = $this->createProject($user, $parent, true); $m3 = $this->createProject($user, $parent, true); $this->assertEqual(1, $m1->getMilestoneNumber()); $this->assertEqual(2, $m2->getMilestoneNumber()); $this->assertEqual(3, $m3->getMilestoneNumber()); } public function testMilestoneMembership() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $milestone = $this->createProject($user, $parent, true); $this->joinProject($parent, $user); $milestone = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($milestone->getPHID())) ->executeOne(); $this->assertTrue($milestone->isUserMember($user->getPHID())); $milestone = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($milestone->getPHID())) ->needMembers(true) ->executeOne(); $this->assertEqual( array($user->getPHID()), $milestone->getMemberPHIDs()); } public function testSameSlugAsName() { // It should be OK to type the primary hashtag into "additional hashtags", // even if the primary hashtag doesn't exist yet because you're creating // or renaming the project. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); // In this first case, set the name and slugs at the same time. $name = 'slugproject'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectNameTransaction::TRANSACTIONTYPE) ->setNewValue($name); $this->applyTransactions($project, $user, $xactions); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($name)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($name, $slugs)); // In this second case, set the name first and then the slugs separately. $name2 = 'slugproject2'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectNameTransaction::TRANSACTIONTYPE) ->setNewValue($name2); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($name2)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($name2, $slugs)); } public function testDuplicateSlugs() { // Creating a project with multiple duplicate slugs should succeed. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); $input = 'duplicate'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($input, $input)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($input, $slugs)); } public function testNormalizeSlugs() { // When a user creates a project with slug "XxX360n0sc0perXxX", normalize // it before writing it. $user = $this->createUser(); $user->save(); $project = $this->createProject($user); $input = 'NoRmAlIzE'; $expect = 'normalize'; $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($input)); $this->applyTransactions($project, $user, $xactions); $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPHIDs(array($project->getPHID())) ->needSlugs(true) ->executeOne(); $slugs = $project->getSlugs(); $slugs = mpull($slugs, 'getSlug'); $this->assertTrue(in_array($expect, $slugs)); // If another user tries to add the same slug in denormalized form, it // should be caught and fail, even though the database version of the slug // is normalized. $project2 = $this->createProject($user); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($input)); $caught = null; try { $this->applyTransactions($project2, $user, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $caught = $ex; } $this->assertTrue((bool)$caught); } public function testProjectMembersVisibility() { // This is primarily testing that you can create a project and set the // visibility or edit policy to "Project Members" immediately. $user1 = $this->createUser(); $user1->save(); $user2 = $this->createUser(); $user2->save(); $project = PhabricatorProject::initializeNewProject($user1); $name = pht('Test Project %d', mt_rand()); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectNameTransaction::TRANSACTIONTYPE) ->setNewValue($name); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue( id(new PhabricatorProjectMembersPolicyRule()) ->getObjectPolicyFullKey()); $edge_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue( array( '=' => array($user1->getPHID() => $user1->getPHID()), )); $this->applyTransactions($project, $user1, $xactions); $this->assertTrue((bool)$this->refreshProject($project, $user1)); $this->assertFalse((bool)$this->refreshProject($project, $user2)); $this->leaveProject($project, $user1); $this->assertFalse((bool)$this->refreshProject($project, $user1)); } public function testParentProject() { $user = $this->createUser(); $user->save(); $parent = $this->createProject($user); $child = $this->createProject($user, $parent); $this->assertTrue(true); $child = $this->refreshProject($child, $user); $this->assertEqual( $parent->getPHID(), $child->getParentProject()->getPHID()); $this->assertEqual(1, (int)$child->getProjectDepth()); $this->assertFalse( $child->isUserMember($user->getPHID())); $this->assertFalse( $child->getParentProject()->isUserMember($user->getPHID())); $this->joinProject($child, $user); $child = $this->refreshProject($child, $user); $this->assertTrue( $child->isUserMember($user->getPHID())); $this->assertTrue( $child->getParentProject()->isUserMember($user->getPHID())); // Test that hiding a parent hides the child. $user2 = $this->createUser(); $user2->save(); // Second user can see the project for now. $this->assertTrue((bool)$this->refreshProject($child, $user2)); // Hide the parent. $this->setViewPolicy($parent, $user, $user->getPHID()); // First user (who can see the parent because they are a member of // the child) can see the project. $this->assertTrue((bool)$this->refreshProject($child, $user)); // Second user can not, because they can't see the parent. $this->assertFalse((bool)$this->refreshProject($child, $user2)); } public function testSlugMaps() { // When querying by slugs, slugs should be normalized and the mapping // should be reported correctly. $user = $this->createUser(); $user->save(); $name = 'queryslugproject'; $name2 = 'QUERYslugPROJECT'; $slug = 'queryslugextra'; $slug2 = 'QuErYSlUgExTrA'; $project = PhabricatorProject::initializeNewProject($user); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectNameTransaction::TRANSACTIONTYPE) ->setNewValue($name); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectSlugsTransaction::TRANSACTIONTYPE) ->setNewValue(array($slug)); $this->applyTransactions($project, $user, $xactions); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($name)); $project_query->execute(); $map = $project_query->getSlugMap(); $this->assertEqual( array( $name => $project->getPHID(), ), ipull($map, 'projectPHID')); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($slug)); $project_query->execute(); $map = $project_query->getSlugMap(); $this->assertEqual( array( $slug => $project->getPHID(), ), ipull($map, 'projectPHID')); $project_query = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withSlugs(array($name, $slug, $name2, $slug2)); $project_query->execute(); $map = $project_query->getSlugMap(); $expect = array( $name => $project->getPHID(), $slug => $project->getPHID(), $name2 => $project->getPHID(), $slug2 => $project->getPHID(), ); $actual = ipull($map, 'projectPHID'); ksort($expect); ksort($actual); $this->assertEqual($expect, $actual); $expect = array( $name => $name, $slug => $slug, $name2 => $name, $slug2 => $slug, ); $actual = ipull($map, 'slug'); ksort($expect); ksort($actual); $this->assertEqual($expect, $actual); } public function testJoinLeaveProject() { $user = $this->createUser(); $user->save(); $proj = $this->createProjectWithNewAuthor(); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( (bool)$proj, pht( 'Assumption that projects are default visible '. 'to any user when created.')); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Arbitrary user not member of project.')); // Join the project. $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join works.')); // Join the project again. $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Joining an already-joined project is a no-op.')); // Leave the project. $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leave works.')); // Leave the project again. $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue((bool)$proj); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leaving an already-left project is a no-op.')); // If a user can't edit or join a project, joining fails. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $caught = null; try { $this->joinProject($proj, $user); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($ex instanceof Exception); // If a user can edit a project, they can join. $proj->setEditPolicy(PhabricatorPolicies::POLICY_USER); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join allowed with edit permission.')); $this->leaveProject($proj, $user); // If a user can join a project, they can join, even if they can't edit. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_USER); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->joinProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertTrue( $proj->isUserMember($user->getPHID()), pht('Join allowed with join permission.')); // A user can leave a project even if they can't edit it or join. $proj->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->setJoinPolicy(PhabricatorPolicies::POLICY_NOONE); $proj->save(); $proj = $this->refreshProject($proj, $user, true); $this->leaveProject($proj, $user); $proj = $this->refreshProject($proj, $user, true); $this->assertFalse( $proj->isUserMember($user->getPHID()), pht('Leave allowed without any permission.')); } public function testComplexConstraints() { $user = $this->createUser(); $user->save(); $engineering = $this->createProject($user); $engineering_scan = $this->createProject($user, $engineering); $engineering_warp = $this->createProject($user, $engineering); $exploration = $this->createProject($user); $exploration_diplomacy = $this->createProject($user, $exploration); $task_engineering = $this->newTask( $user, array($engineering), pht('Engineering Only')); $task_exploration = $this->newTask( $user, array($exploration), pht('Exploration Only')); $task_warp_explore = $this->newTask( $user, array($engineering_warp, $exploration), pht('Warp to New Planet')); $task_diplomacy_scan = $this->newTask( $user, array($engineering_scan, $exploration_diplomacy), pht('Scan Diplomat')); $task_diplomacy = $this->newTask( $user, array($exploration_diplomacy), pht('Diplomatic Meeting')); $task_warp_scan = $this->newTask( $user, array($engineering_scan, $engineering_warp), pht('Scan Warp Drives')); $this->assertQueryByProjects( $user, array( $task_engineering, $task_warp_explore, $task_diplomacy_scan, $task_warp_scan, ), array($engineering), pht('All Engineering')); $this->assertQueryByProjects( $user, array( $task_diplomacy_scan, $task_warp_scan, ), array($engineering_scan), pht('All Scan')); $this->assertQueryByProjects( $user, array( $task_warp_explore, $task_diplomacy_scan, ), array($engineering, $exploration), pht('Engineering + Exploration')); // This is testing that a query for "Parent" and "Parent > Child" works // properly. $this->assertQueryByProjects( $user, array( $task_diplomacy_scan, $task_warp_scan, ), array($engineering, $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)); } public function testBoardMoves() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $backlog = $this->addColumn($user, $board, 0); $column = $this->addColumn($user, $board, 1); // New tasks should appear in the backlog. $task1 = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task1); // Moving a task should move it to the destination column. $this->moveToColumn($user, $board, $task1, $backlog, $column); $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task1); // Same thing again, with a new task. $task2 = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task2); // Move it, too. $this->moveToColumn($user, $board, $task2, $backlog, $column); $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task2); // Now the stuff should be in the column, in order, with the more recently // moved task on top. $expect = array( $task2->getPHID(), $task1->getPHID(), ); $label = pht('Simple move'); $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task after the first task. $options = array( 'afterPHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task1->getPHID(), $task2->getPHID(), ); $label = pht('With afterPHIDs'); $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task before the first task. $options = array( 'beforePHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task2->getPHID(), $task1->getPHID(), ); $label = pht('With beforePHIDs'); $this->assertTasksInColumn($expect, $user, $board, $column, $label); } public function testMilestoneMoves() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $backlog = $this->addColumn($user, $board, 0); // Create a task into the backlog. $task = $this->newTask($user, array($board)); $expect = array( $backlog->getPHID(), ); $this->assertColumns($expect, $user, $board, $task); $milestone = $this->createProject($user, $board, true); $this->addProjectTags($user, $task, array($milestone->getPHID())); // We just want the side effect of looking at the board: creation of the // milestone column. $this->loadColumns($user, $board, $task); $column = id(new PhabricatorProjectColumnQuery()) ->setViewer($user) ->withProjectPHIDs(array($board->getPHID())) ->withProxyPHIDs(array($milestone->getPHID())) ->executeOne(); $this->assertTrue((bool)$column); // Moving the task to the milestone should have moved it to the milestone // column. $expect = array( $column->getPHID(), ); $this->assertColumns($expect, $user, $board, $task); // Move the task within the "Milestone" column. This should not affect // the projects the task is tagged with. See T10912. $task_a = $task; $task_b = $this->newTask($user, array($backlog)); $this->moveToColumn($user, $board, $task_b, $backlog, $column); $a_options = array( 'beforePHID' => $task_b->getPHID(), ); $b_options = array( 'beforePHID' => $task_a->getPHID(), ); $old_projects = $this->getTaskProjects($task); // Move the target task to the top. $this->moveToColumn($user, $board, $task_a, $column, $column, $a_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Move the other task. $this->moveToColumn($user, $board, $task_b, $column, $column, $b_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Move the target task again. $this->moveToColumn($user, $board, $task_a, $column, $column, $a_options); $new_projects = $this->getTaskProjects($task_a); $this->assertEqual($old_projects, $new_projects); // Add the parent project to the task. This should move it out of the // milestone column and into the parent's backlog. $this->addProjectTags($user, $task, array($board->getPHID())); $expect_columns = array( $backlog->getPHID(), ); $this->assertColumns($expect_columns, $user, $board, $task); $new_projects = $this->getTaskProjects($task); $expect_projects = array( $board->getPHID(), ); $this->assertEqual($expect_projects, $new_projects); } public function testColumnExtendedPolicies() { $user = $this->createUser(); $user->save(); $board = $this->createProject($user); $column = $this->addColumn($user, $board, 0); // At first, the user should be able to view and edit the column. $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertTrue($can_edit); // Now, set the project edit policy to "Members of Project". This should // disable editing. $members_policy = id(new PhabricatorProjectMembersPolicyRule()) ->getObjectPolicyFullKey(); $board->setEditPolicy($members_policy)->save(); $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertFalse($can_edit); // Now, join the project. This should make the column editable again. $this->joinProject($board, $user); $column = $this->refreshColumn($user, $column); $this->assertTrue((bool)$column); // This test has been failing randomly in a way that doesn't reproduce // on any host, so add some extra assertions to try to nail it down. $board = $this->refreshProject($board, $user, true); $this->assertTrue((bool)$board); $this->assertTrue($board->isUserMember($user->getPHID())); $can_view = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_VIEW); $this->assertTrue($can_view); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $column, PhabricatorPolicyCapability::CAN_EDIT); $this->assertTrue($can_edit); } public function testProjectPolicyRules() { $author = $this->generateNewTestUser(); $proj_a = PhabricatorProject::initializeNewProject($author) ->setName('Policy A') ->save(); $proj_b = PhabricatorProject::initializeNewProject($author) ->setName('Policy B') ->save(); $user_none = $this->generateNewTestUser(); $user_any = $this->generateNewTestUser(); $user_all = $this->generateNewTestUser(); $this->joinProject($proj_a, $user_any); $this->joinProject($proj_a, $user_all); $this->joinProject($proj_b, $user_all); $any_policy = id(new PhabricatorPolicy()) ->setRules( array( array( 'action' => PhabricatorPolicy::ACTION_ALLOW, 'rule' => 'PhabricatorProjectsPolicyRule', 'value' => array( $proj_a->getPHID(), $proj_b->getPHID(), ), ), )) ->save(); $all_policy = id(new PhabricatorPolicy()) ->setRules( array( array( 'action' => PhabricatorPolicy::ACTION_ALLOW, 'rule' => 'PhabricatorProjectsAllPolicyRule', 'value' => array( $proj_a->getPHID(), $proj_b->getPHID(), ), ), )) ->save(); $any_task = ManiphestTask::initializeNewTask($author) ->setViewPolicy($any_policy->getPHID()) ->save(); $all_task = ManiphestTask::initializeNewTask($author) ->setViewPolicy($all_policy->getPHID()) ->save(); $map = array( array( pht('Project policy rule; user in no projects'), $user_none, false, false, ), array( pht('Project policy rule; user in some projects'), $user_any, true, false, ), array( pht('Project policy rule; user in all projects'), $user_all, true, true, ), ); foreach ($map as $test_case) { list($label, $user, $expect_any, $expect_all) = $test_case; $can_any = PhabricatorPolicyFilter::hasCapability( $user, $any_task, PhabricatorPolicyCapability::CAN_VIEW); $can_all = PhabricatorPolicyFilter::hasCapability( $user, $all_task, PhabricatorPolicyCapability::CAN_VIEW); $this->assertEqual($expect_any, $can_any, pht('%s / Any', $label)); $this->assertEqual($expect_all, $can_all, pht('%s / All', $label)); } } private function moveToColumn( PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task, PhabricatorProjectColumn $src, PhabricatorProjectColumn $dst, $options = null) { $xactions = array(); if (!$options) { $options = array(); } $value = array( 'columnPHID' => $dst->getPHID(), ) + $options; $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COLUMNS) ->setNewValue(array($value)); $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); } private function assertColumns( array $expect, PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task) { $column_phids = $this->loadColumns($viewer, $board, $task); $this->assertEqual($expect, $column_phids); } private function loadColumns( PhabricatorUser $viewer, PhabricatorProject $board, ManiphestTask $task) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) ->setObjectPHIDs( array( $task->getPHID(), )) ->executeLayout(); $columns = $engine->getObjectColumns($board->getPHID(), $task->getPHID()); $column_phids = mpull($columns, 'getPHID'); $column_phids = array_values($column_phids); return $column_phids; } private function assertTasksInColumn( array $expect, PhabricatorUser $viewer, PhabricatorProject $board, PhabricatorProjectColumn $column, $label = null) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) ->setBoardPHIDs(array($board->getPHID())) ->setObjectPHIDs($expect) ->executeLayout(); $object_phids = $engine->getColumnObjectPHIDs( $board->getPHID(), $column->getPHID()); $object_phids = array_values($object_phids); $this->assertEqual($expect, $object_phids, $label); } private function addColumn( PhabricatorUser $viewer, PhabricatorProject $project, $sequence) { $project->setHasWorkboard(1)->save(); return PhabricatorProjectColumn::initializeNewColumn($viewer) ->setSequence(0) ->setProperty('isDefault', ($sequence == 0)) ->setProjectPHID($project->getPHID()) ->save(); } private function getTaskProjects(ManiphestTask $task) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $task->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); sort($project_phids); return $project_phids; } private function attemptProjectEdit( PhabricatorProject $proj, PhabricatorUser $user, $skip_refresh = false) { $proj = $this->refreshProject($proj, $user, true); $new_name = $proj->getName().' '.mt_rand(); $params = array( 'objectIdentifier' => $proj->getID(), 'transactions' => array( array( 'type' => 'name', 'value' => $new_name, ), ), ); id(new ConduitCall('project.edit', $params)) ->setUser($user) ->execute(); return true; } 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($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); } private function newTask( PhabricatorUser $viewer, array $projects, $name = null) { $task = ManiphestTask::initializeNewTask($viewer); if (!strlen($name)) { $name = pht('Test Task'); } $xactions = array(); $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTaskTitleTransaction::TRANSACTIONTYPE) ->setNewValue($name); if ($projects) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setNewValue( array( '=' => array_fuse(mpull($projects, 'getPHID')), )); } $editor = id(new ManiphestTransactionEditor()) ->setActor($viewer) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($task, $xactions); return $task; } private function assertQueryByProjects( PhabricatorUser $viewer, array $expect, array $projects, $label = null) { $datasource = id(new PhabricatorProjectLogicalDatasource()) ->setViewer($viewer); $project_phids = mpull($projects, 'getPHID'); $constraints = $datasource->evaluateTokens($project_phids); $query = id(new ManiphestTaskQuery()) ->setViewer($viewer); $query->withEdgeLogicConstraints( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, $constraints); $tasks = $query->execute(); $expect_phids = mpull($expect, 'getTitle', 'getPHID'); ksort($expect_phids); $actual_phids = mpull($tasks, 'getTitle', 'getPHID'); ksort($actual_phids); $this->assertEqual($expect_phids, $actual_phids, $label); } private function refreshProject( PhabricatorProject $project, PhabricatorUser $viewer, $need_members = false, $need_watchers = false) { $results = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->needMembers($need_members) ->needWatchers($need_watchers) ->withIDs(array($project->getID())) ->execute(); if ($results) { return head($results); } else { return null; } } private function refreshColumn( PhabricatorUser $viewer, PhabricatorProjectColumn $column) { $results = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withIDs(array($column->getID())) ->execute(); if ($results) { return head($results); } else { return null; } } private function createProject( PhabricatorUser $user, PhabricatorProject $parent = null, $is_milestone = false) { - $project = PhabricatorProject::initializeNewProject($user); - + $project = PhabricatorProject::initializeNewProject($user, $parent); $name = pht('Test Project %d', mt_rand()); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectNameTransaction::TRANSACTIONTYPE) ->setNewValue($name); if ($parent) { if ($is_milestone) { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectMilestoneTransaction::TRANSACTIONTYPE) ->setNewValue($parent->getPHID()); } else { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType( PhabricatorProjectParentTransaction::TRANSACTIONTYPE) ->setNewValue($parent->getPHID()); } } $this->applyTransactions($project, $user, $xactions); // Force these values immediately; they are normally updated by the // index engine. if ($parent) { if ($is_milestone) { $parent->setHasMilestones(1)->save(); } else { $parent->setHasSubprojects(1)->save(); } } return $project; } private function setViewPolicy( PhabricatorProject $project, PhabricatorUser $user, $policy) { $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) ->setNewValue($policy); $this->applyTransactions($project, $user, $xactions); return $project; } private function createProjectWithNewAuthor() { $author = $this->createUser(); $author->save(); $project = $this->createProject($author); return $project; } private function createUser() { $rand = mt_rand(); $user = new PhabricatorUser(); $user->setUsername('unittestuser'.$rand); $user->setRealName(pht('Unit Test User %d', $rand)); return $user; } private function joinProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->joinOrLeaveProject($project, $user, '+'); } private function leaveProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->joinOrLeaveProject($project, $user, '-'); } private function watchProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->watchOrUnwatchProject($project, $user, '+'); } private function unwatchProject( PhabricatorProject $project, PhabricatorUser $user) { return $this->watchOrUnwatchProject($project, $user, '-'); } private function joinOrLeaveProject( PhabricatorProject $project, PhabricatorUser $user, $operation) { return $this->applyProjectEdgeTransaction( $project, $user, $operation, PhabricatorProjectProjectHasMemberEdgeType::EDGECONST); } private function watchOrUnwatchProject( PhabricatorProject $project, PhabricatorUser $user, $operation) { return $this->applyProjectEdgeTransaction( $project, $user, $operation, PhabricatorObjectHasWatcherEdgeType::EDGECONST); } private function applyProjectEdgeTransaction( PhabricatorProject $project, PhabricatorUser $user, $operation, $edge_type) { $spec = array( $operation => array($user->getPHID() => $user->getPHID()), ); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue($spec); $this->applyTransactions($project, $user, $xactions); return $project; } private function applyTransactions( PhabricatorProject $project, PhabricatorUser $user, array $xactions) { $editor = id(new PhabricatorProjectTransactionEditor()) ->setActor($user) ->setContentSource($this->newContentSource()) ->setContinueOnNoEffect(true) ->applyTransactions($project, $xactions); } } diff --git a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php index b714f66830..729674e120 100644 --- a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php @@ -1,429 +1,438 @@ isMilestone = $is_milestone; return $this; } public function getIsMilestone() { return $this->isMilestone; } public function getEditorApplicationClass() { return 'PhabricatorProjectApplication'; } public function getEditorObjectsDescription() { return pht('Projects'); } public function getCreateObjectTitle($author, $object) { return pht('%s created this project.', $author); } public function getCreateObjectTitleForFeed($author, $object) { return pht('%s created %s.', $author, $object); } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = PhabricatorTransactions::TYPE_JOIN_POLICY; return $types; } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { $errors = array(); // Prevent creating projects which are both subprojects and milestones, // since this does not make sense, won't work, and will break everything. $parent_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectParentTransaction::TRANSACTIONTYPE: case PhabricatorProjectMilestoneTransaction::TRANSACTIONTYPE: if ($xaction->getNewValue() === null) { continue 2; } if (!$parent_xaction) { $parent_xaction = $xaction; continue 2; } $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'When creating a project, specify a maximum of one parent '. 'project or milestone project. A project can not be both a '. 'subproject and a milestone.'), $xaction); break 2; } } $is_milestone = $this->getIsMilestone(); $is_parent = $object->getHasSubprojects(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: $type = $xaction->getMetadataValue('edge:type'); if ($type != PhabricatorProjectProjectHasMemberEdgeType::EDGECONST) { break; } if ($is_parent) { $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'You can not change members of a project with subprojects '. 'directly. Members of any subproject are automatically '. 'members of the parent project.'), $xaction); } if ($is_milestone) { $errors[] = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('Invalid'), pht( 'You can not change members of a milestone. Members of the '. 'parent project are automatically members of the milestone.'), $xaction); } break; } } return $errors; } protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { // NOTE: We're using the omnipotent user here because the original actor // may no longer have permission to view the object. return id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($object->getPHID())) ->needAncestorMembers(true) ->executeOne(); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return pht('[Project]'); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $this->getActingAsPHID(), ); } protected function getMailCc(PhabricatorLiskDAO $object) { return array(); } public function getMailTagsMap() { return array( PhabricatorProjectTransaction::MAILTAG_METADATA => pht('Project name, hashtags, icon, image, or color changes.'), PhabricatorProjectTransaction::MAILTAG_MEMBERS => pht('Project membership changes.'), PhabricatorProjectTransaction::MAILTAG_WATCHERS => pht('Project watcher list changes.'), PhabricatorProjectTransaction::MAILTAG_OTHER => pht('Other project activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ProjectReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $name = $object->getName(); return id(new PhabricatorMetaMTAMail()) ->setSubject("{$name}"); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $uri = '/project/profile/'.$object->getID().'/'; $body->addLinkSection( pht('PROJECT DETAIL'), PhabricatorEnv::getProductionURI($uri)); return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function supportsSearch() { return true; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { $materialize = false; $new_parent = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $materialize = true; break; } break; case PhabricatorProjectParentTransaction::TRANSACTIONTYPE: case PhabricatorProjectMilestoneTransaction::TRANSACTIONTYPE: $materialize = true; $new_parent = $object->getParentProject(); break; } } if ($new_parent) { // If we just created the first subproject of this parent, we want to // copy all of the real members to the subproject. if (!$new_parent->getHasSubprojects()) { $member_type = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $project_members = PhabricatorEdgeQuery::loadDestinationPHIDs( $new_parent->getPHID(), $member_type); if ($project_members) { $editor = id(new PhabricatorEdgeEditor()); foreach ($project_members as $phid) { $editor->addEdge($object->getPHID(), $member_type, $phid); } $editor->save(); } } } // TODO: We should dump an informational transaction onto the parent // project to show that we created the sub-thing. if ($materialize) { id(new PhabricatorProjectsMembershipIndexEngineExtension()) ->rematerialize($object); } if ($new_parent) { id(new PhabricatorProjectsMembershipIndexEngineExtension()) ->rematerialize($new_parent); } // See PHI1046. Milestones are always in the Space of their parent project. // Synchronize the database values to match the application values. $conn = $object->establishConnection('w'); queryfx( $conn, 'UPDATE %R SET spacePHID = %ns WHERE parentProjectPHID = %s AND milestoneNumber IS NOT NULL', $object, $object->getSpacePHID(), $object->getPHID()); return parent::applyFinalEffects($object, $xactions); } public function addSlug(PhabricatorProject $project, $slug, $force) { $slug = PhabricatorSlug::normalizeProjectSlug($slug); $table = new PhabricatorProjectSlug(); $project_phid = $project->getPHID(); if ($force) { // If we have the `$force` flag set, we only want to ignore an existing // slug if it's for the same project. We'll error on collisions with // other projects. $current = $table->loadOneWhere( 'slug = %s AND projectPHID = %s', $slug, $project_phid); } else { // Without the `$force` flag, we'll just return without doing anything // if any other project already has the slug. $current = $table->loadOneWhere( 'slug = %s', $slug); } if ($current) { return; } return id(new PhabricatorProjectSlug()) ->setSlug($slug) ->setProjectPHID($project_phid) ->save(); } public function removeSlugs(PhabricatorProject $project, array $slugs) { if (!$slugs) { return; } // We're going to try to delete both the literal and normalized versions // of all slugs. This allows us to destroy old slugs that are no longer // valid. foreach ($this->normalizeSlugs($slugs) as $slug) { $slugs[] = $slug; } $objects = id(new PhabricatorProjectSlug())->loadAllWhere( 'projectPHID = %s AND slug IN (%Ls)', $project->getPHID(), $slugs); foreach ($objects as $object) { $object->delete(); } } public function normalizeSlugs(array $slugs) { foreach ($slugs as $key => $slug) { $slugs[$key] = PhabricatorSlug::normalizeProjectSlug($slug); } $slugs = array_unique($slugs); $slugs = array_values($slugs); return $slugs; } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); $type_edge = PhabricatorTransactions::TYPE_EDGE; $edgetype_member = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; + // See T13462. If we're creating a milestone, set a dummy milestone + // number so the project behaves like a milestone and uses milestone + // policy rules. Otherwise, we'll end up checking the default policies + // (which are not relevant to milestones) instead of the parent project + // policies (which are the correct policies). + if ($this->getIsMilestone() && !$copy->isMilestone()) { + $copy->setMilestoneNumber(1); + } + $member_xaction = null; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() !== $type_edge) { continue; } $edgetype = $xaction->getMetadataValue('edge:type'); if ($edgetype !== $edgetype_member) { continue; } $member_xaction = $xaction; } if ($member_xaction) { $object_phid = $object->getPHID(); if ($object_phid) { $project = id(new PhabricatorProjectQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($object_phid)) ->needMembers(true) ->executeOne(); $members = $project->getMemberPHIDs(); } else { $members = array(); } $clone_xaction = clone $member_xaction; $hint = $this->getPHIDTransactionNewValue($clone_xaction, $members); $rule = new PhabricatorProjectMembersPolicyRule(); $hint = array_fuse($hint); PhabricatorPolicyRule::passTransactionHintToRule( $copy, $rule, $hint); } return $copy; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $results = parent::expandTransactions($object, $xactions); $is_milestone = $object->isMilestone(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectMilestoneTransaction::TRANSACTIONTYPE: if ($xaction->getNewValue() !== null) { $is_milestone = true; } break; } } $this->setIsMilestone($is_milestone); return $results; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { // Herald rules may run on behalf of other users and need to execute // membership checks against ancestors. $project = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($object->getPHID())) ->needAncestorMembers(true) ->executeOne(); return id(new PhabricatorProjectHeraldAdapter()) ->setProject($project); } } diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index 765c8a6b43..860d6e1749 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -1,918 +1,918 @@ setViewer(PhabricatorUser::getOmnipotentUser()) ->withClasses(array('PhabricatorProjectApplication')) ->executeOne(); $view_policy = $app->getPolicy( ProjectDefaultViewCapability::CAPABILITY); $edit_policy = $app->getPolicy( ProjectDefaultEditCapability::CAPABILITY); $join_policy = $app->getPolicy( ProjectDefaultJoinCapability::CAPABILITY); // If this is the child of some other project, default the Space to the // Space of the parent. if ($parent) { $space_phid = $parent->getSpacePHID(); } else { $space_phid = $actor->getDefaultSpacePHID(); } $default_icon = PhabricatorProjectIconSet::getDefaultIconKey(); $default_color = PhabricatorProjectIconSet::getDefaultColorKey(); return id(new PhabricatorProject()) ->setAuthorPHID($actor->getPHID()) ->setIcon($default_icon) ->setColor($default_color) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setJoinPolicy($join_policy) ->setSpacePHID($space_phid) ->setIsMembershipLocked(0) ->attachMemberPHIDs(array()) ->attachSlugs(array()) ->setHasWorkboard(0) ->setHasMilestones(0) ->setHasSubprojects(0) ->setSubtype(PhabricatorEditEngineSubtype::SUBTYPE_DEFAULT) - ->attachParentProject(null); + ->attachParentProject($parent); } public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, PhabricatorPolicyCapability::CAN_JOIN, ); } public function getPolicy($capability) { if ($this->isMilestone()) { return $this->getParentProject()->getPolicy($capability); } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); case PhabricatorPolicyCapability::CAN_JOIN: return $this->getJoinPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->isMilestone()) { return $this->getParentProject()->hasAutomaticCapability( $capability, $viewer); } $can_edit = PhabricatorPolicyCapability::CAN_EDIT; switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if ($this->isUserMember($viewer->getPHID())) { // Project members can always view a project. return true; } break; case PhabricatorPolicyCapability::CAN_EDIT: $parent = $this->getParentProject(); if ($parent) { $can_edit_parent = PhabricatorPolicyFilter::hasCapability( $viewer, $parent, $can_edit); if ($can_edit_parent) { return true; } } break; case PhabricatorPolicyCapability::CAN_JOIN: if (PhabricatorPolicyFilter::hasCapability($viewer, $this, $can_edit)) { // Project editors can always join a project. return true; } break; } return false; } public function describeAutomaticCapability($capability) { // TODO: Clarify the additional rules that parent and subprojects imply. switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Members of a project can always view it.'); case PhabricatorPolicyCapability::CAN_JOIN: return pht('Users who can edit a project can always join it.'); } return null; } public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $parent = $this->getParentProject(); if ($parent) { $extended[] = array( $parent, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } public function isUserMember($user_phid) { if ($this->memberPHIDs !== self::ATTACHABLE) { return in_array($user_phid, $this->memberPHIDs); } return $this->assertAttachedKey($this->sparseMembers, $user_phid); } public function setIsUserMember($user_phid, $is_member) { if ($this->sparseMembers === self::ATTACHABLE) { $this->sparseMembers = array(); } $this->sparseMembers[$user_phid] = $is_member; return $this; } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort128', 'status' => 'text32', 'primarySlug' => 'text128?', 'isMembershipLocked' => 'bool', 'profileImagePHID' => 'phid?', 'icon' => 'text32', 'color' => 'text32', 'mailKey' => 'bytes20', 'joinPolicy' => 'policy', 'parentProjectPHID' => 'phid?', 'hasWorkboard' => 'bool', 'hasMilestones' => 'bool', 'hasSubprojects' => 'bool', 'milestoneNumber' => 'uint32?', 'projectPath' => 'hashpath64', 'projectDepth' => 'uint32', 'projectPathKey' => 'bytes4', 'subtype' => 'text64', ), self::CONFIG_KEY_SCHEMA => array( 'key_icon' => array( 'columns' => array('icon'), ), 'key_color' => array( 'columns' => array('color'), ), 'key_milestone' => array( 'columns' => array('parentProjectPHID', 'milestoneNumber'), 'unique' => true, ), 'key_primaryslug' => array( 'columns' => array('primarySlug'), 'unique' => true, ), 'key_path' => array( 'columns' => array('projectPath', 'projectDepth'), ), 'key_pathkey' => array( 'columns' => array('projectPathKey'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorProjectProjectPHIDType::TYPECONST); } public function attachMemberPHIDs(array $phids) { $this->memberPHIDs = $phids; return $this; } public function getMemberPHIDs() { return $this->assertAttached($this->memberPHIDs); } public function isArchived() { return ($this->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED); } public function getProfileImageURI() { return $this->getProfileImageFile()->getBestURI(); } public function attachProfileImageFile(PhabricatorFile $file) { $this->profileImageFile = $file; return $this; } public function getProfileImageFile() { return $this->assertAttached($this->profileImageFile); } public function isUserWatcher($user_phid) { if ($this->watcherPHIDs !== self::ATTACHABLE) { return in_array($user_phid, $this->watcherPHIDs); } return $this->assertAttachedKey($this->sparseWatchers, $user_phid); } public function isUserAncestorWatcher($user_phid) { $is_watcher = $this->isUserWatcher($user_phid); if (!$is_watcher) { $parent = $this->getParentProject(); if ($parent) { return $parent->isUserWatcher($user_phid); } } return $is_watcher; } public function getWatchedAncestorPHID($user_phid) { if ($this->isUserWatcher($user_phid)) { return $this->getPHID(); } $parent = $this->getParentProject(); if ($parent) { return $parent->getWatchedAncestorPHID($user_phid); } return null; } public function setIsUserWatcher($user_phid, $is_watcher) { if ($this->sparseWatchers === self::ATTACHABLE) { $this->sparseWatchers = array(); } $this->sparseWatchers[$user_phid] = $is_watcher; return $this; } public function attachWatcherPHIDs(array $phids) { $this->watcherPHIDs = $phids; return $this; } public function getWatcherPHIDs() { return $this->assertAttached($this->watcherPHIDs); } public function getAllAncestorWatcherPHIDs() { $parent = $this->getParentProject(); if ($parent) { $watchers = $parent->getAllAncestorWatcherPHIDs(); } else { $watchers = array(); } foreach ($this->getWatcherPHIDs() as $phid) { $watchers[$phid] = $phid; } return $watchers; } public function attachSlugs(array $slugs) { $this->slugs = $slugs; return $this; } public function getSlugs() { return $this->assertAttached($this->slugs); } public function getColor() { if ($this->isArchived()) { return PHUITagView::COLOR_DISABLED; } return $this->color; } public function getURI() { $id = $this->getID(); return "/project/view/{$id}/"; } public function getProfileURI() { $id = $this->getID(); return "/project/profile/{$id}/"; } public function getWorkboardURI() { return urisprintf('/project/board/%d/', $this->getID()); } public function getReportsURI() { return urisprintf('/project/reports/%d/', $this->getID()); } public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); } if (!strlen($this->getPHID())) { $this->setPHID($this->generatePHID()); } if (!strlen($this->getProjectPathKey())) { $hash = PhabricatorHash::digestForIndex($this->getPHID()); $hash = substr($hash, 0, 4); $this->setProjectPathKey($hash); } $path = array(); $depth = 0; if ($this->parentProjectPHID) { $parent = $this->getParentProject(); $path[] = $parent->getProjectPath(); $depth = $parent->getProjectDepth() + 1; } $path[] = $this->getProjectPathKey(); $path = implode('', $path); $limit = self::getProjectDepthLimit(); if ($depth >= $limit) { throw new Exception(pht('Project depth is too great.')); } $this->setProjectPath($path); $this->setProjectDepth($depth); $this->openTransaction(); $result = parent::save(); $this->updateDatasourceTokens(); $this->saveTransaction(); return $result; } public static function getProjectDepthLimit() { // This is limited by how many path hashes we can fit in the path // column. return 16; } public function updateDatasourceTokens() { $table = self::TABLE_DATASOURCE_TOKEN; $conn_w = $this->establishConnection('w'); $id = $this->getID(); $slugs = queryfx_all( $conn_w, 'SELECT * FROM %T WHERE projectPHID = %s', id(new PhabricatorProjectSlug())->getTableName(), $this->getPHID()); $all_strings = ipull($slugs, 'slug'); $all_strings[] = $this->getDisplayName(); $all_strings = implode(' ', $all_strings); $tokens = PhabricatorTypeaheadDatasource::tokenizeString($all_strings); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf($conn_w, '(%d, %s)', $id, $token); } $this->openTransaction(); queryfx( $conn_w, 'DELETE FROM %T WHERE projectID = %d', $table, $id); foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) { queryfx( $conn_w, 'INSERT INTO %T (projectID, token) VALUES %LQ', $table, $chunk); } $this->saveTransaction(); } public function isMilestone() { return ($this->getMilestoneNumber() !== null); } public function getParentProject() { return $this->assertAttached($this->parentProject); } public function attachParentProject(PhabricatorProject $project = null) { $this->parentProject = $project; return $this; } public function getAncestorProjectPaths() { $parts = array(); $path = $this->getProjectPath(); $parent_length = (strlen($path) - 4); for ($ii = $parent_length; $ii > 0; $ii -= 4) { $parts[] = substr($path, 0, $ii); } return $parts; } public function getAncestorProjects() { $ancestors = array(); $cursor = $this->getParentProject(); while ($cursor) { $ancestors[] = $cursor; $cursor = $cursor->getParentProject(); } return $ancestors; } public function supportsEditMembers() { if ($this->isMilestone()) { return false; } if ($this->getHasSubprojects()) { return false; } return true; } public function supportsMilestones() { if ($this->isMilestone()) { return false; } return true; } public function supportsSubprojects() { if ($this->isMilestone()) { return false; } return true; } public function loadNextMilestoneNumber() { $current = queryfx_one( $this->establishConnection('w'), 'SELECT MAX(milestoneNumber) n FROM %T WHERE parentProjectPHID = %s', $this->getTableName(), $this->getPHID()); if (!$current) { $number = 1; } else { $number = (int)$current['n'] + 1; } return $number; } public function getDisplayName() { $name = $this->getName(); // If this is a milestone, show it as "Parent > Sprint 99". if ($this->isMilestone()) { $name = pht( '%s (%s)', $this->getParentProject()->getName(), $name); } return $name; } public function getDisplayIconKey() { if ($this->isMilestone()) { $key = PhabricatorProjectIconSet::getMilestoneIconKey(); } else { $key = $this->getIcon(); } return $key; } public function getDisplayIconIcon() { $key = $this->getDisplayIconKey(); return PhabricatorProjectIconSet::getIconIcon($key); } public function getDisplayIconName() { $key = $this->getDisplayIconKey(); return PhabricatorProjectIconSet::getIconName($key); } public function getDisplayColor() { if ($this->isMilestone()) { return $this->getParentProject()->getColor(); } return $this->getColor(); } public function getDisplayIconComposeIcon() { $icon = $this->getDisplayIconIcon(); return $icon; } public function getDisplayIconComposeColor() { $color = $this->getDisplayColor(); $map = array( 'grey' => 'charcoal', 'checkered' => 'backdrop', ); return idx($map, $color, $color); } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getDefaultWorkboardSort() { return $this->getProperty('workboard.sort.default'); } public function setDefaultWorkboardSort($sort) { return $this->setProperty('workboard.sort.default', $sort); } public function getDefaultWorkboardFilter() { return $this->getProperty('workboard.filter.default'); } public function setDefaultWorkboardFilter($filter) { return $this->setProperty('workboard.filter.default', $filter); } public function getWorkboardBackgroundColor() { return $this->getProperty('workboard.background'); } public function setWorkboardBackgroundColor($color) { return $this->setProperty('workboard.background', $color); } public function getDisplayWorkboardBackgroundColor() { $color = $this->getWorkboardBackgroundColor(); if ($color === null) { $parent = $this->getParentProject(); if ($parent) { return $parent->getDisplayWorkboardBackgroundColor(); } } if ($color === 'none') { $color = null; } return $color; } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('projects.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorProjectCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorProjectTransactionEditor(); } public function getApplicationTransactionTemplate() { return new PhabricatorProjectTransaction(); } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { if ($this->isMilestone()) { return $this->getParentProject()->getSpacePHID(); } return $this->spacePHID; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $columns = id(new PhabricatorProjectColumn()) ->loadAllWhere('projectPHID = %s', $this->getPHID()); foreach ($columns as $column) { $engine->destroyObject($column); } $slugs = id(new PhabricatorProjectSlug()) ->loadAllWhere('projectPHID = %s', $this->getPHID()); foreach ($slugs as $slug) { $slug->delete(); } $this->saveTransaction(); } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorProjectFulltextEngine(); } /* -( PhabricatorFerretInterface )--------------------------------------- */ public function newFerretEngine() { return new PhabricatorProjectFerretEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('slug') ->setType('string') ->setDescription(pht('Primary slug/hashtag.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('subtype') ->setType('string') ->setDescription(pht('Subtype of the project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('milestone') ->setType('int?') ->setDescription(pht('For milestones, milestone sequence number.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('parent') ->setType('map?') ->setDescription( pht( 'For subprojects and milestones, a brief description of the '. 'parent project.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('depth') ->setType('int') ->setDescription( pht( 'For subprojects and milestones, depth of this project in the '. 'tree. Root projects have depth 0.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('icon') ->setType('map') ->setDescription(pht('Information about the project icon.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('color') ->setType('map') ->setDescription(pht('Information about the project color.')), ); } public function getFieldValuesForConduit() { $color_key = $this->getColor(); $color_name = PhabricatorProjectIconSet::getColorName($color_key); if ($this->isMilestone()) { $milestone = (int)$this->getMilestoneNumber(); } else { $milestone = null; } $parent = $this->getParentProject(); if ($parent) { $parent_ref = $parent->getRefForConduit(); } else { $parent_ref = null; } return array( 'name' => $this->getName(), 'slug' => $this->getPrimarySlug(), 'subtype' => $this->getSubtype(), 'milestone' => $milestone, 'depth' => (int)$this->getProjectDepth(), 'parent' => $parent_ref, 'icon' => array( 'key' => $this->getDisplayIconKey(), 'name' => $this->getDisplayIconName(), 'icon' => $this->getDisplayIconIcon(), ), 'color' => array( 'key' => $color_key, 'name' => $color_name, ), ); } public function getConduitSearchAttachments() { return array( id(new PhabricatorProjectsMembersSearchEngineAttachment()) ->setAttachmentKey('members'), id(new PhabricatorProjectsWatchersSearchEngineAttachment()) ->setAttachmentKey('watchers'), id(new PhabricatorProjectsAncestorsSearchEngineAttachment()) ->setAttachmentKey('ancestors'), ); } /** * Get an abbreviated representation of this project for use in providing * "parent" and "ancestor" information. */ public function getRefForConduit() { return array( 'id' => (int)$this->getID(), 'phid' => $this->getPHID(), 'name' => $this->getName(), ); } /* -( PhabricatorColumnProxyInterface )------------------------------------ */ public function getProxyColumnName() { return $this->getName(); } public function getProxyColumnIcon() { return $this->getDisplayIconIcon(); } public function getProxyColumnClass() { if ($this->isMilestone()) { return 'phui-workboard-column-milestone'; } return null; } /* -( PhabricatorEditEngineSubtypeInterface )------------------------------ */ public function getEditEngineSubtype() { return $this->getSubtype(); } public function setEditEngineSubtype($value) { return $this->setSubtype($value); } public function newEditEngineSubtypeMap() { $config = PhabricatorEnv::getEnvConfig('projects.subtypes'); return PhabricatorEditEngineSubtype::newSubtypeMap($config) ->setDatasource(new PhabricatorProjectSubtypeDatasource()); } public function newSubtypeObject() { $subtype_key = $this->getEditEngineSubtype(); $subtype_map = $this->newEditEngineSubtypeMap(); return $subtype_map->getSubtype($subtype_key); } }