From daa6993aac429d1ac5cb65ebdd07052c75db1917 Mon Sep 17 00:00:00 2001 From: Harshi-Shah-CS Date: Wed, 10 Jun 2026 19:13:00 +0530 Subject: [PATCH] fix: paginate repositories and parse all remote URL formats --- package.json | 2 +- src/adapters/github.test.ts | 184 +++++++++++++++++++++++++++++++++++- src/adapters/github.ts | 79 +++++++++++++--- src/graphql/queries.ts | 29 ++++-- src/types/launch.ts | 8 ++ 5 files changed, 275 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 01277b5..07fb4f3 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/cli-launch", - "version": "1.10.1", + "version": "1.11.0", "description": "Launch related operations", "author": "Contentstack CLI", "bin": { diff --git a/src/adapters/github.test.ts b/src/adapters/github.test.ts index 9d30e7a..32d8153 100644 --- a/src/adapters/github.test.ts +++ b/src/adapters/github.test.ts @@ -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'; @@ -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 () => { @@ -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', @@ -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', @@ -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', @@ -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', () => { diff --git a/src/adapters/github.ts b/src/adapters/github.ts index 6494490..8ab2630 100755 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -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 @@ -71,10 +74,12 @@ export default class GitHub extends BaseClass { private async handleNewProject(): Promise { // 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(); } } @@ -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 }, @@ -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]}`; } } @@ -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(); @@ -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', @@ -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} variables + * @param {any[]} [repositoriesRes=[]] + * @return {*} {Promise} + * @memberof GitHub + */ + async queryRepositories( + variables: Record = {}, + repositoriesRes: Repository[] = [], + ): Promise { + 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 * diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 1cb0e81..e9ccdf3 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -12,15 +12,28 @@ const userConnectionsQuery: DocumentNode = gql` `; const repositoriesQuery: DocumentNode = gql` - query Repositories( - $repositoriesInput: RepositoriesInput! = { provider: "GitHub" } + query GitRepositories( + $first: Float = 100 + $page: Float = 1 + $query: RepositoriesInput! = { provider: "GitHub" } ) { - repositories: Repositories(query: $repositoriesInput) { - id - url - name - fullName - defaultBranch + repositories: GitRepositories(query: $query, first: $first, page: $page) { + edges { + node { + id + url + name + fullName + defaultBranch + } + cursor + } + pageData { + page + } + pageInfo { + hasNextPage + } } } `; diff --git a/src/types/launch.ts b/src/types/launch.ts index fa0c05f..7e50cf2 100755 --- a/src/types/launch.ts +++ b/src/types/launch.ts @@ -92,6 +92,14 @@ export type Environment = { frameworkPreset: string; }; +export type Repository = { + id: string; + url: string; + name: string; + fullName: string; + defaultBranch: string; +}; + export enum DeploymentStatus { QUEUED = 'QUEUED', LIVE = 'LIVE',