Skip to content
Closed
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentstack/cli-launch",
"version": "1.10.1",
"version": "1.11.0",
"description": "Launch related operations",
"author": "Contentstack CLI",
"bin": {
Expand Down
184 changes: 179 additions & 5 deletions src/adapters/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { githubAdapter as cliUtilitiesJestMock } from '../test/mocks/cli-utilities';
import GitHub from './github';
import GitHub, { MAX_REPOSITORY_PAGES } from './github';
import { getRemoteUrls } from '../util/create-git-meta';
import { repositoriesQuery, userConnectionsQuery } from '../graphql';
import BaseClass from './base-class';
Expand Down Expand Up @@ -109,7 +109,15 @@ describe('GitHub Adapter', () => {
});

describe('checkGitRemoteAvailableAndValid', () => {
const repositoriesResponse = { data: { repositories } };
const repositoriesResponse = {
data: {
repositories: {
edges: repositories.map((node) => ({ node })),
pageData: { page: 1 },
pageInfo: { hasNextPage: false },
},
},
};

it(`should successfully check if the git remote is available and valid
when the github remote URL is HTTPS based`, async () => {
Expand All @@ -129,7 +137,37 @@ describe('GitHub Adapter', () => {

expect(existsSync).toHaveBeenCalledWith('/home/project1/.git');
expect(getRemoteUrls as jest.Mock).toHaveBeenCalledWith('/home/project1/.git/config');
expect(apolloClient.query).toHaveBeenCalledWith({ query: repositoriesQuery });
expect(apolloClient.query).toHaveBeenCalledWith({
query: repositoriesQuery,
variables: { page: 1, first: 100 },
});
expect(githubAdapterInstance.config.repository).toEqual({
__typename: 'GitRepository',
id: '647250661',
url: 'https://github.com/test-user/eleventy-sample',
name: 'eleventy-sample',
fullName: 'test-user/eleventy-sample',
defaultBranch: 'main',
});
expect(result).toBe(true);
});

it(`should successfully check if the git remote is available and valid
when the github remote URL embeds userinfo (https://user@github.com/...)`, async () => {
(existsSync as jest.Mock).mockReturnValueOnce(true);
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
origin: 'https://test-user@github.com/test-user/eleventy-sample.git',
});
const apolloClient = {
query: jest.fn().mockResolvedValueOnce(repositoriesResponse),
} as any;
const githubAdapterInstance = new GitHub({
config: { projectBasePath: '/home/project1' },
apolloClient: apolloClient,
} as any);

const result = await githubAdapterInstance.checkGitRemoteAvailableAndValid();

expect(githubAdapterInstance.config.repository).toEqual({
__typename: 'GitRepository',
id: '647250661',
Expand Down Expand Up @@ -159,7 +197,10 @@ describe('GitHub Adapter', () => {

expect(existsSync).toHaveBeenCalledWith('/home/project1/.git');
expect(getRemoteUrls as jest.Mock).toHaveBeenCalledWith('/home/project1/.git/config');
expect(apolloClient.query).toHaveBeenCalledWith({ query: repositoriesQuery });
expect(apolloClient.query).toHaveBeenCalledWith({
query: repositoriesQuery,
variables: { page: 1, first: 100 },
});
expect(githubAdapterInstance.config.repository).toEqual({
__typename: 'GitRepository',
id: '647250661',
Expand Down Expand Up @@ -281,7 +322,10 @@ describe('GitHub Adapter', () => {

expect(existsSync).toHaveBeenCalledWith('/home/project1/.git');
expect(getRemoteUrls as jest.Mock).toHaveBeenCalledWith('/home/project1/.git/config');
expect(apolloClient.query).toHaveBeenCalledWith({ query: repositoriesQuery });
expect(apolloClient.query).toHaveBeenCalledWith({
query: repositoriesQuery,
variables: { page: 1, first: 100 },
});
expect(logMock).toHaveBeenCalledWith(
'Repository not added to the GitHub app. Please add it to the app’s repository access list and try again.',
'error',
Expand All @@ -290,6 +334,136 @@ describe('GitHub Adapter', () => {
expect(err).toEqual(new Error('1'));
expect(githubAdapterInstance.config.repository).toBeUndefined();
});

it('should log an error and exit if the remote URL format is unsupported', async () => {
(existsSync as jest.Mock).mockReturnValueOnce(true);
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
origin: 'https://gitlab.com/test-user/some-repo.git',
});
const apolloClient = {
query: jest.fn().mockResolvedValueOnce(repositoriesResponse),
} as any;
const githubAdapterInstance = new GitHub({
config: { projectBasePath: '/home/project1' },
log: logMock,
exit: exitMock,
apolloClient: apolloClient,
} as any);
let err;

try {
await githubAdapterInstance.checkGitRemoteAvailableAndValid();
} catch (error: any) {
err = error;
}

expect(logMock).toHaveBeenCalledWith(
'Unsupported Git remote URL format: https://gitlab.com/test-user/some-repo.git. Please use a standard GitHub HTTPS or SSH remote URL.',
'error',
);
expect(exitMock).toHaveBeenCalledWith(1);
expect(err).toEqual(new Error('1'));
expect(githubAdapterInstance.config.repository).toBeUndefined();
});

it('should paginate beyond the first page to find a repository on a later page', async () => {
(existsSync as jest.Mock).mockReturnValueOnce(true);
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
origin: 'https://github.com/test-user/repo-301.git',
});

const PER_PAGE = 100;
const FULL_PAGES = 3; // target sits on the 4th (partial) page, well within the cap
const targetRepo = {
__typename: 'GitRepository',
id: '301',
url: 'https://github.com/test-user/repo-301',
name: 'repo-301',
fullName: 'test-user/repo-301',
defaultBranch: 'main',
};

const apolloClient = {
query: jest.fn().mockImplementation(({ variables }) => {
const { page } = variables;
const edges =
page <= FULL_PAGES
? Array.from({ length: PER_PAGE }, (_, i) => ({
node: {
__typename: 'GitRepository',
id: `${(page - 1) * PER_PAGE + i}`,
url: `https://github.com/test-user/repo-${(page - 1) * PER_PAGE + i}`,
name: `repo-${(page - 1) * PER_PAGE + i}`,
fullName: `test-user/repo-${(page - 1) * PER_PAGE + i}`,
defaultBranch: 'main',
},
}))
: [{ node: targetRepo }];
return Promise.resolve({ data: { repositories: { edges, pageData: { page }, pageInfo: { hasNextPage: false } } } });
}),
} as any;
const githubAdapterInstance = new GitHub({
config: { projectBasePath: '/home/project1' },
apolloClient: apolloClient,
} as any);

const result = await githubAdapterInstance.checkGitRemoteAvailableAndValid();

// 3 full pages + 1 partial page = 4 requests.
expect(apolloClient.query).toHaveBeenCalledTimes(FULL_PAGES + 1);
expect(githubAdapterInstance.config.repository).toEqual(targetRepo);
expect(result).toBe(true);
});

it(`should cap pagination at ${MAX_REPOSITORY_PAGES} pages (1000 repositories) for consistency with the management service`, async () => {
(existsSync as jest.Mock).mockReturnValueOnce(true);
(getRemoteUrls as jest.Mock).mockResolvedValueOnce({
origin: 'https://github.com/test-user/missing-repo.git',
});

// Every page is full, so without a cap the loop would never stop on its own.
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' },
log: logMock,
exit: exitMock,
apolloClient: apolloClient,
} as any);
let err;

try {
await githubAdapterInstance.checkGitRemoteAvailableAndValid();
} catch (error: any) {
err = error;
}

expect(apolloClient.query).toHaveBeenCalledTimes(MAX_REPOSITORY_PAGES);
expect(logMock).toHaveBeenCalledWith(
expect.stringContaining('beyond the first 1000 repositories the GitHub App can access'),
'error',
);
expect(logMock).not.toHaveBeenCalledWith(
'Repository not added to the GitHub app. Please add it to the app’s repository access list and try again.',
'error',
);
expect(exitMock).toHaveBeenCalledWith(1);
expect(err).toEqual(new Error('1'));
});
});

describe('runGitHubFlow', () => {
Expand Down
79 changes: 66 additions & 13 deletions src/adapters/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { print } from '../util';
import BaseClass from './base-class';
import { getRemoteUrls } from '../util/create-git-meta';
import { repositoriesQuery, userConnectionsQuery, importProjectMutation } from '../graphql';
import { DeploymentStatus } from '../types';
import { DeploymentStatus, Repository } from '../types';
import { existsSync } from 'fs';

export const MAX_REPOSITORY_PAGES = 10;
export const REPOSITORY_PAGE_SIZE = 100;

export default class GitHub extends BaseClass {
/**
* @method run - initialization function
Expand Down Expand Up @@ -71,10 +74,12 @@ export default class GitHub extends BaseClass {
private async handleNewProject(): Promise<void> {
// NOTE Step 1: Check is Github connected
if (await this.checkGitHubConnected()) {
// NOTE Step 2: check is the git remote available in the user's repo list
// NOTE Step 2: Select org first; the GitRepositories query is org-scoped (guarded).
await this.selectOrg();
// NOTE Step 3: check is the git remote available in the user's repo list
if (await this.checkGitRemoteAvailableAndValid()) {
if (await this.checkUserGitHubAccess()) {
// NOTE Step 3: check is the user has proper git access
// NOTE Step 4: check is the user has proper git access
await this.prepareForNewProjectCreation();
}
}
Expand Down Expand Up @@ -168,7 +173,6 @@ export default class GitHub extends BaseClass {
const { token, apiKey } = configHandler.get(`tokens.${alias}`) ?? {};
this.config.selectedStack = apiKey;
this.config.deliveryToken = token;
await this.selectOrg();
print([
{ message: '?', color: 'green' },
{ message: 'Repository', bold: true },
Expand Down Expand Up @@ -292,16 +296,16 @@ export default class GitHub extends BaseClass {
private extractRepoFullNameFromGithubRemoteURL(url: string) {
let match;

// HTTPS format: https://github.com/owner/repo.git
match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)(\.git)?$/);
// HTTPS format: https://[user[:token]@]github.com/owner/repo(.git)(/)
match = url.match(/^https:\/\/(?:[^@/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (match) {
return `${match[1]}/${match[2].replace(/\.git$/, '')}`;
return `${match[1]}/${match[2]}`;
}

// SSH format: git@github.com:owner/repo.git
match = url.match(/^git@github\.com:([^/]+)\/([^/]+)(\.git)?$/);
// SSH format: git@github.com:owner/repo(.git) or ssh://git@github.com/owner/repo(.git)
match = url.match(/^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (match) {
return `${match[1]}/${match[2].replace(/\.git$/, '')}`;
return `${match[1]}/${match[2]}`;
}
}

Expand Down Expand Up @@ -331,10 +335,9 @@ export default class GitHub extends BaseClass {
this.exit(1);
}

let repositories;
let repositories: Repository[] = [];
try {
const repositoriesQueryResponse = await this.apolloClient.query({ query: repositoriesQuery });
repositories = repositoriesQueryResponse.data.repositories;
repositories = await this.queryRepositories({ page: 1, first: REPOSITORY_PAGE_SIZE });
} catch {
this.log('GitHub app uninstalled. Please reconnect the app and try again', 'error');
await this.connectToAdapterOnUi();
Expand All @@ -343,11 +346,30 @@ export default class GitHub extends BaseClass {

const repoFullName = this.extractRepoFullNameFromGithubRemoteURL(localRemoteUrl);

if (!repoFullName) {
this.log(
`Unsupported Git remote URL format: ${localRemoteUrl}. Please use a standard GitHub HTTPS or SSH remote URL.`,
'error',
);
this.exit(1);
}

this.config.repository = find(repositories, {
fullName: repoFullName,
});

if (!this.config.repository) {
const checkedCount = MAX_REPOSITORY_PAGES * REPOSITORY_PAGE_SIZE;
if (repositories.length >= checkedCount) {
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 ' +
'"Only select repositories" and add the repository you want to deploy. Then re-run the command.',
'error',
);
this.exit(1);
}

this.log(
'Repository not added to the GitHub app. Please add it to the app’s repository access list and try again.',
'error',
Expand All @@ -358,6 +380,37 @@ export default class GitHub extends BaseClass {
return true;
}

/**
* @method queryRepositories - Recursively fetch each page of repositories
* accessible to the GitHub app, up to MAX_REPOSITORY_PAGES.
*
* @param {Record<string, any>} variables
* @param {any[]} [repositoriesRes=[]]
* @return {*} {Promise<any[]>}
* @memberof GitHub
*/
async queryRepositories(
variables: Record<string, any> = {},
repositoriesRes: Repository[] = [],
): Promise<Repository[]> {
const first = typeof variables.first === 'number' ? variables.first : REPOSITORY_PAGE_SIZE;
const page = typeof variables.page === 'number' ? variables.page : 1;

const { data: { repositories } } = await this.apolloClient.query({
query: repositoriesQuery,
variables: { ...variables, first, page },
});

const edges = repositories?.edges ?? [];
repositoriesRes.push(...map(edges, 'node'));

if (edges.length === first && page < MAX_REPOSITORY_PAGES) {
return this.queryRepositories({ ...variables, first, page: page + 1 }, repositoriesRes);
}

return repositoriesRes;
}

/**
* @method checkUserGitHubAccess - GitHub user access validation
*
Expand Down
Loading
Loading