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);
+ }
+}