diff --git a/src/adapters/base-class.test.ts b/src/adapters/base-class.test.ts index 1c62a7e..6292dd5 100644 --- a/src/adapters/base-class.test.ts +++ b/src/adapters/base-class.test.ts @@ -685,4 +685,71 @@ describe('BaseClass', () => { expect(exitMock).toHaveBeenCalledWith(1); }); }); + + describe('detectFramework', () => { + it('should scope the framework query to the selected GitHub connection namespace', async () => { + const apolloClient = { + query: jest.fn().mockResolvedValueOnce({ data: { framework: { framework: 'GATSBY' } } }), + } as any; + baseClass = new BaseClass({ + log: logMock, + exit: exitMock, + apolloClient, + config: { + provider: 'GitHub', + listOfFrameWorks: [], + repository: { fullName: 'test-user/eleventy-sample', defaultBranch: 'main' }, + userConnection: { provider: 'GitHub', namespace: 'org-account' }, + }, + } as any); + (ux.inquire as jest.Mock).mockResolvedValueOnce('GATSBY'); + + await baseClass.detectFramework(); + + expect(apolloClient.query).toHaveBeenCalledWith({ + query: expect.anything(), + variables: { + query: { + provider: 'GitHub', + repoName: 'test-user/eleventy-sample', + branchName: 'main', + namespace: 'org-account', + }, + }, + }); + }); + }); + + describe('selectBranch', () => { + it('should scope the branches query to the selected GitHub connection namespace', async () => { + const apolloClient = { + query: jest.fn().mockResolvedValueOnce({ + data: { branches: { edges: [], pageData: { page: 1 }, pageInfo: { hasNextPage: false } } }, + }), + } as any; + baseClass = new BaseClass({ + log: logMock, + exit: exitMock, + apolloClient, + config: { + provider: 'GitHub', + flags: {}, + repository: { fullName: 'test-user/eleventy-sample' }, + userConnection: { provider: 'GitHub', namespace: 'org-account' }, + }, + } as any); + (ux.inquire as jest.Mock).mockResolvedValueOnce('main'); + + await baseClass.selectBranch(); + + expect(apolloClient.query).toHaveBeenCalledWith({ + query: expect.anything(), + variables: { + page: 1, + first: 100, + query: { provider: 'GitHub', repoName: 'test-user/eleventy-sample', namespace: 'org-account' }, + }, + }); + }); + }); }); diff --git a/src/adapters/base-class.ts b/src/adapters/base-class.ts index a264347..c2364c8 100755 --- a/src/adapters/base-class.ts +++ b/src/adapters/base-class.ts @@ -160,6 +160,7 @@ export default class BaseClass { */ async detectFramework(): Promise { const { fullName, defaultBranch } = this.config.repository || {}; + const namespace = this.config.userConnection?.namespace; const query = this.config.provider === 'FileUpload' ? fileFrameworkQuery : frameworkQuery; const variables = this.config.provider === 'FileUpload' @@ -171,6 +172,7 @@ export default class BaseClass { provider: this.config.provider, repoName: fullName, branchName: defaultBranch, + ...(namespace ? { namespace } : {}), }, }; this.config.framework = (await this.apolloClient @@ -464,12 +466,14 @@ export default class BaseClass { * @memberof BaseClass */ async selectBranch(): Promise { + const namespace = this.config.userConnection?.namespace; const variables = { page: 1, first: 100, query: { provider: this.config.provider, repoName: this.config.repository?.fullName, + ...(namespace ? { namespace } : {}), }, }; diff --git a/src/adapters/github.test.ts b/src/adapters/github.test.ts index 2b587e4..e01e21f 100644 --- a/src/adapters/github.test.ts +++ b/src/adapters/github.test.ts @@ -106,6 +106,83 @@ describe('GitHub Adapter', () => { expect(connectToAdapterOnUiMock).toHaveBeenCalled(); expect(githubAdapterInstance.config.userConnection).toEqual(undefined); }); + + it('should prompt the user to select a connection when multiple GitHub connections exist', async () => { + const multipleUserConnections = [ + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' }, + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' }, + ]; + const userConnectionResponse = { data: { userConnections: multipleUserConnections } }; + const apolloClient = { + query: jest.fn().mockResolvedValueOnce(userConnectionResponse), + } as any; + const githubAdapterInstance = new GitHub({ + config: { projectBasePath: '/home/project1', provider: 'GitHub' }, + apolloClient: apolloClient, + log: logMock, + } as any); + (ux.inquire as jest.Mock).mockResolvedValueOnce('org-account'); + + await githubAdapterInstance.checkGitHubConnected(); + + expect(ux.inquire).toHaveBeenCalledWith({ + type: 'search-list', + name: 'userConnection', + message: 'Choose a GitHub Namespace', + choices: ['personal-account', 'org-account'], + }); + expect(githubAdapterInstance.config.userConnection).toEqual(multipleUserConnections[1]); + }); + + it('should use the --namespace flag to select a connection without prompting', async () => { + const multipleUserConnections = [ + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' }, + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' }, + ]; + const userConnectionResponse = { data: { userConnections: multipleUserConnections } }; + const apolloClient = { + query: jest.fn().mockResolvedValueOnce(userConnectionResponse), + } as any; + const githubAdapterInstance = new GitHub({ + config: { projectBasePath: '/home/project1', provider: 'GitHub', flags: { namespace: 'org-account' } }, + apolloClient: apolloClient, + log: logMock, + } as any); + + await githubAdapterInstance.checkGitHubConnected(); + + expect(ux.inquire).not.toHaveBeenCalled(); + expect(githubAdapterInstance.config.userConnection).toEqual(multipleUserConnections[1]); + }); + + it('should log an error and exit if the --namespace flag does not match any connection', async () => { + const multipleUserConnections = [ + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' }, + { __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' }, + ]; + const userConnectionResponse = { data: { userConnections: multipleUserConnections } }; + const apolloClient = { + query: jest.fn().mockResolvedValueOnce(userConnectionResponse), + } as any; + const githubAdapterInstance = new GitHub({ + config: { projectBasePath: '/home/project1', provider: 'GitHub', flags: { namespace: 'unknown-account' } }, + apolloClient: apolloClient, + log: logMock, + exit: exitMock, + } as any); + + let err; + try { + await githubAdapterInstance.checkGitHubConnected(); + } catch (error: any) { + err = error; + } + + expect(ux.inquire).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith('GitHub connection namespace not found!', 'error'); + expect(exitMock).toHaveBeenCalledWith(1); + expect(err).toEqual(new Error('1')); + }); }); describe('checkGitRemoteAvailableAndValid', () => { @@ -212,6 +289,31 @@ describe('GitHub Adapter', () => { expect(result).toBe(true); }); + it('should scope the repositories query to the selected GitHub connection namespace', async () => { + (existsSync as jest.Mock).mockReturnValueOnce(true); + (getRemoteUrls as jest.Mock).mockResolvedValueOnce({ + origin: 'https://github.com/test-user/eleventy-sample.git', + }); + const apolloClient = { + query: jest.fn().mockResolvedValueOnce(repositoriesResponse), + } as any; + const githubAdapterInstance = new GitHub({ + config: { + projectBasePath: '/home/project1', + provider: 'GitHub', + userConnection: { provider: 'GitHub', namespace: 'org-account' }, + }, + apolloClient: apolloClient, + } as any); + + await githubAdapterInstance.checkGitRemoteAvailableAndValid(); + + expect(apolloClient.query).toHaveBeenCalledWith({ + query: repositoriesQuery, + variables: { page: 1, first: 100, query: { provider: 'GitHub', namespace: 'org-account' } }, + }); + }); + it('should log an error and exit if git config file does not exists', async () => { (existsSync as jest.Mock).mockReturnValueOnce(false); const githubAdapterInstance = new GitHub({ @@ -298,6 +400,37 @@ describe('GitHub Adapter', () => { expect(err).toEqual(new Error('1')); }); + it('should reference the selected connection namespace when the GitHub app is uninstalled', async () => { + (existsSync as jest.Mock).mockReturnValueOnce(true); + (getRemoteUrls as jest.Mock).mockResolvedValueOnce({ + origin: 'https://github.com/test-user/eleventy-sample.git', + }); + const apolloClient = { + query: jest.fn().mockRejectedValue(new Error('GitHub app error')), + } as any; + jest.spyOn(BaseClass.prototype, 'connectToAdapterOnUi').mockResolvedValueOnce(); + const githubAdapterInstance = new GitHub({ + config: { + projectBasePath: '/home/project1', + userConnection: { provider: 'GitHub', namespace: 'org-account' }, + }, + apolloClient: apolloClient, + log: logMock, + exit: exitMock, + } as any); + + try { + await githubAdapterInstance.checkGitRemoteAvailableAndValid(); + } catch { + // exitMock throws to halt the flow under test, same as sibling tests in this file + } + + expect(logMock).toHaveBeenCalledWith( + 'GitHub app uninstalled for the "org-account" connection. Please reconnect the app and try again', + 'error', + ); + }); + it('should log an error and exit if repository is not found in the list of available repositories', async () => { (existsSync as jest.Mock).mockReturnValueOnce(true); (getRemoteUrls as jest.Mock).mockResolvedValueOnce({ @@ -464,6 +597,46 @@ describe('GitHub Adapter', () => { expect(exitMock).toHaveBeenCalledWith(1); expect(err).toEqual(new Error('1')); }); + + it('should reference the selected connection namespace when the repository is beyond the checked pages', async () => { + (existsSync as jest.Mock).mockReturnValueOnce(true); + (getRemoteUrls as jest.Mock).mockResolvedValueOnce({ + origin: 'https://github.com/test-user/missing-repo.git', + }); + const apolloClient = { + query: jest.fn().mockImplementation(({ variables }) => { + const { page, first } = variables; + const edges = Array.from({ length: first }, (_, i) => ({ + node: { + __typename: 'GitRepository', + id: `${(page - 1) * first + i}`, + url: `https://github.com/test-user/repo-${(page - 1) * first + i}`, + name: `repo-${(page - 1) * first + i}`, + fullName: `test-user/repo-${(page - 1) * first + i}`, + defaultBranch: 'main', + }, + })); + return Promise.resolve({ data: { repositories: { edges, pageData: { page }, pageInfo: { hasNextPage: true } } } }); + }), + } as any; + const githubAdapterInstance = new GitHub({ + config: { + projectBasePath: '/home/project1', + userConnection: { provider: 'GitHub', namespace: 'org-account' }, + }, + log: logMock, + exit: exitMock, + apolloClient: apolloClient, + } as any); + + try { + await githubAdapterInstance.checkGitRemoteAvailableAndValid(); + } catch { + // exitMock throws to halt the flow under test, same as sibling tests in this file + } + + expect(logMock).toHaveBeenCalledWith(expect.stringContaining('the "org-account" connection'), 'error'); + }); }); describe('runGitHubFlow', () => { diff --git a/src/adapters/github.ts b/src/adapters/github.ts index ff77f51..57f1111 100755 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -3,6 +3,7 @@ import { resolve } from 'path'; import omit from 'lodash/omit'; import find from 'lodash/find'; import split from 'lodash/split'; +import filter from 'lodash/filter'; import { exec } from 'child_process'; import includes from 'lodash/includes'; import { configHandler, cliux as ux } from '@contentstack/cli-utilities'; @@ -240,7 +241,7 @@ export default class GitHub extends BaseClass { }); if (serverCommandInput) { this.config.serverCommand = serverCommandInput; - } + } } else { this.config.serverCommand = serverCommand; } @@ -296,10 +297,33 @@ export default class GitHub extends BaseClass { .then(({ data: { userConnections } }) => userConnections) .catch((error) => this.log(error, 'error')); - const userConnection = find(userConnections, { + const matchingConnections = filter(userConnections, { provider: this.config.provider, }); + const namespaceFlag = this.config.flags?.namespace; + let userConnection; + if (namespaceFlag) { + userConnection = find(matchingConnections, { namespace: namespaceFlag }); + if (!userConnection) { + this.log('GitHub connection namespace not found!', 'error'); + this.exit(1); + } + } else if (matchingConnections.length > 1) { + const selectedNamespace = await ux.inquire({ + type: 'search-list', + name: 'userConnection', + message: 'Choose a GitHub Namespace', + choices: map(matchingConnections, (connection) => connection.namespace || connection.userUid), + }); + userConnection = find( + matchingConnections, + (connection) => (connection.namespace || connection.userUid) === selectedNamespace, + ); + } else { + userConnection = matchingConnections[0]; + } + if (userConnection) { this.log('GitHub connection identified!', 'info'); this.config.userConnection = userConnection; @@ -353,11 +377,20 @@ export default class GitHub extends BaseClass { this.exit(1); } + const namespace = this.config.userConnection?.namespace; let repositories: Repository[] = []; try { - repositories = await this.queryRepositories({ page: 1, first: REPOSITORY_PAGE_SIZE }); + repositories = await this.queryRepositories({ + page: 1, + first: REPOSITORY_PAGE_SIZE, + ...(namespace ? { query: { provider: this.config.provider, namespace } } : {}), + }); } catch { - this.log('GitHub app uninstalled. Please reconnect the app and try again', 'error'); + this.log( + `GitHub app uninstalled${namespace ? ` for the "${namespace}" connection` : ''}. ` + + 'Please reconnect the app and try again', + 'error', + ); await this.connectToAdapterOnUi(); this.exit(1); } @@ -379,9 +412,12 @@ export default class GitHub extends BaseClass { if (!this.config.repository) { const checkedCount = MAX_REPOSITORY_PAGES * REPOSITORY_PAGE_SIZE; if (repositories.length >= checkedCount) { + const installationSettingsLocation = namespace + ? `In the GitHub App installation settings for the "${namespace}" connection` + : 'In your GitHub App\'s installation settings'; this.log( `"${repoFullName}" is beyond the first ${checkedCount} repositories the GitHub App can access. ` + - 'In your GitHub App\'s installation settings, under "Repository access", select ' + + `${installationSettingsLocation}, under "Repository access", select ` + '"Only select repositories" and add the repository you want to deploy. Then re-run the command.', 'error', ); diff --git a/src/commands/launch/index.ts b/src/commands/launch/index.ts index 88ca0fe..d00130c 100755 --- a/src/commands/launch/index.ts +++ b/src/commands/launch/index.ts @@ -61,6 +61,10 @@ export default class Launch extends BaseCommand { branch: Flags.string({ description: '[optional] GitHub branch name.', }), + namespace: Flags.string({ + description: + '[optional] GitHub connection namespace, to select among multiple connected GitHub accounts/organizations.', + }), 'build-command': Flags.string({ description: '[optional] Build Command.', }), diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index e9ccdf3..97b6cdc 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -7,6 +7,7 @@ const userConnectionsQuery: DocumentNode = gql` userConnections: UserConnections(query: $query) { userUid provider + namespace } } `; @@ -108,7 +109,6 @@ const projectsQuery: DocumentNode = gql` username gitProviderMetadata { ... on GitHubMetadata { - connectionUid gitProvider } } diff --git a/src/types/launch.ts b/src/types/launch.ts index 89574fb..cf02ee6 100755 --- a/src/types/launch.ts +++ b/src/types/launch.ts @@ -57,6 +57,11 @@ type ConfigType = { deliveryToken?: Record; isStreamingEnabled?: boolean; isContentstackAuthenticationEnabled?: boolean; + userConnection?: { + userUid: string; + provider: string; + namespace?: string; + }; } & typeof config & Record;