Skip to content

Commit b2d4764

Browse files
rohan-agrawal-cstejas-contentstackclaude
committed
feat: add support for choosing namespace in case of multiple github connections.
- Added functionality to scope queries to the selected GitHub connection namespace in BaseClass methods detectFramework and selectBranch. - Updated GitHub adapter to handle multiple user connections, allowing users to select a namespace or use a --namespace flag. - Enhanced tests to cover new namespace selection logic and ensure proper handling of GitHub connections. - Updated GraphQL queries and types to include namespace information for user connections. #claude_code# 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Tejas Talekar <tejas.talekar@contentstack.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e2ab44b commit b2d4764

7 files changed

Lines changed: 297 additions & 5 deletions

File tree

src/adapters/base-class.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,4 +685,71 @@ describe('BaseClass', () => {
685685
expect(exitMock).toHaveBeenCalledWith(1);
686686
});
687687
});
688+
689+
describe('detectFramework', () => {
690+
it('should scope the framework query to the selected GitHub connection namespace', async () => {
691+
const apolloClient = {
692+
query: jest.fn().mockResolvedValueOnce({ data: { framework: { framework: 'GATSBY' } } }),
693+
} as any;
694+
baseClass = new BaseClass({
695+
log: logMock,
696+
exit: exitMock,
697+
apolloClient,
698+
config: {
699+
provider: 'GitHub',
700+
listOfFrameWorks: [],
701+
repository: { fullName: 'test-user/eleventy-sample', defaultBranch: 'main' },
702+
userConnection: { provider: 'GitHub', namespace: 'org-account' },
703+
},
704+
} as any);
705+
(ux.inquire as jest.Mock).mockResolvedValueOnce('GATSBY');
706+
707+
await baseClass.detectFramework();
708+
709+
expect(apolloClient.query).toHaveBeenCalledWith({
710+
query: expect.anything(),
711+
variables: {
712+
query: {
713+
provider: 'GitHub',
714+
repoName: 'test-user/eleventy-sample',
715+
branchName: 'main',
716+
namespace: 'org-account',
717+
},
718+
},
719+
});
720+
});
721+
});
722+
723+
describe('selectBranch', () => {
724+
it('should scope the branches query to the selected GitHub connection namespace', async () => {
725+
const apolloClient = {
726+
query: jest.fn().mockResolvedValueOnce({
727+
data: { branches: { edges: [], pageData: { page: 1 }, pageInfo: { hasNextPage: false } } },
728+
}),
729+
} as any;
730+
baseClass = new BaseClass({
731+
log: logMock,
732+
exit: exitMock,
733+
apolloClient,
734+
config: {
735+
provider: 'GitHub',
736+
flags: {},
737+
repository: { fullName: 'test-user/eleventy-sample' },
738+
userConnection: { provider: 'GitHub', namespace: 'org-account' },
739+
},
740+
} as any);
741+
(ux.inquire as jest.Mock).mockResolvedValueOnce('main');
742+
743+
await baseClass.selectBranch();
744+
745+
expect(apolloClient.query).toHaveBeenCalledWith({
746+
query: expect.anything(),
747+
variables: {
748+
page: 1,
749+
first: 100,
750+
query: { provider: 'GitHub', repoName: 'test-user/eleventy-sample', namespace: 'org-account' },
751+
},
752+
});
753+
});
754+
});
688755
});

src/adapters/base-class.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export default class BaseClass {
160160
*/
161161
async detectFramework(): Promise<void> {
162162
const { fullName, defaultBranch } = this.config.repository || {};
163+
const namespace = this.config.userConnection?.namespace;
163164
const query = this.config.provider === 'FileUpload' ? fileFrameworkQuery : frameworkQuery;
164165
const variables =
165166
this.config.provider === 'FileUpload'
@@ -171,6 +172,7 @@ export default class BaseClass {
171172
provider: this.config.provider,
172173
repoName: fullName,
173174
branchName: defaultBranch,
175+
...(namespace ? { namespace } : {}),
174176
},
175177
};
176178
this.config.framework = (await this.apolloClient
@@ -464,12 +466,14 @@ export default class BaseClass {
464466
* @memberof BaseClass
465467
*/
466468
async selectBranch(): Promise<void> {
469+
const namespace = this.config.userConnection?.namespace;
467470
const variables = {
468471
page: 1,
469472
first: 100,
470473
query: {
471474
provider: this.config.provider,
472475
repoName: this.config.repository?.fullName,
476+
...(namespace ? { namespace } : {}),
473477
},
474478
};
475479

src/adapters/github.test.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,83 @@ describe('GitHub Adapter', () => {
106106
expect(connectToAdapterOnUiMock).toHaveBeenCalled();
107107
expect(githubAdapterInstance.config.userConnection).toEqual(undefined);
108108
});
109+
110+
it('should prompt the user to select a connection when multiple GitHub connections exist', async () => {
111+
const multipleUserConnections = [
112+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' },
113+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' },
114+
];
115+
const userConnectionResponse = { data: { userConnections: multipleUserConnections } };
116+
const apolloClient = {
117+
query: jest.fn().mockResolvedValueOnce(userConnectionResponse),
118+
} as any;
119+
const githubAdapterInstance = new GitHub({
120+
config: { projectBasePath: '/home/project1', provider: 'GitHub' },
121+
apolloClient: apolloClient,
122+
log: logMock,
123+
} as any);
124+
(ux.inquire as jest.Mock).mockResolvedValueOnce('org-account');
125+
126+
await githubAdapterInstance.checkGitHubConnected();
127+
128+
expect(ux.inquire).toHaveBeenCalledWith({
129+
type: 'search-list',
130+
name: 'userConnection',
131+
message: 'Choose a GitHub Namespace',
132+
choices: ['personal-account', 'org-account'],
133+
});
134+
expect(githubAdapterInstance.config.userConnection).toEqual(multipleUserConnections[1]);
135+
});
136+
137+
it('should use the --namespace flag to select a connection without prompting', async () => {
138+
const multipleUserConnections = [
139+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' },
140+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' },
141+
];
142+
const userConnectionResponse = { data: { userConnections: multipleUserConnections } };
143+
const apolloClient = {
144+
query: jest.fn().mockResolvedValueOnce(userConnectionResponse),
145+
} as any;
146+
const githubAdapterInstance = new GitHub({
147+
config: { projectBasePath: '/home/project1', provider: 'GitHub', flags: { namespace: 'org-account' } },
148+
apolloClient: apolloClient,
149+
log: logMock,
150+
} as any);
151+
152+
await githubAdapterInstance.checkGitHubConnected();
153+
154+
expect(ux.inquire).not.toHaveBeenCalled();
155+
expect(githubAdapterInstance.config.userConnection).toEqual(multipleUserConnections[1]);
156+
});
157+
158+
it('should log an error and exit if the --namespace flag does not match any connection', async () => {
159+
const multipleUserConnections = [
160+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'personal-account' },
161+
{ __typename: 'UserConnection', userUid: 'testuser1', provider: 'GitHub', namespace: 'org-account' },
162+
];
163+
const userConnectionResponse = { data: { userConnections: multipleUserConnections } };
164+
const apolloClient = {
165+
query: jest.fn().mockResolvedValueOnce(userConnectionResponse),
166+
} as any;
167+
const githubAdapterInstance = new GitHub({
168+
config: { projectBasePath: '/home/project1', provider: 'GitHub', flags: { namespace: 'unknown-account' } },
169+
apolloClient: apolloClient,
170+
log: logMock,
171+
exit: exitMock,
172+
} as any);
173+
174+
let err;
175+
try {
176+
await githubAdapterInstance.checkGitHubConnected();
177+
} catch (error: any) {
178+
err = error;
179+
}
180+
181+
expect(ux.inquire).not.toHaveBeenCalled();
182+
expect(logMock).toHaveBeenCalledWith('GitHub connection namespace not found!', 'error');
183+
expect(exitMock).toHaveBeenCalledWith(1);
184+
expect(err).toEqual(new Error('1'));
185+
});
109186
});
110187

111188
describe('checkGitRemoteAvailableAndValid', () => {
@@ -212,6 +289,31 @@ describe('GitHub Adapter', () => {
212289
expect(result).toBe(true);
213290
});
214291

292+
it('should scope the repositories query to the selected GitHub connection namespace', async () => {
293+
(existsSync as jest.Mock).mockReturnValueOnce(true);
294+
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
295+
origin: 'https://github.com/test-user/eleventy-sample.git',
296+
});
297+
const apolloClient = {
298+
query: jest.fn().mockResolvedValueOnce(repositoriesResponse),
299+
} as any;
300+
const githubAdapterInstance = new GitHub({
301+
config: {
302+
projectBasePath: '/home/project1',
303+
provider: 'GitHub',
304+
userConnection: { provider: 'GitHub', namespace: 'org-account' },
305+
},
306+
apolloClient: apolloClient,
307+
} as any);
308+
309+
await githubAdapterInstance.checkGitRemoteAvailableAndValid();
310+
311+
expect(apolloClient.query).toHaveBeenCalledWith({
312+
query: repositoriesQuery,
313+
variables: { page: 1, first: 100, query: { provider: 'GitHub', namespace: 'org-account' } },
314+
});
315+
});
316+
215317
it('should log an error and exit if git config file does not exists', async () => {
216318
(existsSync as jest.Mock).mockReturnValueOnce(false);
217319
const githubAdapterInstance = new GitHub({
@@ -298,6 +400,37 @@ describe('GitHub Adapter', () => {
298400
expect(err).toEqual(new Error('1'));
299401
});
300402

403+
it('should reference the selected connection namespace when the GitHub app is uninstalled', async () => {
404+
(existsSync as jest.Mock).mockReturnValueOnce(true);
405+
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
406+
origin: 'https://github.com/test-user/eleventy-sample.git',
407+
});
408+
const apolloClient = {
409+
query: jest.fn().mockRejectedValue(new Error('GitHub app error')),
410+
} as any;
411+
jest.spyOn(BaseClass.prototype, 'connectToAdapterOnUi').mockResolvedValueOnce();
412+
const githubAdapterInstance = new GitHub({
413+
config: {
414+
projectBasePath: '/home/project1',
415+
userConnection: { provider: 'GitHub', namespace: 'org-account' },
416+
},
417+
apolloClient: apolloClient,
418+
log: logMock,
419+
exit: exitMock,
420+
} as any);
421+
422+
try {
423+
await githubAdapterInstance.checkGitRemoteAvailableAndValid();
424+
} catch {
425+
// exitMock throws to halt the flow under test, same as sibling tests in this file
426+
}
427+
428+
expect(logMock).toHaveBeenCalledWith(
429+
'GitHub app uninstalled for the "org-account" connection. Please reconnect the app and try again',
430+
'error',
431+
);
432+
});
433+
301434
it('should log an error and exit if repository is not found in the list of available repositories', async () => {
302435
(existsSync as jest.Mock).mockReturnValueOnce(true);
303436
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
@@ -464,6 +597,46 @@ describe('GitHub Adapter', () => {
464597
expect(exitMock).toHaveBeenCalledWith(1);
465598
expect(err).toEqual(new Error('1'));
466599
});
600+
601+
it('should reference the selected connection namespace when the repository is beyond the checked pages', async () => {
602+
(existsSync as jest.Mock).mockReturnValueOnce(true);
603+
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
604+
origin: 'https://github.com/test-user/missing-repo.git',
605+
});
606+
const apolloClient = {
607+
query: jest.fn().mockImplementation(({ variables }) => {
608+
const { page, first } = variables;
609+
const edges = Array.from({ length: first }, (_, i) => ({
610+
node: {
611+
__typename: 'GitRepository',
612+
id: `${(page - 1) * first + i}`,
613+
url: `https://github.com/test-user/repo-${(page - 1) * first + i}`,
614+
name: `repo-${(page - 1) * first + i}`,
615+
fullName: `test-user/repo-${(page - 1) * first + i}`,
616+
defaultBranch: 'main',
617+
},
618+
}));
619+
return Promise.resolve({ data: { repositories: { edges, pageData: { page }, pageInfo: { hasNextPage: true } } } });
620+
}),
621+
} as any;
622+
const githubAdapterInstance = new GitHub({
623+
config: {
624+
projectBasePath: '/home/project1',
625+
userConnection: { provider: 'GitHub', namespace: 'org-account' },
626+
},
627+
log: logMock,
628+
exit: exitMock,
629+
apolloClient: apolloClient,
630+
} as any);
631+
632+
try {
633+
await githubAdapterInstance.checkGitRemoteAvailableAndValid();
634+
} catch {
635+
// exitMock throws to halt the flow under test, same as sibling tests in this file
636+
}
637+
638+
expect(logMock).toHaveBeenCalledWith(expect.stringContaining('the "org-account" connection'), 'error');
639+
});
467640
});
468641

469642
describe('runGitHubFlow', () => {

src/adapters/github.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolve } from 'path';
33
import omit from 'lodash/omit';
44
import find from 'lodash/find';
55
import split from 'lodash/split';
6+
import filter from 'lodash/filter';
67
import { exec } from 'child_process';
78
import includes from 'lodash/includes';
89
import { configHandler, cliux as ux } from '@contentstack/cli-utilities';
@@ -240,7 +241,7 @@ export default class GitHub extends BaseClass {
240241
});
241242
if (serverCommandInput) {
242243
this.config.serverCommand = serverCommandInput;
243-
}
244+
}
244245
} else {
245246
this.config.serverCommand = serverCommand;
246247
}
@@ -296,10 +297,33 @@ export default class GitHub extends BaseClass {
296297
.then(({ data: { userConnections } }) => userConnections)
297298
.catch((error) => this.log(error, 'error'));
298299

299-
const userConnection = find(userConnections, {
300+
const matchingConnections = filter(userConnections, {
300301
provider: this.config.provider,
301302
});
302303

304+
const namespaceFlag = this.config.flags?.namespace;
305+
let userConnection;
306+
if (namespaceFlag) {
307+
userConnection = find(matchingConnections, { namespace: namespaceFlag });
308+
if (!userConnection) {
309+
this.log('GitHub connection namespace not found!', 'error');
310+
this.exit(1);
311+
}
312+
} else if (matchingConnections.length > 1) {
313+
const selectedNamespace = await ux.inquire({
314+
type: 'search-list',
315+
name: 'userConnection',
316+
message: 'Choose a GitHub Namespace',
317+
choices: map(matchingConnections, (connection) => connection.namespace || connection.userUid),
318+
});
319+
userConnection = find(
320+
matchingConnections,
321+
(connection) => (connection.namespace || connection.userUid) === selectedNamespace,
322+
);
323+
} else {
324+
userConnection = matchingConnections[0];
325+
}
326+
303327
if (userConnection) {
304328
this.log('GitHub connection identified!', 'info');
305329
this.config.userConnection = userConnection;
@@ -353,11 +377,20 @@ export default class GitHub extends BaseClass {
353377
this.exit(1);
354378
}
355379

380+
const namespace = this.config.userConnection?.namespace;
356381
let repositories: Repository[] = [];
357382
try {
358-
repositories = await this.queryRepositories({ page: 1, first: REPOSITORY_PAGE_SIZE });
383+
repositories = await this.queryRepositories({
384+
page: 1,
385+
first: REPOSITORY_PAGE_SIZE,
386+
...(namespace ? { query: { provider: this.config.provider, namespace } } : {}),
387+
});
359388
} catch {
360-
this.log('GitHub app uninstalled. Please reconnect the app and try again', 'error');
389+
this.log(
390+
`GitHub app uninstalled${namespace ? ` for the "${namespace}" connection` : ''}. ` +
391+
'Please reconnect the app and try again',
392+
'error',
393+
);
361394
await this.connectToAdapterOnUi();
362395
this.exit(1);
363396
}
@@ -379,9 +412,12 @@ export default class GitHub extends BaseClass {
379412
if (!this.config.repository) {
380413
const checkedCount = MAX_REPOSITORY_PAGES * REPOSITORY_PAGE_SIZE;
381414
if (repositories.length >= checkedCount) {
415+
const installationSettingsLocation = namespace
416+
? `In the GitHub App installation settings for the "${namespace}" connection`
417+
: 'In your GitHub App\'s installation settings';
382418
this.log(
383419
`"${repoFullName}" is beyond the first ${checkedCount} repositories the GitHub App can access. ` +
384-
'In your GitHub App\'s installation settings, under "Repository access", select ' +
420+
`${installationSettingsLocation}, under "Repository access", select ` +
385421
'"Only select repositories" and add the repository you want to deploy. Then re-run the command.',
386422
'error',
387423
);

0 commit comments

Comments
 (0)