diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index 36c78386f3..3bd38353f6 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1,1033 +1,1066 @@ 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 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(); $user2 = $this->createUser(); $user2->save(); $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(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); $this->applyTransactions($project, $user, $xactions); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->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(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name2); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->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(PhabricatorProjectTransaction::TYPE_SLUGS) ->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(PhabricatorProjectTransaction::TYPE_SLUGS) ->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(PhabricatorProjectTransaction::TYPE_SLUGS) ->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(PhabricatorProjectTransaction::TYPE_NAME) ->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(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_SLUGS) ->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); } - private function attemptProjectEdit( - PhabricatorProject $proj, - PhabricatorUser $user, - $skip_refresh = false) { - - $proj = $this->refreshProject($proj, $user, true); - - $new_name = $proj->getName().' '.mt_rand(); - - $xaction = new PhabricatorProjectTransaction(); - $xaction->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME); - $xaction->setNewValue($new_name); - - $this->applyTransactions($proj, $user, array($xaction)); - - return true; - } - 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')); } + private function attemptProjectEdit( + PhabricatorProject $proj, + PhabricatorUser $user, + $skip_refresh = false) { + + $proj = $this->refreshProject($proj, $user, true); + + $new_name = $proj->getName().' '.mt_rand(); + + $xaction = new PhabricatorProjectTransaction(); + $xaction->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME); + $xaction->setNewValue($new_name); + + $this->applyTransactions($proj, $user, array($xaction)); + + return true; + } + + 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(ManiphestTransaction::TYPE_TITLE) ->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(PhabricatorContentSource::newConsoleSource()) ->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 createProject( PhabricatorUser $user, PhabricatorProject $parent = null, $is_milestone = false) { $project = PhabricatorProject::initializeNewProject($user); $name = pht('Test Project %d', mt_rand()); $xactions = array(); $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_NAME) ->setNewValue($name); if ($parent) { if ($is_milestone) { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_MILESTONE) ->setNewValue($parent->getPHID()); } else { $xactions[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorProjectTransaction::TYPE_PARENT) ->setNewValue($parent->getPHID()); } } $this->applyTransactions($project, $user, $xactions); 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(PhabricatorContentSource::newConsoleSource()) ->setContinueOnNoEffect(true) ->applyTransactions($project, $xactions); } } diff --git a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php index 26cc3af86e..6db9b774e6 100644 --- a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php @@ -1,920 +1,920 @@ isMilestone = $is_milestone; return $this; } private function getIsMilestone() { return $this->isMilestone; } public function getEditorApplicationClass() { return 'PhabricatorProjectApplication'; } public function getEditorObjectsDescription() { return pht('Projects'); } 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; $types[] = PhabricatorProjectTransaction::TYPE_NAME; $types[] = PhabricatorProjectTransaction::TYPE_SLUGS; $types[] = PhabricatorProjectTransaction::TYPE_STATUS; $types[] = PhabricatorProjectTransaction::TYPE_IMAGE; $types[] = PhabricatorProjectTransaction::TYPE_ICON; $types[] = PhabricatorProjectTransaction::TYPE_COLOR; $types[] = PhabricatorProjectTransaction::TYPE_LOCKED; $types[] = PhabricatorProjectTransaction::TYPE_PARENT; $types[] = PhabricatorProjectTransaction::TYPE_MILESTONE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: return $object->getName(); case PhabricatorProjectTransaction::TYPE_SLUGS: $slugs = $object->getSlugs(); $slugs = mpull($slugs, 'getSlug', 'getSlug'); unset($slugs[$object->getPrimarySlug()]); return array_keys($slugs); case PhabricatorProjectTransaction::TYPE_STATUS: return $object->getStatus(); case PhabricatorProjectTransaction::TYPE_IMAGE: return $object->getProfileImagePHID(); case PhabricatorProjectTransaction::TYPE_ICON: return $object->getIcon(); case PhabricatorProjectTransaction::TYPE_COLOR: return $object->getColor(); case PhabricatorProjectTransaction::TYPE_LOCKED: return (int)$object->getIsMembershipLocked(); case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: case PhabricatorProjectTransaction::TYPE_LOCKED: case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return $xaction->getNewValue(); case PhabricatorProjectTransaction::TYPE_SLUGS: return $this->normalizeSlugs($xaction->getNewValue()); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: $name = $xaction->getNewValue(); $object->setName($name); if (!$this->getIsMilestone()) { $object->setPrimarySlug(PhabricatorSlug::normalizeProjectSlug($name)); } return; case PhabricatorProjectTransaction::TYPE_SLUGS: return; case PhabricatorProjectTransaction::TYPE_STATUS: $object->setStatus($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_IMAGE: $object->setProfileImagePHID($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_ICON: $object->setIcon($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_COLOR: $object->setColor($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_LOCKED: $object->setIsMembershipLocked($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_PARENT: $object->setParentProjectPHID($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_MILESTONE: $number = $object->getParentProject()->loadNextMilestoneNumber(); $object->setMilestoneNumber($number); $object->setParentProjectPHID($xaction->getNewValue()); return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: // First, add the old name as a secondary slug; this is helpful // for renames and generally a good thing to do. if ($old !== null) { $this->addSlug($object, $old, false); } $this->addSlug($object, $new, false); return; case PhabricatorProjectTransaction::TYPE_SLUGS: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_diff($new, $old); $rem = array_diff($old, $new); foreach ($add as $slug) { $this->addSlug($object, $slug, true); } $this->removeSlugs($object, $rem); return; case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: case PhabricatorProjectTransaction::TYPE_LOCKED: case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: $edge_type = $xaction->getMetadataValue('edge:type'); switch ($edge_type) { case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: case PhabricatorObjectHasWatcherEdgeType::EDGECONST: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); // When adding members or watchers, we add subscriptions. $add = array_keys(array_diff_key($new, $old)); // When removing members, we remove their subscription too. // When unwatching, we leave subscriptions, since it's fine to be // subscribed to a project but not be a member of it. $edge_const = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; if ($edge_type == $edge_const) { $rem = array_keys(array_diff_key($old, $new)); } else { $rem = array(); } // NOTE: The subscribe is "explicit" because there's no implicit // unsubscribe, so Join -> Leave -> Join doesn't resubscribe you // if we use an implicit subscribe, even though you never willfully // unsubscribed. Not sure if adding implicit unsubscribe (which // would not write the unsubscribe row) is justified to deal with // this, which is a fairly weird edge case and pretty arguable both // ways. // Subscriptions caused by watches should also clearly be explicit, // and that case is unambiguous. id(new PhabricatorSubscriptionsEditor()) ->setActor($this->requireActor()) ->setObject($object) ->subscribeExplicit($add) ->unsubscribe($rem) ->save(); if ($rem) { // When removing members, also remove any watches on the project. $edge_editor = new PhabricatorEdgeEditor(); foreach ($rem as $rem_phid) { $edge_editor->removeEdge( $object->getPHID(), PhabricatorObjectHasWatcherEdgeType::EDGECONST, $rem_phid); } $edge_editor->save(); } break; } break; } return parent::applyBuiltinExternalTransaction($object, $xaction); } 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 PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: if ($xaction->getNewValue() === null) { continue; } if (!$parent_xaction) { $parent_xaction = $xaction; continue; } $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; break; } } $is_milestone = $this->getIsMilestone(); $is_parent = $object->getHasSubprojects(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_MEMBERS: 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 validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case PhabricatorProjectTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Project name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } if (!$xactions) { break; } if ($this->getIsMilestone()) { break; } $name = last($xactions)->getNewValue(); if (!PhabricatorSlug::isValidProjectSlug($name)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Project names must contain at least one letter or number.'), last($xactions)); break; } $slug = PhabricatorSlug::normalizeProjectSlug($name); $slug_used_already = id(new PhabricatorProjectSlug()) ->loadOneWhere('slug = %s', $slug); if ($slug_used_already && $slug_used_already->getProjectPHID() != $object->getPHID()) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Duplicate'), pht( 'Project name generates the same hashtag ("%s") as another '. 'existing project. Choose a unique name.', '#'.$slug), nonempty(last($xactions), null)); $errors[] = $error; } break; case PhabricatorProjectTransaction::TYPE_SLUGS: if (!$xactions) { break; } $slug_xaction = last($xactions); $new = $slug_xaction->getNewValue(); $invalid = array(); foreach ($new as $slug) { if (!PhabricatorSlug::isValidProjectSlug($slug)) { $invalid[] = $slug; } } if ($invalid) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Hashtags must contain at least one letter or number. %s '. 'project hashtag(s) are invalid: %s.', phutil_count($invalid), implode(', ', $invalid)), $slug_xaction); break; } $new = $this->normalizeSlugs($new); if ($new) { $slugs_used_already = id(new PhabricatorProjectSlug()) ->loadAllWhere('slug IN (%Ls)', $new); } else { // The project doesn't have any extra slugs. $slugs_used_already = array(); } $slugs_used_already = mgroup($slugs_used_already, 'getProjectPHID'); foreach ($slugs_used_already as $project_phid => $used_slugs) { if ($project_phid == $object->getPHID()) { continue; } $used_slug_strs = mpull($used_slugs, 'getSlug'); $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( '%s project hashtag(s) are already used by other projects: %s.', phutil_count($used_slug_strs), implode(', ', $used_slug_strs)), $slug_xaction); $errors[] = $error; } break; case PhabricatorProjectTransaction::TYPE_PARENT: case PhabricatorProjectTransaction::TYPE_MILESTONE: if (!$xactions) { break; } $xaction = last($xactions); $parent_phid = $xaction->getNewValue(); if (!$parent_phid) { continue; } if (!$this->getIsNewObject()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can only set a parent or milestone project when creating a '. 'project for the first time.'), $xaction); break; } $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs(array($parent_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); if (!$projects) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Parent or milestone project PHID ("%s") must be the PHID of a '. 'valid, visible project which you have permission to edit.', $parent_phid), $xaction); break; } $project = head($projects); if ($project->isMilestone()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Parent or milestone project PHID ("%s") must not be a '. 'milestone. Milestones may not have subprojects or milestones.', $parent_phid), $xaction); break; } $limit = PhabricatorProject::getProjectDepthLimit(); if ($project->getProjectDepth() >= ($limit - 1)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can not create a subproject or mielstone under this parent '. 'because it would nest projects too deeply. The maximum '. 'nesting depth of projects is %s.', new PhutilNumber($limit)), $xaction); break; } $object->attachParentProject($project); break; } return $errors; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: case PhabricatorProjectTransaction::TYPE_ICON: case PhabricatorProjectTransaction::TYPE_COLOR: PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); return; case PhabricatorProjectTransaction::TYPE_LOCKED: PhabricatorPolicyFilter::requireCapability( $this->requireActor(), newv($this->getEditorApplicationClass(), array()), ProjectCanLockProjectsCapability::CAPABILITY); return; case PhabricatorTransactions::TYPE_EDGE: switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $actor_phid = $this->requireActor()->getPHID(); $is_join = (($add === array($actor_phid)) && !$rem); $is_leave = (($rem === array($actor_phid)) && !$add); if ($is_join) { // You need CAN_JOIN to join a project. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_JOIN); } else if ($is_leave) { // You usually don't need any capabilities to leave a project. if ($object->getIsMembershipLocked()) { // you must be able to edit though to leave locked projects PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); } } else { // You need CAN_EDIT to change members other than yourself. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); } return; } break; } return parent::requireCapabilities($object, $xaction); } 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())) - ->needMembers(true) + ->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(), ); } 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_SUBSCRIBERS => pht('Project subscribers change.'), 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) { $id = $object->getID(); $name = $object->getName(); return id(new PhabricatorMetaMTAMail()) ->setSubject("{$name}") ->addHeader('Thread-Topic', "Project {$id}"); } 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 extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_IMAGE: $new = $xaction->getNewValue(); if ($new) { return array($new); } break; } return parent::extractFilePHIDsFromCustomTransaction($object, $xaction); } 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 PhabricatorProjectTransaction::TYPE_PARENT: $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(); } } } if ($this->getIsNewObject()) { $this->setDefaultProfilePicture($object); } // 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); } return parent::applyFinalEffects($object, $xactions); } private 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(); } private 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(); } } private 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; $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); // Automatically add the author as a member when they create a project // if they're using the web interface. $content_source = $this->getContentSource(); $source_web = PhabricatorContentSource::SOURCE_WEB; $is_web = ($content_source->getSource() === $source_web); if ($this->getIsNewObject() && $is_web) { if ($actor_phid) { $type_member = PhabricatorProjectProjectHasMemberEdgeType::EDGECONST; $results[] = id(new PhabricatorProjectTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $type_member) ->setNewValue( array( '+' => array($actor_phid => $actor_phid), )); } } $is_milestone = $object->isMilestone(); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_MILESTONE: if ($xaction->getNewValue() !== null) { $is_milestone = true; } break; } } $this->setIsMilestone($is_milestone); return $results; } private function setDefaultProfilePicture(PhabricatorProject $project) { if ($project->isMilestone()) { return; } $compose_color = $project->getDisplayIconComposeColor(); $compose_icon = $project->getDisplayIconComposeIcon(); $builtin = id(new PhabricatorFilesComposeIconBuiltinFile()) ->setColor($compose_color) ->setIcon($compose_icon); $data = $builtin->loadBuiltinFileData(); $file = PhabricatorFile::newFromFileData( $data, array( 'name' => $builtin->getBuiltinDisplayName(), 'profile' => true, 'canCDN' => true, )); $project ->setProfileImagePHID($file->getPHID()) ->save(); } } diff --git a/src/applications/project/query/PhabricatorProjectQuery.php b/src/applications/project/query/PhabricatorProjectQuery.php index e2d678e7d7..304dd956e1 100644 --- a/src/applications/project/query/PhabricatorProjectQuery.php +++ b/src/applications/project/query/PhabricatorProjectQuery.php @@ -1,700 +1,722 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withStatus($status) { $this->status = $status; return $this; } public function withMemberPHIDs(array $member_phids) { $this->memberPHIDs = $member_phids; return $this; } public function withSlugs(array $slugs) { $this->slugs = $slugs; return $this; } public function withNames(array $names) { $this->names = $names; return $this; } public function withNameTokens(array $tokens) { $this->nameTokens = array_values($tokens); return $this; } public function withIcons(array $icons) { $this->icons = $icons; return $this; } public function withColors(array $colors) { $this->colors = $colors; return $this; } public function withParentProjectPHIDs($parent_phids) { $this->parentPHIDs = $parent_phids; return $this; } public function withAncestorProjectPHIDs($ancestor_phids) { $this->ancestorPHIDs = $ancestor_phids; return $this; } public function withIsMilestone($is_milestone) { $this->isMilestone = $is_milestone; return $this; } public function withHasSubprojects($has_subprojects) { $this->hasSubprojects = $has_subprojects; return $this; } public function withDepthBetween($min, $max) { $this->minDepth = $min; $this->maxDepth = $max; return $this; } public function needMembers($need_members) { $this->needMembers = $need_members; return $this; } + public function needAncestorMembers($need_ancestor_members) { + $this->needAncestorMembers = $need_ancestor_members; + return $this; + } + public function needWatchers($need_watchers) { $this->needWatchers = $need_watchers; return $this; } public function needImages($need_images) { $this->needImages = $need_images; return $this; } public function needSlugs($need_slugs) { $this->needSlugs = $need_slugs; return $this; } public function newResultObject() { return new PhabricatorProject(); } protected function getDefaultOrderVector() { return array('name'); } public function getBuiltinOrders() { return array( 'name' => array( 'vector' => array('name'), 'name' => pht('Name'), ), ) + parent::getBuiltinOrders(); } public function getOrderableColumns() { return parent::getOrderableColumns() + array( 'name' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'name', 'reverse' => true, 'type' => 'string', 'unique' => true, ), ); } protected function getPagingValueMap($cursor, array $keys) { $project = $this->loadCursorObject($cursor); return array( 'name' => $project->getName(), ); } public function getSlugMap() { if ($this->slugMap === null) { throw new PhutilInvalidStateException('execute'); } return $this->slugMap; } protected function willExecute() { $this->slugMap = array(); $this->slugNormals = array(); $this->allSlugs = array(); if ($this->slugs) { foreach ($this->slugs as $slug) { if (PhabricatorSlug::isValidProjectSlug($slug)) { $normal = PhabricatorSlug::normalizeProjectSlug($slug); $this->slugNormals[$slug] = $normal; $this->allSlugs[$normal] = $normal; } // NOTE: At least for now, we query for the normalized slugs but also // for the slugs exactly as entered. This allows older projects with // slugs that are no longer valid to continue to work. $this->allSlugs[$slug] = $slug; } } } protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } protected function willFilterPage(array $projects) { $ancestor_paths = array(); foreach ($projects as $project) { foreach ($project->getAncestorProjectPaths() as $path) { $ancestor_paths[$path] = $path; } } if ($ancestor_paths) { $ancestors = id(new PhabricatorProject())->loadAllWhere( 'projectPath IN (%Ls)', $ancestor_paths); } else { $ancestors = array(); } $projects = $this->linkProjectGraph($projects, $ancestors); $viewer_phid = $this->getViewer()->getPHID(); $material_type = PhabricatorProjectMaterializedMemberEdgeType::EDGECONST; $watcher_type = PhabricatorObjectHasWatcherEdgeType::EDGECONST; $types = array(); $types[] = $material_type; if ($this->needWatchers) { $types[] = $watcher_type; } + $all_graph = $this->getAllReachableAncestors($projects); + + if ($this->needAncestorMembers) { + $src_projects = $all_graph; + } else { + $src_projects = $projects; + } + $all_sources = array(); - foreach ($projects as $project) { + foreach ($src_projects as $project) { if ($project->isMilestone()) { $phid = $project->getParentProjectPHID(); } else { $phid = $project->getPHID(); } $all_sources[$phid] = $phid; } $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs($all_sources) ->withEdgeTypes($types); + $need_all_edges = + $this->needMembers || + $this->needWatchers || + $this->needAncestorMembers; + // If we only need to know if the viewer is a member, we can restrict // the query to just their PHID. $any_edges = true; - if (!$this->needMembers && !$this->needWatchers) { + if (!$need_all_edges) { if ($viewer_phid) { $edge_query->withDestinationPHIDs(array($viewer_phid)); } else { // If we don't need members or watchers and don't have a viewer PHID // (viewer is logged-out or omnipotent), they'll never be a member // so we don't need to issue this query at all. $any_edges = false; } } if ($any_edges) { $edge_query->execute(); } $membership_projects = array(); - foreach ($projects as $project) { + foreach ($src_projects as $project) { $project_phid = $project->getPHID(); if ($project->isMilestone()) { $source_phids = array($project->getParentProjectPHID()); } else { $source_phids = array($project_phid); } if ($any_edges) { $member_phids = $edge_query->getDestinationPHIDs( $source_phids, array($material_type)); } else { $member_phids = array(); } if (in_array($viewer_phid, $member_phids)) { $membership_projects[$project_phid] = $project; } - if ($this->needMembers) { + if ($this->needMembers || $this->needAncestorMembers) { $project->attachMemberPHIDs($member_phids); } if ($this->needWatchers) { $watcher_phids = $edge_query->getDestinationPHIDs( $source_phids, array($watcher_type)); $project->attachWatcherPHIDs($watcher_phids); $project->setIsUserWatcher( $viewer_phid, in_array($viewer_phid, $watcher_phids)); } } - $all_graph = $this->getAllReachableAncestors($projects); - $member_graph = $this->getAllReachableAncestors($membership_projects); + // If we loaded ancestor members, we've already populated membership + // lists above, so we can skip this step. + if (!$this->needAncestorMembers) { + $member_graph = $this->getAllReachableAncestors($membership_projects); - foreach ($all_graph as $phid => $project) { - $is_member = isset($member_graph[$phid]); - $project->setIsUserMember($viewer_phid, $is_member); + foreach ($all_graph as $phid => $project) { + $is_member = isset($member_graph[$phid]); + $project->setIsUserMember($viewer_phid, $is_member); + } } return $projects; } protected function didFilterPage(array $projects) { if ($this->needImages) { $default = null; $file_phids = mpull($projects, 'getProfileImagePHID'); $file_phids = array_filter($file_phids); if ($file_phids) { $files = id(new PhabricatorFileQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); } else { $files = array(); } foreach ($projects as $project) { $file = idx($files, $project->getProfileImagePHID()); if (!$file) { if (!$default) { $default = PhabricatorFile::loadBuiltin( $this->getViewer(), 'project.png'); } $file = $default; } $project->attachProfileImageFile($file); } } $this->loadSlugs($projects); return $projects; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->status != self::STATUS_ANY) { switch ($this->status) { case self::STATUS_OPEN: case self::STATUS_ACTIVE: $filter = array( PhabricatorProjectStatus::STATUS_ACTIVE, ); break; case self::STATUS_CLOSED: case self::STATUS_ARCHIVED: $filter = array( PhabricatorProjectStatus::STATUS_ARCHIVED, ); break; default: throw new Exception( pht( "Unknown project status '%s'!", $this->status)); } $where[] = qsprintf( $conn, 'status IN (%Ld)', $filter); } if ($this->ids !== null) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } if ($this->memberPHIDs !== null) { $where[] = qsprintf( $conn, 'e.dst IN (%Ls)', $this->memberPHIDs); } if ($this->slugs !== null) { $where[] = qsprintf( $conn, 'slug.slug IN (%Ls)', $this->allSlugs); } if ($this->names !== null) { $where[] = qsprintf( $conn, 'name IN (%Ls)', $this->names); } if ($this->icons !== null) { $where[] = qsprintf( $conn, 'icon IN (%Ls)', $this->icons); } if ($this->colors !== null) { $where[] = qsprintf( $conn, 'color IN (%Ls)', $this->colors); } if ($this->parentPHIDs !== null) { $where[] = qsprintf( $conn, 'parentProjectPHID IN (%Ls)', $this->parentPHIDs); } if ($this->ancestorPHIDs !== null) { $ancestor_paths = queryfx_all( $conn, 'SELECT projectPath, projectDepth FROM %T WHERE phid IN (%Ls)', id(new PhabricatorProject())->getTableName(), $this->ancestorPHIDs); if (!$ancestor_paths) { throw new PhabricatorEmptyQueryException(); } $sql = array(); foreach ($ancestor_paths as $ancestor_path) { $sql[] = qsprintf( $conn, '(projectPath LIKE %> AND projectDepth > %d)', $ancestor_path['projectPath'], $ancestor_path['projectDepth']); } $where[] = '('.implode(' OR ', $sql).')'; $where[] = qsprintf( $conn, 'parentProjectPHID IS NOT NULL'); } if ($this->isMilestone !== null) { if ($this->isMilestone) { $where[] = qsprintf( $conn, 'milestoneNumber IS NOT NULL'); } else { $where[] = qsprintf( $conn, 'milestoneNumber IS NULL'); } } if ($this->hasSubprojects !== null) { $where[] = qsprintf( $conn, 'hasSubprojects = %d', (int)$this->hasSubprojects); } if ($this->minDepth !== null) { $where[] = qsprintf( $conn, 'projectDepth >= %d', $this->minDepth); } if ($this->maxDepth !== null) { $where[] = qsprintf( $conn, 'projectDepth <= %d', $this->maxDepth); } return $where; } protected function shouldGroupQueryResultRows() { if ($this->memberPHIDs || $this->nameTokens) { return true; } return parent::shouldGroupQueryResultRows(); } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = parent::buildJoinClauseParts($conn); if ($this->memberPHIDs !== null) { $joins[] = qsprintf( $conn, 'JOIN %T e ON e.src = p.phid AND e.type = %d', PhabricatorEdgeConfig::TABLE_NAME_EDGE, PhabricatorProjectMaterializedMemberEdgeType::EDGECONST); } if ($this->slugs !== null) { $joins[] = qsprintf( $conn, 'JOIN %T slug on slug.projectPHID = p.phid', id(new PhabricatorProjectSlug())->getTableName()); } if ($this->nameTokens !== null) { foreach ($this->nameTokens as $key => $token) { $token_table = 'token_'.$key; $joins[] = qsprintf( $conn, 'JOIN %T %T ON %T.projectID = p.id AND %T.token LIKE %>', PhabricatorProject::TABLE_DATASOURCE_TOKEN, $token_table, $token_table, $token_table, $token); } } return $joins; } public function getQueryApplicationClass() { return 'PhabricatorProjectApplication'; } protected function getPrimaryTableAlias() { return 'p'; } private function linkProjectGraph(array $projects, array $ancestors) { $ancestor_map = mpull($ancestors, null, 'getPHID'); $projects_map = mpull($projects, null, 'getPHID'); $all_map = $projects_map + $ancestor_map; $done = array(); foreach ($projects as $key => $project) { $seen = array($project->getPHID() => true); if (!$this->linkProject($project, $all_map, $done, $seen)) { $this->didRejectResult($project); unset($projects[$key]); continue; } foreach ($project->getAncestorProjects() as $ancestor) { $seen[$ancestor->getPHID()] = true; } } return $projects; } private function linkProject($project, array $all, array $done, array $seen) { $parent_phid = $project->getParentProjectPHID(); // This project has no parent, so just attach `null` and return. if (!$parent_phid) { $project->attachParentProject(null); return true; } // This project has a parent, but it failed to load. if (empty($all[$parent_phid])) { return false; } // Test for graph cycles. If we encounter one, we're going to hide the // entire cycle since we can't meaningfully resolve it. if (isset($seen[$parent_phid])) { return false; } $seen[$parent_phid] = true; $parent = $all[$parent_phid]; $project->attachParentProject($parent); if (!empty($done[$parent_phid])) { return true; } return $this->linkProject($parent, $all, $done, $seen); } private function getAllReachableAncestors(array $projects) { $ancestors = array(); $seen = mpull($projects, null, 'getPHID'); $stack = $projects; while ($stack) { $project = array_pop($stack); $phid = $project->getPHID(); $ancestors[$phid] = $project; $parent_phid = $project->getParentProjectPHID(); if (!$parent_phid) { continue; } if (isset($seen[$parent_phid])) { continue; } $seen[$parent_phid] = true; $stack[] = $project->getParentProject(); } return $ancestors; } private function loadSlugs(array $projects) { // Build a map from primary slugs to projects. $primary_map = array(); foreach ($projects as $project) { $primary_slug = $project->getPrimarySlug(); if ($primary_slug === null) { continue; } $primary_map[$primary_slug] = $project; } // Link up all of the queried slugs which correspond to primary // slugs. If we can link up everything from this (no slugs were queried, // or only primary slugs were queried) we don't need to load anything // else. $unknown = $this->slugNormals; foreach ($unknown as $input => $normal) { if (isset($primary_map[$input])) { $match = $input; } else if (isset($primary_map[$normal])) { $match = $normal; } else { continue; } $this->slugMap[$input] = array( 'slug' => $match, 'projectPHID' => $primary_map[$match]->getPHID(), ); unset($unknown[$input]); } // If we need slugs, we have to load everything. // If we still have some queried slugs which we haven't mapped, we only // need to look for them. // If we've mapped everything, we don't have to do any work. $project_phids = mpull($projects, 'getPHID'); if ($this->needSlugs) { $slugs = id(new PhabricatorProjectSlug())->loadAllWhere( 'projectPHID IN (%Ls)', $project_phids); } else if ($unknown) { $slugs = id(new PhabricatorProjectSlug())->loadAllWhere( 'projectPHID IN (%Ls) AND slug IN (%Ls)', $project_phids, $unknown); } else { $slugs = array(); } // Link up any slugs we were not able to link up earlier. $extra_map = mpull($slugs, 'getProjectPHID', 'getSlug'); foreach ($unknown as $input => $normal) { if (isset($extra_map[$input])) { $match = $input; } else if (isset($extra_map[$normal])) { $match = $normal; } else { continue; } $this->slugMap[$input] = array( 'slug' => $match, 'projectPHID' => $extra_map[$match], ); unset($unknown[$input]); } if ($this->needSlugs) { $slug_groups = mgroup($slugs, 'getProjectPHID'); foreach ($projects as $project) { $project_slugs = idx($slug_groups, $project->getPHID(), array()); $project->attachSlugs($project_slugs); } } } }