Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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. ` +
Comment thread
dhruv-parekh-cs marked this conversation as resolved.
'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',
);
Comment thread
dhruv-parekh-cs marked this conversation as resolved.
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) {
Comment thread
Harshi-Shah-CS marked this conversation as resolved.
Comment thread
dhruv-parekh-cs marked this conversation as resolved.
return this.queryRepositories({ ...variables, first, page: page + 1 }, repositoriesRes);
}

return repositoriesRes;
}

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