diff --git a/.codacy.yml b/.codacy.yml index 0ba8aa4..39a96ea 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -26,6 +26,11 @@ engines: enabled: false Squiz.WhiteSpace.MemberVarSpacing.Incorrect: enabled: false + # Disable WordPress-specific escaping rules for CLI application + WordPress.Security.EscapeOutput.OutputNotEscaped: + enabled: false + WordPress.XSS.EscapeOutput.OutputNotEscaped: + enabled: false exclude_paths: - 'vendor/**' - 'tests/**' diff --git a/phpcs.xml b/phpcs.xml index 1504f62..6c293a2 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -9,4 +9,12 @@ + + + + 0 + + + 0 + diff --git a/src/Commands/Vcs/Connection/LinkCommand.php b/src/Commands/Vcs/Connection/LinkCommand.php new file mode 100644 index 0000000..385c8bb --- /dev/null +++ b/src/Commands/Vcs/Connection/LinkCommand.php @@ -0,0 +1,436 @@ + --vcs-org= --source-org= + * Links the VCS organization to the destination Pantheon organization. + * @usage --vcs-org= + * Links the VCS organization to the destination Pantheon organization. If multiple source organizations have this VCS connection, you'll be prompted to select one. + * @usage --source-org= + * Lists VCS organizations from the source organization and prompts you to select one to link to the destination organization. + * @usage + * Interactive mode: prompts for both VCS organization and source Pantheon organization. + */ + public function connectionLink( + string $destination_org, + array $options = [ + 'vcs-org' => null, + 'source-org' => null, + ] + ) { + $user = $this->session()->getUser(); + $vcs_org = $options['vcs-org']; + $source_org = $options['source-org']; + + // Get and validate destination organization + $destination_pantheon_org = $this->getAndValidateOrganization($destination_org, 'destination'); + + // Determine source organization and VCS organization + list($source_pantheon_org, $vcs_installation) = $this->determineSourceAndVcsOrg( + $user, + $vcs_org, + $source_org, + $destination_pantheon_org + ); + + // Show confirmation + $this->log()->notice('Linking VCS organization:'); + $this->log()->notice(' VCS Organization: {vcs_org} ({vcs_type})', [ + 'vcs_org' => $vcs_installation->login_name, + 'vcs_type' => $vcs_installation->alias, + ]); + $this->log()->notice(' Source Pantheon Org: {source_org}', ['source_org' => $source_pantheon_org->getLabel()]); + $this->log()->notice(' Destination Pantheon Org: {dest_org}', ['dest_org' => $destination_pantheon_org->getLabel()]); + + if (!$this->confirm('Do you want to proceed with linking this VCS organization?')) { + $this->log()->warning('Operation cancelled.'); + return; + } + + // Call the API to link the VCS organization + $this->linkVcsOrganization( + $source_pantheon_org->id, + $destination_pantheon_org->id, + $vcs_installation->installation_id + ); + + $this->log()->notice('Successfully linked VCS organization {vcs_org} to {dest_org}.', [ + 'vcs_org' => $vcs_installation->login_name, + 'dest_org' => $destination_pantheon_org->getLabel(), + ]); + } + + /** + * Gets and validates that the organization exists and user is a member. + * + * @param string $org_identifier Organization name, label, or ID. + * @param string $org_type Type of organization (for error messages). + * @return \Pantheon\Terminus\Models\Organization + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + protected function getAndValidateOrganization(string $org_identifier, string $org_type) + { + try { + $membership = $this->session()->getUser()->getOrganizationMemberships()->get($org_identifier); + return $membership->getOrganization(); + } catch (\Exception $e) { + throw new TerminusException( + 'Could not find {org_type} organization "{org}". Please ensure you are a member of this organization.', + [ + 'org_type' => $org_type, + 'org' => $org_identifier, + ] + ); + } + } + + /** + * Determines source organization and VCS organization based on provided options. + * + * @param \Pantheon\Terminus\Models\User $user Current authenticated user + * @param string|null $vcs_org VCS organization name to search for + * @param string|null $source_org Source Pantheon organization name/ID + * @param \Pantheon\Terminus\Models\Organization $destination_org Destination organization object + * @return array [$source_organization, $vcs_installation] + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + protected function determineSourceAndVcsOrg($user, $vcs_org, $source_org, $destination_org) + { + // Case 1: Both VCS org and source org are provided + if ($vcs_org && $source_org) { + $source_pantheon_org = $this->getAndValidateOrganization($source_org, 'source'); + $vcs_installation = $this->findVcsOrgInPantheonOrg($user, $source_pantheon_org, $vcs_org); + + if (!$vcs_installation) { + throw new TerminusException( + 'VCS organization "{vcs_org}" not found in source Pantheon organization "{source_org}".', + [ + 'vcs_org' => $vcs_org, + 'source_org' => $source_pantheon_org->getLabel(), + ] + ); + } + + return [$source_pantheon_org, $vcs_installation]; + } + + // Case 2: Only VCS org is provided - find which Pantheon org has it + if ($vcs_org && !$source_org) { + return $this->findPantheonOrgWithVcsOrg($user, $vcs_org, $destination_org); + } + + // Case 3: Only source org is provided - list VCS orgs and prompt + if (!$vcs_org && $source_org) { + $source_pantheon_org = $this->getAndValidateOrganization($source_org, 'source'); + $vcs_installation = $this->promptForVcsOrgFromPantheonOrg($user, $source_pantheon_org); + + return [$source_pantheon_org, $vcs_installation]; + } + + // Case 4: Neither provided - fully interactive mode + // First, get all Pantheon orgs that have VCS connections + $orgs_with_vcs = $this->getAllOrgsWithVcsConnections($user, $destination_org); + + if (empty($orgs_with_vcs)) { + throw new TerminusException( + 'No Pantheon organizations found with VCS connections. Please use vcs:connection:add to add a VCS connection first.' + ); + } + + // Prompt user to select source org + $source_pantheon_org = $this->promptForSourceOrg($orgs_with_vcs); + + // Prompt user to select VCS org from the source org + $vcs_installation = $this->promptForVcsOrgFromPantheonOrg($user, $source_pantheon_org); + + return [$source_pantheon_org, $vcs_installation]; + } + + /** + * Finds a specific VCS organization in a Pantheon organization. + * + * @param \Pantheon\Terminus\Models\User $user Current authenticated user + * @param \Pantheon\Terminus\Models\Organization $pantheon_org Pantheon organization to search in + * @param string $vcs_org_name Name of the VCS organization to find + * @return object|null The VCS installation object or null if not found + */ + protected function findVcsOrgInPantheonOrg($user, $pantheon_org, $vcs_org_name) + { + $installations_resp = $this->getVcsClient()->getInstallations($pantheon_org->id, $user->id); + $installations = $installations_resp['data'] ?? []; + + foreach ($installations as $installation) { + if ($installation->login_name === $vcs_org_name) { + return $installation; + } + } + + return null; + } + + /** + * Finds which Pantheon organization has a specific VCS organization. + * + * @param \Pantheon\Terminus\Models\User $user + * @param string $vcs_org_name + * @param \Pantheon\Terminus\Models\Organization $destination_org + * @return array [$source_organization, $vcs_installation] + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + protected function findPantheonOrgWithVcsOrg($user, $vcs_org_name, $destination_org) + { + $matching_orgs = []; + $orgs = $user->getOrganizationMemberships()->all(); + + foreach ($orgs as $membership) { + $org = $membership->getOrganization(); + + // Skip the destination org + if ($org->id === $destination_org->id) { + continue; + } + + $vcs_installation = $this->findVcsOrgInPantheonOrg($user, $org, $vcs_org_name); + + if ($vcs_installation) { + $matching_orgs[] = [ + 'org' => $org, + 'installation' => $vcs_installation, + ]; + } + } + + if (empty($matching_orgs)) { + throw new TerminusException( + 'VCS organization "{vcs_org}" not found in any of your Pantheon organizations.', + ['vcs_org' => $vcs_org_name] + ); + } + + // If only one match, use it + if (count($matching_orgs) === 1) { + return [$matching_orgs[0]['org'], $matching_orgs[0]['installation']]; + } + + // Multiple matches - prompt user to select + $this->log()->notice('VCS organization "{vcs_org}" found in multiple Pantheon organizations:', ['vcs_org' => $vcs_org_name]); + + $org_choices = []; + foreach ($matching_orgs as $idx => $match) { + $org_choices[$idx] = $match['org']->getLabel() . ' (' . $match['org']->id . ')'; + } + + $helper = new QuestionHelper(); + $question = new ChoiceQuestion( + 'Please select the source Pantheon organization:', + $org_choices + ); + $question->setErrorMessage('Invalid selection.'); + + $input = $this->input(); + $output = $this->output(); + $selected_idx = $helper->ask($input, $output, $question); + + // Find the index of the selected choice + $selected_key = array_search($selected_idx, $org_choices); + + return [$matching_orgs[$selected_key]['org'], $matching_orgs[$selected_key]['installation']]; + } + + /** + * Prompts user to select a VCS organization from a Pantheon organization. + * + * @param \Pantheon\Terminus\Models\User $user + * @param \Pantheon\Terminus\Models\Organization $pantheon_org + * @return object The selected VCS installation + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + protected function promptForVcsOrgFromPantheonOrg($user, $pantheon_org) + { + $installations_resp = $this->getVcsClient()->getInstallations($pantheon_org->id, $user->id); + $installations = $installations_resp['data'] ?? []; + + if (empty($installations)) { + throw new TerminusException( + 'No VCS connections found in Pantheon organization "{org}".', + ['org' => $pantheon_org->getLabel()] + ); + } + + if (count($installations) === 1) { + return $installations[0]; + } + + // Multiple VCS orgs - prompt user to select + $vcs_choices = []; + foreach ($installations as $idx => $installation) { + $vcs_choices[$idx] = sprintf( + '%s (%s) - ID: %s', + $installation->login_name, + $installation->alias, + $installation->installation_id + ); + } + + $helper = new QuestionHelper(); + $question = new ChoiceQuestion( + 'Please select the VCS organization to link:', + $vcs_choices + ); + $question->setErrorMessage('Invalid selection.'); + + $input = $this->input(); + $output = $this->output(); + $selected = $helper->ask($input, $output, $question); + + // Find the index of the selected choice + $selected_key = array_search($selected, $vcs_choices); + + return $installations[$selected_key]; + } + + /** + * Gets all Pantheon organizations that have VCS connections. + * + * @param \Pantheon\Terminus\Models\User $user + * @param \Pantheon\Terminus\Models\Organization $destination_org + * @return array Array of organizations with VCS connections + */ + protected function getAllOrgsWithVcsConnections($user, $destination_org) + { + $orgs_with_vcs = []; + $orgs = $user->getOrganizationMemberships()->all(); + + foreach ($orgs as $membership) { + $org = $membership->getOrganization(); + + // Skip the destination org + if ($org->id === $destination_org->id) { + continue; + } + + try { + $installations_resp = $this->getVcsClient()->getInstallations($org->id, $user->id); + $installations = $installations_resp['data'] ?? []; + + if (!empty($installations)) { + $orgs_with_vcs[] = $org; + } + } catch (\Exception $e) { + // Skip orgs where we can't fetch installations + $this->log()->debug('Could not fetch VCS installations for org {org}: {error}', [ + 'org' => $org->getLabel(), + 'error' => $e->getMessage(), + ]); + } + } + + return $orgs_with_vcs; + } + + /** + * Prompts user to select a source Pantheon organization. + * + * @param array $orgs Array of Pantheon organizations + * @return \Pantheon\Terminus\Models\Organization + */ + protected function promptForSourceOrg(array $orgs) + { + if (count($orgs) === 1) { + return $orgs[0]; + } + + $org_choices = []; + foreach ($orgs as $idx => $org) { + $org_choices[$idx] = $org->getLabel() . ' (' . $org->id . ')'; + } + + $helper = new QuestionHelper(); + $question = new ChoiceQuestion( + 'Please select the source Pantheon organization:', + $org_choices + ); + $question->setErrorMessage('Invalid selection.'); + + $input = $this->input(); + $output = $this->output(); + $selected = $helper->ask($input, $output, $question); + + // Find the index of the selected choice + $selected_key = array_search($selected, $org_choices); + + return $orgs[$selected_key]; + } + + /** + * Calls the API to link a VCS organization to a destination Pantheon organization. + * + * @param string $source_org_id Source Pantheon organization UUID + * @param string $destination_org_id Destination Pantheon organization UUID + * @param string $installation_id VCS installation ID + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + protected function linkVcsOrganization( + string $source_org_id, + string $destination_org_id, + string $installation_id + ) { + try { + $result = $this->getVcsClient()->linkInstallation( + $source_org_id, + $destination_org_id, + $installation_id + ); + + if (!empty($result['error'])) { + throw new TerminusException( + 'Error linking VCS organization: {error}', + ['error' => $result['error']] + ); + } + } catch (\Exception $e) { + throw new TerminusException( + 'Failed to link VCS organization: {error}', + ['error' => $e->getMessage()] + ); + } + } +} diff --git a/src/VcsApi/Client.php b/src/VcsApi/Client.php index e552aef..04c0c61 100644 --- a/src/VcsApi/Client.php +++ b/src/VcsApi/Client.php @@ -407,6 +407,35 @@ public function getInstallations(string $org_id, string $user_id): array return $this->requestApi(sprintf('installation/user/%s/org/%s', $user_id, $org_id), $request_options, "X-Pantheon-Session"); } + /** + * Link an existing VCS installation to a new Pantheon organization. + * + * @param string $source_org_id Source Pantheon organization UUID + * @param string $destination_org_id Destination Pantheon organization UUID + * @param string $installation_id VCS installation ID + * + * @return array + * + * @throws \Pantheon\Terminus\Exceptions\TerminusException + */ + public function linkInstallation( + string $source_org_id, + string $destination_org_id, + string $installation_id + ): array { + $request_options = [ + 'json' => [ + 'organization_id' => $source_org_id, + 'installation_id' => $installation_id, + ], + 'method' => 'POST', + ]; + + $path = sprintf('authorize/organization/%s', $destination_org_id); + + return $this->requestApi($path, $request_options, "X-Pantheon-Session"); + } + /** * Get auth links. */ diff --git a/tests/unit/LinkCommandTest.php b/tests/unit/LinkCommandTest.php new file mode 100644 index 0000000..9c08a03 --- /dev/null +++ b/tests/unit/LinkCommandTest.php @@ -0,0 +1,273 @@ +command = $this->getMockBuilder(LinkCommand::class) + ->onlyMethods(['session', 'getVcsClient', 'log', 'confirm', 'input', 'output']) + ->getMock(); + + $this->mockUser = $this->createMock(User::class); + $this->mockUser->id = 'user-123'; + $this->mockSession = $this->createMock(Session::class); + $this->mockVcsClient = $this->createMock(Client::class); + $this->mockOrgMemberships = $this->createMock(OrganizationUserMemberships::class); + + $this->mockSession->method('getUser')->willReturn($this->mockUser); + $this->mockUser->method('getOrganizationMemberships')->willReturn($this->mockOrgMemberships); + + $this->command->method('session')->willReturn($this->mockSession); + $this->command->method('getVcsClient')->willReturn($this->mockVcsClient); + } + + /** + * Test that getAndValidateOrganization throws exception for non-existent org. + */ + public function testGetAndValidateOrganizationThrowsExceptionForNonexistentOrg(): void + { + $this->expectException(TerminusException::class); + $this->expectExceptionMessage('Could not find destination organization "nonexistent-org"'); + + $this->mockOrgMemberships + ->method('get') + ->with('nonexistent-org') + ->willThrowException(new \Exception('Not found')); + + $this->invokeProtectedMethod('getAndValidateOrganization', ['nonexistent-org', 'destination']); + } + + /** + * Test that getAndValidateOrganization returns organization for valid org. + */ + public function testGetAndValidateOrganizationReturnsOrgForValidOrg(): void + { + $mockOrg = $this->createMock(Organization::class); + $mockOrg->id = 'org-123'; + $mockOrg->method('getLabel')->willReturn('Test Org'); + + $mockMembership = $this->createMock(OrganizationUserMembership::class); + $mockMembership->method('getOrganization')->willReturn($mockOrg); + + $this->mockOrgMemberships + ->method('get') + ->with('test-org') + ->willReturn($mockMembership); + + $result = $this->invokeProtectedMethod('getAndValidateOrganization', ['test-org', 'destination']); + + $this->assertSame($mockOrg, $result); + $this->assertEquals('org-123', $result->id); + } + + /** + * Test findVcsOrgInPantheonOrg returns null when VCS org not found. + */ + public function testFindVcsOrgInPantheonOrgReturnsNullWhenNotFound(): void + { + $mockOrg = $this->createMock(Organization::class); + $mockOrg->id = 'org-123'; + + $installation1 = new \stdClass(); + $installation1->login_name = 'github-org-1'; + $installation1->installation_id = 'install-1'; + + $installation2 = new \stdClass(); + $installation2->login_name = 'github-org-2'; + $installation2->installation_id = 'install-2'; + + $this->mockVcsClient + ->method('getInstallations') + ->with('org-123', $this->anything()) + ->willReturn(['data' => [$installation1, $installation2]]); + + $result = $this->invokeProtectedMethod( + 'findVcsOrgInPantheonOrg', + [$this->mockUser, $mockOrg, 'github-org-3'] + ); + + $this->assertNull($result); + } + + /** + * Test findVcsOrgInPantheonOrg returns installation when found. + */ + public function testFindVcsOrgInPantheonOrgReturnsInstallationWhenFound(): void + { + $mockOrg = $this->createMock(Organization::class); + $mockOrg->id = 'org-123'; + + $installation1 = new \stdClass(); + $installation1->login_name = 'github-org-1'; + $installation1->installation_id = 'install-1'; + + $installation2 = new \stdClass(); + $installation2->login_name = 'github-org-2'; + $installation2->installation_id = 'install-2'; + + $this->mockVcsClient + ->method('getInstallations') + ->with('org-123', $this->anything()) + ->willReturn(['data' => [$installation1, $installation2]]); + + $result = $this->invokeProtectedMethod( + 'findVcsOrgInPantheonOrg', + [$this->mockUser, $mockOrg, 'github-org-2'] + ); + + $this->assertNotNull($result); + $this->assertEquals('github-org-2', $result->login_name); + $this->assertEquals('install-2', $result->installation_id); + } + + /** + * Test getAllOrgsWithVcsConnections filters orgs correctly. + */ + public function testGetAllOrgsWithVcsConnectionsFiltersCorrectly(): void + { + $destinationOrg = $this->createMock(Organization::class); + $destinationOrg->id = 'dest-org-123'; + + $org1 = $this->createMock(Organization::class); + $org1->id = 'org-1'; + $org1->method('getLabel')->willReturn('Org 1'); + + $org2 = $this->createMock(Organization::class); + $org2->id = 'org-2'; + $org2->method('getLabel')->willReturn('Org 2'); + + $org3 = $this->createMock(Organization::class); + $org3->id = 'org-3'; + $org3->method('getLabel')->willReturn('Org 3'); + + $membership1 = $this->createMock(OrganizationUserMembership::class); + $membership1->method('getOrganization')->willReturn($org1); + + $membership2 = $this->createMock(OrganizationUserMembership::class); + $membership2->method('getOrganization')->willReturn($org2); + + $membership3 = $this->createMock(OrganizationUserMembership::class); + $membership3->method('getOrganization')->willReturn($org3); + + $membershipDest = $this->createMock(OrganizationUserMembership::class); + $membershipDest->method('getOrganization')->willReturn($destinationOrg); + + $this->mockOrgMemberships + ->method('all') + ->willReturn([$membership1, $membership2, $membership3, $membershipDest]); + + $installation = new \stdClass(); + $installation->installation_id = 'install-1'; + + // org-1 has VCS connections + // org-2 has no VCS connections + // org-3 has VCS connections + // dest-org should be filtered out + $this->mockVcsClient + ->method('getInstallations') + ->willReturnCallback(function ($orgId) use ($installation) { + if ($orgId === 'org-1' || $orgId === 'org-3') { + return ['data' => [$installation]]; + } + return ['data' => []]; + }); + + $result = $this->invokeProtectedMethod( + 'getAllOrgsWithVcsConnections', + [$this->mockUser, $destinationOrg] + ); + + $this->assertCount(2, $result); + $this->assertEquals('org-1', $result[0]->id); + $this->assertEquals('org-3', $result[1]->id); + } + + /** + * Test promptForVcsOrgFromPantheonOrg throws exception when no VCS connections. + */ + public function testPromptForVcsOrgFromPantheonOrgThrowsExceptionWhenNoConnections(): void + { + $this->expectException(TerminusException::class); + $this->expectExceptionMessage('No VCS connections found in Pantheon organization'); + + $mockOrg = $this->createMock(Organization::class); + $mockOrg->id = 'org-123'; + $mockOrg->method('getLabel')->willReturn('Test Org'); + + $this->mockVcsClient + ->method('getInstallations') + ->with('org-123', $this->anything()) + ->willReturn(['data' => []]); + + $this->invokeProtectedMethod( + 'promptForVcsOrgFromPantheonOrg', + [$this->mockUser, $mockOrg] + ); + } + + /** + * Test promptForVcsOrgFromPantheonOrg returns single installation without prompting. + */ + public function testPromptForVcsOrgFromPantheonOrgReturnsSingleInstallation(): void + { + $mockOrg = $this->createMock(Organization::class); + $mockOrg->id = 'org-123'; + + $installation = new \stdClass(); + $installation->login_name = 'github-org-1'; + $installation->installation_id = 'install-1'; + + $this->mockVcsClient + ->method('getInstallations') + ->with('org-123', $this->anything()) + ->willReturn(['data' => [$installation]]); + + $result = $this->invokeProtectedMethod( + 'promptForVcsOrgFromPantheonOrg', + [$this->mockUser, $mockOrg] + ); + + $this->assertSame($installation, $result); + $this->assertEquals('github-org-1', $result->login_name); + } + + /** + * Helper method to invoke protected methods using reflection. + * + * @param string $methodName Method name to invoke + * @param array $args Arguments to pass to the method + * @return mixed + */ + private function invokeProtectedMethod(string $methodName, array $args = []) + { + $reflection = new ReflectionClass($this->command); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invokeArgs($this->command, $args); + } +}