diff --git a/src/commands/apps/link.test.ts b/src/commands/apps/link.test.ts index fc3bbd8..4e96fe0 100644 --- a/src/commands/apps/link.test.ts +++ b/src/commands/apps/link.test.ts @@ -1,6 +1,15 @@ import { DEFAULT_API_BASE_URL } from '@/config/consts.js'; import authorizationService from '@/services/authorization-service.js'; -import { promptAppSelection, promptOrganizationSelection } from '@/utils/prompt.js'; +import { GitConnectionDto } from '@/types/git-connection.js'; +import { isInteractive } from '@/utils/environment.js'; +import { getGitRemoteUrl } from '@/utils/git.js'; +import { + prompt, + promptAppSelection, + promptGitConnectionSelection, + promptOrganizationSelection, + promptRepositorySelection, +} from '@/utils/prompt.js'; import userConfig from '@/utils/user-config.js'; import consola from 'consola'; import nock from 'nock'; @@ -12,29 +21,71 @@ vi.mock('@/utils/prompt.js'); vi.mock('@/services/authorization-service.js'); vi.mock('consola'); vi.mock('@/utils/environment.js', () => ({ - isInteractive: () => true, + isInteractive: vi.fn(() => true), })); vi.mock('@/utils/git.js', () => ({ - getGitRemoteInfo: () => ({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }), + getGitRemoteUrl: vi.fn(() => 'git@github.com:capawesome-team/cli.git'), })); describe('apps-link', () => { + const appId = 'app-123'; + const orgId = 'org-1'; + const remoteUrl = 'git@github.com:capawesome-team/cli.git'; + const repositoryPath = 'capawesome-team/cli'; + const testToken = 'test-token'; + const gitConnection: GitConnectionDto = { + id: 'gc-1', + authKind: 'oauth', + baseUrl: null, + name: 'My Connection', + organizationId: orgId, + provider: 'github', + restricted: false, + }; + const otherGitConnection: GitConnectionDto = { + ...gitConnection, + id: 'gc-2', + name: 'Other Connection', + }; + const mockUserConfig = vi.mocked(userConfig); + const mockIsInteractive = vi.mocked(isInteractive); + const mockGetGitRemoteUrl = vi.mocked(getGitRemoteUrl); + const mockPrompt = vi.mocked(prompt); const mockPromptOrganizationSelection = vi.mocked(promptOrganizationSelection); const mockPromptAppSelection = vi.mocked(promptAppSelection); + const mockPromptGitConnectionSelection = vi.mocked(promptGitConnectionSelection); + const mockPromptRepositorySelection = vi.mocked(promptRepositorySelection); const mockConsola = vi.mocked(consola); const mockAuthorizationService = vi.mocked(authorizationService); + const nockAppRequest = () => + nock(DEFAULT_API_BASE_URL) + .get(`/v1/apps/${appId}`) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, { id: appId, name: 'Test App', organizationId: orgId }); + + const nockResolveRequest = (gitConnections: GitConnectionDto[], provider: string | null = 'github') => + nock(DEFAULT_API_BASE_URL) + .get(`/v1/organizations/${orgId}/git-connections/resolve`) + .query({ remoteUrl }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, { gitConnections, path: repositoryPath, provider }); + + const nockLinkRequest = (gitConnectionId: string, path: string) => + nock(DEFAULT_API_BASE_URL) + .put(`/v1/apps/${appId}/repository`, { gitConnectionId, path }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, { id: appId, name: 'Test App' }); + beforeEach(() => { vi.clearAllMocks(); mockUserConfig.read.mockReturnValue({ token: 'test-token' }); mockAuthorizationService.getCurrentAuthorizationToken.mockReturnValue('test-token'); mockAuthorizationService.hasAuthorizationToken.mockReturnValue(true); + mockIsInteractive.mockReturnValue(true); + mockGetGitRemoteUrl.mockReturnValue(remoteUrl); vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { throw new Error(`Process exited with code ${code}`); @@ -46,71 +97,183 @@ describe('apps-link', () => { vi.restoreAllMocks(); }); - it('should link repository with provided app ID', async () => { - const appId = 'app-123'; - const testToken = 'test-token'; + it('should link repository with provided git connection ID and path', async () => { + const appScope = nockAppRequest(); + const linkScope = nockLinkRequest(gitConnection.id, repositoryPath); - const options = { appId }; + await linkCommand.action({ appId, gitConnectionId: gitConnection.id, path: repositoryPath }, undefined); - const scope = nock(DEFAULT_API_BASE_URL) - .put(`/v1/apps/${appId}/repository`, { - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }) + expect(appScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); + expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); + }); + + it('should link repository with resolved path when path is not provided', async () => { + const appScope = nockAppRequest(); + const resolveScope = nockResolveRequest([gitConnection]); + const linkScope = nockLinkRequest(gitConnection.id, repositoryPath); + + await linkCommand.action({ appId, gitConnectionId: gitConnection.id }, undefined); + + expect(appScope.isDone()).toBe(true); + expect(resolveScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); + expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); + }); + + it('should resolve git connection by name', async () => { + const appScope = nockAppRequest(); + const connectionsScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/organizations/${orgId}/git-connections`) + .query({ name: gitConnection.name }) .matchHeader('Authorization', `Bearer ${testToken}`) - .reply(200, { id: appId, name: 'Test App' }); + .reply(200, [gitConnection]); + const linkScope = nockLinkRequest(gitConnection.id, repositoryPath); - await linkCommand.action(options, undefined); + await linkCommand.action({ appId, gitConnection: gitConnection.name, path: repositoryPath }, undefined); - expect(scope.isDone()).toBe(true); + expect(appScope.isDone()).toBe(true); + expect(connectionsScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); }); - it('should prompt for organization and app when app ID is not provided', async () => { - const appId = 'app-123'; - const orgId = 'org-1'; - const testToken = 'test-token'; + it('should error when git connection name is not found', async () => { + const appScope = nockAppRequest(); + const connectionsScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/organizations/${orgId}/git-connections`) + .query({ name: 'Unknown' }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, []); + + await expect(linkCommand.action({ appId, gitConnection: 'Unknown' }, undefined)).rejects.toThrow(); + + expect(appScope.isDone()).toBe(true); + expect(connectionsScope.isDone()).toBe(true); + expect(mockConsola.error).toHaveBeenCalledWith('No git connection found with name "Unknown".'); + }); + + it('should error when both git connection ID and name are provided', async () => { + await expect( + linkCommand.action({ appId, gitConnectionId: gitConnection.id, gitConnection: gitConnection.name }, undefined), + ).rejects.toThrow(); + + expect(mockConsola.error).toHaveBeenCalledWith( + 'The --git-connection-id and --git-connection options cannot be used together.', + ); + }); + + it('should error when no git connection is provided in non-interactive environment', async () => { + mockIsInteractive.mockReturnValue(false); + const appScope = nockAppRequest(); - const options = {}; + await expect(linkCommand.action({ appId }, undefined)).rejects.toThrow(); + expect(appScope.isDone()).toBe(true); + expect(mockConsola.error).toHaveBeenCalledWith( + 'You must provide the git connection using the --git-connection-id or --git-connection option when running in non-interactive environment.', + ); + }); + + it('should link repository after confirming the resolved git connection', async () => { mockPromptOrganizationSelection.mockResolvedValueOnce(orgId); mockPromptAppSelection.mockResolvedValueOnce(appId); + mockPrompt.mockResolvedValueOnce(true as never); - const scope = nock(DEFAULT_API_BASE_URL) - .put(`/v1/apps/${appId}/repository`, { - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }) - .matchHeader('Authorization', `Bearer ${testToken}`) - .reply(200, { id: appId, name: 'Test App' }); + const resolveScope = nockResolveRequest([gitConnection]); + const linkScope = nockLinkRequest(gitConnection.id, repositoryPath); - await linkCommand.action(options, undefined); + await linkCommand.action({}, undefined); - expect(scope.isDone()).toBe(true); + expect(resolveScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); expect(mockPromptOrganizationSelection).toHaveBeenCalled(); expect(mockPromptAppSelection).toHaveBeenCalledWith(orgId); expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); }); - it('should handle API error', async () => { - const appId = 'app-123'; - const testToken = 'test-token'; + it('should prompt for git connection when multiple candidates are resolved', async () => { + mockPromptGitConnectionSelection.mockResolvedValueOnce(otherGitConnection); + + const appScope = nockAppRequest(); + const resolveScope = nockResolveRequest([gitConnection, otherGitConnection]); + const linkScope = nockLinkRequest(otherGitConnection.id, repositoryPath); - const options = { appId }; + await linkCommand.action({ appId }, undefined); + + expect(appScope.isDone()).toBe(true); + expect(resolveScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); + expect(mockPromptGitConnectionSelection).toHaveBeenCalledWith([gitConnection, otherGitConnection]); + expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); + }); + + it('should error when no git connection candidate is resolved', async () => { + const appScope = nockAppRequest(); + const resolveScope = nockResolveRequest([]); + + await expect(linkCommand.action({ appId }, undefined)).rejects.toThrow(); + + expect(appScope.isDone()).toBe(true); + expect(resolveScope.isDone()).toBe(true); + expect(mockConsola.error).toHaveBeenCalledWith(expect.stringContaining('Please create a github connection')); + }); - const scope = nock(DEFAULT_API_BASE_URL) + it('should fall back to git connection selection when no git remote is available', async () => { + mockGetGitRemoteUrl.mockReturnValue(undefined); + mockPromptGitConnectionSelection.mockResolvedValueOnce(gitConnection); + mockPromptRepositorySelection.mockResolvedValueOnce(repositoryPath); + + const appScope = nockAppRequest(); + const connectionsScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/organizations/${orgId}/git-connections`) + .query({ restricted: 'false', limit: '50' }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, [gitConnection]); + const linkScope = nockLinkRequest(gitConnection.id, repositoryPath); + + await linkCommand.action({ appId }, undefined); + + expect(appScope.isDone()).toBe(true); + expect(connectionsScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); + expect(mockPromptGitConnectionSelection).toHaveBeenCalledWith([gitConnection]); + expect(mockPromptRepositorySelection).toHaveBeenCalledWith(gitConnection); + expect(mockConsola.success).toHaveBeenCalledWith('Repository connected successfully.'); + }); + + it('should error when no git connections are found', async () => { + mockGetGitRemoteUrl.mockReturnValue(undefined); + + const appScope = nockAppRequest(); + const connectionsScope = nock(DEFAULT_API_BASE_URL) + .get(`/v1/organizations/${orgId}/git-connections`) + .query({ restricted: 'false', limit: '50' }) + .matchHeader('Authorization', `Bearer ${testToken}`) + .reply(200, []); + + await expect(linkCommand.action({ appId }, undefined)).rejects.toThrow(); + + expect(appScope.isDone()).toBe(true); + expect(connectionsScope.isDone()).toBe(true); + expect(mockConsola.error).toHaveBeenCalled(); + }); + + it('should handle API error', async () => { + const appScope = nockAppRequest(); + const linkScope = nock(DEFAULT_API_BASE_URL) .put(`/v1/apps/${appId}/repository`, { - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', + gitConnectionId: gitConnection.id, + path: repositoryPath, }) .matchHeader('Authorization', `Bearer ${testToken}`) - .reply(400, { message: 'Git provider not connected' }); + .reply(404, { message: 'Git connection not found.' }); - await expect(linkCommand.action(options, undefined)).rejects.toThrow(); + await expect( + linkCommand.action({ appId, gitConnectionId: gitConnection.id, path: repositoryPath }, undefined), + ).rejects.toThrow(); - expect(scope.isDone()).toBe(true); + expect(appScope.isDone()).toBe(true); + expect(linkScope.isDone()).toBe(true); }); }); diff --git a/src/commands/apps/link.ts b/src/commands/apps/link.ts index 6c35502..ebc591c 100644 --- a/src/commands/apps/link.ts +++ b/src/commands/apps/link.ts @@ -1,38 +1,148 @@ +import { DEFAULT_CONSOLE_BASE_URL } from '@/config/consts.js'; import appsService from '@/services/apps.js'; +import gitConnectionsService from '@/services/git-connections.js'; +import { GitConnectionResolutionDto } from '@/types/git-connection.js'; import { withAuth } from '@/utils/auth.js'; import { isInteractive } from '@/utils/environment.js'; -import { getGitRemoteInfo } from '@/utils/git.js'; -import { promptAppSelection, promptOrganizationSelection } from '@/utils/prompt.js'; +import { getGitRemoteUrl } from '@/utils/git.js'; +import { + prompt, + promptAppSelection, + promptGitConnectionSelection, + promptOrganizationSelection, + promptRepositorySelection, +} from '@/utils/prompt.js'; import { defineCommand, defineOptions } from '@robingenz/zli'; +import { AxiosError } from 'axios'; import consola from 'consola'; import { z } from 'zod'; +const resolveGitRemote = async (organizationId: string): Promise => { + const remoteUrl = getGitRemoteUrl(); + if (!remoteUrl) { + return undefined; + } + try { + return await gitConnectionsService.resolve({ organizationId, remoteUrl }); + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 400) { + return undefined; + } + throw error; + } +}; + export default defineCommand({ description: 'Connect a git repository to an app.', options: defineOptions( z.object({ appId: z.string().optional().describe('ID of the app.'), + gitConnection: z.string().optional().describe('Name of the git connection to use.'), + gitConnectionId: z.string().optional().describe('ID of the git connection to use.'), + path: z + .string() + .optional() + .describe('Path of the repository (e.g. `owner/repo`) or the clone URL for `git_http` connections.'), }), ), action: withAuth(async (options, args) => { - let { appId } = options; + let { appId, gitConnectionId, path } = options; + const gitConnectionName = options.gitConnection; + + if (gitConnectionId && gitConnectionName) { + consola.error('The --git-connection-id and --git-connection options cannot be used together.'); + process.exit(1); + } - if (!appId) { + let organizationId: string; + if (appId) { + const app = await appsService.findOne({ appId }); + organizationId = app.organizationId; + } else { if (!isInteractive()) { consola.error('You must provide the app ID when running in non-interactive environment.'); process.exit(1); } - const organizationId = await promptOrganizationSelection(); + organizationId = await promptOrganizationSelection(); appId = await promptAppSelection(organizationId); } - const gitRemoteInfo = getGitRemoteInfo(); - await appsService.linkRepository({ - appId, - ownerSlug: gitRemoteInfo.ownerSlug, - provider: gitRemoteInfo.provider, - repositorySlug: gitRemoteInfo.repositorySlug, - projectSlug: gitRemoteInfo.projectSlug, - }); + + if (gitConnectionName) { + const gitConnections = await gitConnectionsService.findAll({ organizationId, name: gitConnectionName }); + const gitConnection = gitConnections[0]; + if (!gitConnection) { + consola.error(`No git connection found with name "${gitConnectionName}".`); + process.exit(1); + } + gitConnectionId = gitConnection.id; + } + + if (gitConnectionId) { + path = path ?? (await resolveGitRemote(organizationId))?.path; + if (!path) { + consola.error('You must provide the repository path using the --path option.'); + process.exit(1); + } + await appsService.linkRepository({ appId, gitConnectionId, path }); + consola.success('Repository connected successfully.'); + return; + } + + if (!isInteractive()) { + consola.error( + 'You must provide the git connection using the --git-connection-id or --git-connection option when running in non-interactive environment.', + ); + process.exit(1); + } + + if (!path) { + const resolution = await resolveGitRemote(organizationId); + if (resolution) { + const gitConnections = resolution.gitConnections; + const gitConnection = gitConnections[0]; + if (gitConnections.length === 1 && gitConnection) { + const confirmed = await prompt( + `Do you want to connect \`${resolution.path}\` using the git connection "${gitConnection.name}"?`, + { type: 'confirm', initial: true }, + ); + if (confirmed) { + await appsService.linkRepository({ appId, gitConnectionId: gitConnection.id, path: resolution.path }); + consola.success('Repository connected successfully.'); + return; + } + } else if (gitConnections.length > 1) { + const selectedGitConnection = await promptGitConnectionSelection(gitConnections); + await appsService.linkRepository({ appId, gitConnectionId: selectedGitConnection.id, path: resolution.path }); + consola.success('Repository connected successfully.'); + return; + } else if (resolution.provider) { + consola.error( + `No git connection found for the git remote \`origin\`. Please create a ${resolution.provider} connection in the Capawesome Cloud Console (${DEFAULT_CONSOLE_BASE_URL}).`, + ); + process.exit(1); + } else { + consola.error('No git connection can serve the git remote `origin`.'); + process.exit(1); + } + } + } + + const gitConnections = await gitConnectionsService.findAll({ organizationId, restricted: false, limit: 50 }); + if (gitConnections.length === 0) { + consola.error( + `No git connections found. Please create one in the Capawesome Cloud Console (${DEFAULT_CONSOLE_BASE_URL}).`, + ); + process.exit(1); + } + const gitConnection = await promptGitConnectionSelection(gitConnections); + if (!path) { + if (gitConnection.provider === 'git_http') { + path = await prompt('Enter the clone URL of the repository:', { type: 'text' }); + } else { + path = await promptRepositorySelection(gitConnection); + } + } + await appsService.linkRepository({ appId, gitConnectionId: gitConnection.id, path }); consola.success('Repository connected successfully.'); }), }); diff --git a/src/services/git-connections.ts b/src/services/git-connections.ts new file mode 100644 index 0000000..782673f --- /dev/null +++ b/src/services/git-connections.ts @@ -0,0 +1,94 @@ +import authorizationService from '@/services/authorization-service.js'; +import { + FindAllGitConnectionRepositoriesDto, + FindAllGitConnectionsDto, + GitConnectionDto, + GitConnectionRepositoryDto, + GitConnectionResolutionDto, + ResolveGitConnectionsDto, +} from '@/types/git-connection.js'; +import httpClient, { HttpClient } from '@/utils/http-client.js'; + +export interface GitConnectionsService { + findAll(dto: FindAllGitConnectionsDto): Promise; + findAllRepositories(dto: FindAllGitConnectionRepositoriesDto): Promise; + resolve(dto: ResolveGitConnectionsDto): Promise; +} + +class GitConnectionsServiceImpl implements GitConnectionsService { + private readonly httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + async findAll(dto: FindAllGitConnectionsDto): Promise { + const params: Record = {}; + if (dto.limit !== undefined) { + params.limit = dto.limit.toString(); + } + if (dto.name !== undefined) { + params.name = dto.name; + } + if (dto.offset !== undefined) { + params.offset = dto.offset.toString(); + } + if (dto.provider !== undefined) { + params.provider = dto.provider; + } + if (dto.restricted !== undefined) { + params.restricted = dto.restricted.toString(); + } + const response = await this.httpClient.get( + `/v1/organizations/${dto.organizationId}/git-connections`, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + params, + }, + ); + return response.data; + } + + async findAllRepositories(dto: FindAllGitConnectionRepositoriesDto): Promise { + const params: Record = {}; + if (dto.namespace !== undefined) { + params.namespace = dto.namespace; + } + if (dto.path !== undefined) { + params.path = dto.path; + } + if (dto.query !== undefined) { + params.query = dto.query; + } + const response = await this.httpClient.get( + `/v1/organizations/${dto.organizationId}/git-connections/${dto.gitConnectionId}/repositories`, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + params, + }, + ); + return response.data; + } + + async resolve(dto: ResolveGitConnectionsDto): Promise { + const params: Record = { remoteUrl: dto.remoteUrl }; + const response = await this.httpClient.get( + `/v1/organizations/${dto.organizationId}/git-connections/resolve`, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + }, + params, + }, + ); + return response.data; + } +} + +const gitConnectionsService = new GitConnectionsServiceImpl(httpClient); + +export default gitConnectionsService; diff --git a/src/types/app.ts b/src/types/app.ts index 158170f..a22528c 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,6 +1,7 @@ export interface AppDto { id: string; name: string; + organizationId: string; type: AppType; } @@ -28,10 +29,8 @@ export interface FindOneAppDto { export interface LinkAppRepositoryDto { appId: string; - ownerSlug: string; - provider: string; - repositorySlug: string; - projectSlug?: string; + gitConnectionId: string; + path: string; } export interface TransferAppDto { diff --git a/src/types/git-connection.ts b/src/types/git-connection.ts new file mode 100644 index 0000000..9547adc --- /dev/null +++ b/src/types/git-connection.ts @@ -0,0 +1,51 @@ +export type GitConnectionAuthKind = 'basic' | 'github_app' | 'oauth' | 'token'; + +export type GitConnectionProvider = 'azure_devops' | 'bitbucket' | 'git_http' | 'gitea' | 'github' | 'gitlab'; + +export interface GitConnectionDto { + id: string; + authKind: GitConnectionAuthKind; + baseUrl: string | null; + name: string; + organizationId: string; + provider: GitConnectionProvider; + restricted: boolean; +} + +export interface FindAllGitConnectionsDto { + organizationId: string; + limit?: number; + name?: string; + offset?: number; + provider?: GitConnectionProvider; + restricted?: boolean; +} + +export interface ResolveGitConnectionsDto { + organizationId: string; + remoteUrl: string; +} + +export interface GitConnectionResolutionDto { + gitConnections: GitConnectionDto[]; + path: string; + provider: GitConnectionProvider | null; +} + +export interface FindAllGitConnectionRepositoriesDto { + gitConnectionId: string; + organizationId: string; + namespace?: string; + path?: string; + query?: string; +} + +export interface GitConnectionRepositoryDto { + defaultBranch: string; + id: string; + name: string; + namespace: string; + path: string; + private: boolean; + webUrl: string; +} diff --git a/src/utils/git.test.ts b/src/utils/git.test.ts deleted file mode 100644 index 92d81e7..0000000 --- a/src/utils/git.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { parseGitRemoteUrl } from './git.js'; - -describe('parseGitRemoteUrl', () => { - it('should parse GitHub HTTPS URL', () => { - const result = parseGitRemoteUrl('https://github.com/capawesome-team/cli.git'); - expect(result).toEqual({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }); - }); - - it('should parse GitHub HTTPS URL without .git suffix', () => { - const result = parseGitRemoteUrl('https://github.com/capawesome-team/cli'); - expect(result).toEqual({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }); - }); - - it('should parse GitHub SSH URL', () => { - const result = parseGitRemoteUrl('git@github.com:capawesome-team/cli.git'); - expect(result).toEqual({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }); - }); - - it('should parse GitHub SSH URL without .git suffix', () => { - const result = parseGitRemoteUrl('git@github.com:capawesome-team/cli'); - expect(result).toEqual({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }); - }); - - it('should parse GitLab HTTPS URL', () => { - const result = parseGitRemoteUrl('https://gitlab.com/my-group/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-group', - provider: 'gitlab', - repositorySlug: 'my-repo', - }); - }); - - it('should parse GitLab SSH URL', () => { - const result = parseGitRemoteUrl('git@gitlab.com:my-group/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-group', - provider: 'gitlab', - repositorySlug: 'my-repo', - }); - }); - - it('should parse GitLab HTTPS URL with subgroup', () => { - const result = parseGitRemoteUrl('https://gitlab.com/my-group/my-subgroup/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-group', - provider: 'gitlab', - repositorySlug: 'my-repo', - projectSlug: 'my-subgroup', - }); - }); - - it('should parse GitLab SSH URL with subgroup', () => { - const result = parseGitRemoteUrl('git@gitlab.com:my-group/my-subgroup/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-group', - provider: 'gitlab', - repositorySlug: 'my-repo', - projectSlug: 'my-subgroup', - }); - }); - - it('should parse Bitbucket HTTPS URL', () => { - const result = parseGitRemoteUrl('https://bitbucket.org/my-team/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-team', - provider: 'bitbucket', - repositorySlug: 'my-repo', - }); - }); - - it('should parse Bitbucket SSH URL', () => { - const result = parseGitRemoteUrl('git@bitbucket.org:my-team/my-repo.git'); - expect(result).toEqual({ - ownerSlug: 'my-team', - provider: 'bitbucket', - repositorySlug: 'my-repo', - }); - }); - - it('should parse Azure DevOps HTTPS URL', () => { - const result = parseGitRemoteUrl('https://dev.azure.com/my-org/my-project/_git/my-repo'); - expect(result).toEqual({ - ownerSlug: 'my-org', - provider: 'azure', - repositorySlug: 'my-repo', - projectSlug: 'my-project', - }); - }); - - it('should parse Azure DevOps SSH URL', () => { - const result = parseGitRemoteUrl('git@ssh.dev.azure.com:v3/my-org/my-project/my-repo'); - expect(result).toEqual({ - ownerSlug: 'my-org', - provider: 'azure', - repositorySlug: 'my-repo', - projectSlug: 'my-project', - }); - }); - - it('should parse Visual Studio HTTPS URL', () => { - const result = parseGitRemoteUrl('https://my-org.visualstudio.com/my-project/_git/my-repo'); - expect(result).toEqual({ - ownerSlug: 'my-org', - provider: 'azure', - repositorySlug: 'my-repo', - projectSlug: 'my-project', - }); - }); - - it('should parse GitHub HTTPS URL with credentials', () => { - const result = parseGitRemoteUrl('https://x-access-token:ghp_secret123@github.com/capawesome-team/cli.git'); - expect(result).toEqual({ - ownerSlug: 'capawesome-team', - provider: 'github', - repositorySlug: 'cli', - }); - }); - - it('should throw for unsupported hostname', () => { - expect(() => parseGitRemoteUrl('https://example.com/owner/repo.git')).toThrow( - 'Unsupported git provider for hostname "example.com".', - ); - }); - - it('should not leak credentials in error messages', () => { - expect(() => parseGitRemoteUrl('https://token@example.com/owner/repo.git')).toThrow( - 'Unsupported git provider for hostname "example.com".', - ); - }); - - it('should throw for unparseable URL', () => { - expect(() => parseGitRemoteUrl('not-a-url')).toThrow('Could not parse git remote URL.'); - }); -}); diff --git a/src/utils/git.ts b/src/utils/git.ts index 65f63b3..8626fc8 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,109 +1,9 @@ import { execSync } from 'child_process'; -import { UserError } from '@/utils/error.js'; - -export interface GitRemoteInfo { - ownerSlug: string; - provider: string; - repositorySlug: string; - projectSlug?: string; -} - -const HOSTNAME_TO_PROVIDER: Record = { - 'github.com': 'github', - 'gitlab.com': 'gitlab', - 'bitbucket.org': 'bitbucket', - 'dev.azure.com': 'azure', - 'ssh.dev.azure.com': 'azure', -}; - -export const getGitRemoteInfo = (): GitRemoteInfo => { - const remoteUrl = getGitRemoteUrl(); - return parseGitRemoteUrl(remoteUrl); -}; - -const getGitRemoteUrl = (): string => { +export const getGitRemoteUrl = (): string | undefined => { try { return execSync('git remote get-url origin', { encoding: 'utf-8' }).trim(); } catch { - throw new UserError( - 'Could not read the git remote URL. Make sure you are inside a git repository with an origin remote.', - ); + return undefined; } }; - -export const parseGitRemoteUrl = (remoteUrl: string): GitRemoteInfo => { - // Azure DevOps HTTPS: https://dev.azure.com/{org}/{project}/_git/{repo} - const azureHttpsMatch = remoteUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/); - if (azureHttpsMatch && azureHttpsMatch[1] && azureHttpsMatch[2] && azureHttpsMatch[3]) { - return { - ownerSlug: azureHttpsMatch[1], - provider: 'azure', - repositorySlug: azureHttpsMatch[3], - projectSlug: azureHttpsMatch[2], - }; - } - - // Azure DevOps SSH: git@ssh.dev.azure.com:v3/{org}/{project}/{repo} - const azureSshMatch = remoteUrl.match(/ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/); - if (azureSshMatch && azureSshMatch[1] && azureSshMatch[2] && azureSshMatch[3]) { - return { - ownerSlug: azureSshMatch[1], - provider: 'azure', - repositorySlug: azureSshMatch[3], - projectSlug: azureSshMatch[2], - }; - } - - // Visual Studio HTTPS: https://{org}.visualstudio.com/{project}/_git/{repo} - const vsHttpsMatch = remoteUrl.match(/([^/]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/); - if (vsHttpsMatch && vsHttpsMatch[1] && vsHttpsMatch[2] && vsHttpsMatch[3]) { - return { - ownerSlug: vsHttpsMatch[1], - provider: 'azure', - repositorySlug: vsHttpsMatch[3], - projectSlug: vsHttpsMatch[2], - }; - } - - // SSH: git@{host}:{owner}[/{subgroup}]/{repo}.git - const sshMatch = remoteUrl.match(/git@([^:]+):([^/]+)(?:\/([^/]+))?\/([^/]+?)(?:\.git)?$/); - if (sshMatch && sshMatch[1] && sshMatch[2] && sshMatch[4]) { - const hostname = sshMatch[1]; - const provider = HOSTNAME_TO_PROVIDER[hostname]; - if (!provider) { - throw new UserError(`Unsupported git provider for hostname "${hostname}".`); - } - return { - ownerSlug: sshMatch[2], - provider, - repositorySlug: sshMatch[4], - projectSlug: sshMatch[3], - }; - } - - // HTTPS: https://[user@]{host}/{owner}[/{subgroup}]/{repo}.git - try { - const url = new URL(remoteUrl); - if (url.protocol === 'http:' || url.protocol === 'https:') { - const hostname = url.hostname; - const provider = HOSTNAME_TO_PROVIDER[hostname]; - if (!provider) { - throw new UserError(`Unsupported git provider for hostname "${hostname}".`); - } - const pathSegments = url.pathname.split('/').filter(Boolean); - const repositorySlug = pathSegments.pop()?.replace(/\.git$/, ''); - const ownerSlug = pathSegments.shift(); - const projectSlug = pathSegments.length > 0 ? pathSegments.join('/') : undefined; - if (ownerSlug && repositorySlug) { - return { ownerSlug, provider, repositorySlug, projectSlug }; - } - } - } catch (error) { - if (error instanceof UserError) { - throw error; - } - } - - throw new UserError('Could not parse git remote URL.'); -}; diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 6b71aed..8086053 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -1,3 +1,4 @@ +import { GitConnectionDto } from '@/types/git-connection.js'; import consola from 'consola'; export const prompt: typeof consola.prompt = async (message, options) => { @@ -73,3 +74,52 @@ export const promptAppSelection = async ( }); return appId; }; + +export const promptGitConnectionSelection = async (gitConnections: GitConnectionDto[]): Promise => { + // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged + const gitConnectionId = await prompt('Which git connection do you want to use?', { + type: 'select', + options: gitConnections.map((gitConnection) => ({ + label: `${gitConnection.name} (${gitConnection.provider})`, + value: gitConnection.id, + })), + }); + const gitConnection = gitConnections.find((gitConnection) => gitConnection.id === gitConnectionId); + if (!gitConnection) { + consola.error('Git connection not found.'); + process.exit(1); + } + return gitConnection; +}; + +export const promptRepositorySelection = async (gitConnection: GitConnectionDto): Promise => { + const gitConnectionsService = await import('@/services/git-connections.js').then((mod) => mod.default); + let namespace: string | undefined; + let query: string | undefined; + if (gitConnection.provider === 'bitbucket') { + namespace = await prompt('Enter the Bitbucket workspace slug:', { type: 'text' }); + } else if (gitConnection.provider === 'azure_devops') { + namespace = await prompt('Enter the Azure DevOps organization and project (e.g. `my-org/my-project`):', { + type: 'text', + }); + } else { + const search = await prompt('Search for a repository (optional):', { type: 'text' }); + query = search.trim() || undefined; + } + const repositories = await gitConnectionsService.findAllRepositories({ + gitConnectionId: gitConnection.id, + organizationId: gitConnection.organizationId, + namespace, + query, + }); + if (repositories.length === 0) { + consola.error('No repositories found.'); + process.exit(1); + } + // @ts-ignore wait till https://github.com/unjs/consola/pull/280 is merged + const path = await prompt('Which repository do you want to connect?', { + type: 'select', + options: repositories.map((repository) => ({ label: repository.path, value: repository.path })), + }); + return path; +};