diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 45b777fc..71c80d6c 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -76,6 +76,105 @@ describe('getGitHubRepoInfo', async () => { ); expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); }); + + // Tests for credential formats + + it('returns the owner and repo for URL with classic PAT token (ghp_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with fine-grained PAT token (github_pat_)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with username:password format', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://username:password@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with OAuth token (oauth2:token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://oauth2:gho_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with GitHub Actions token (x-access-token)', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://x-access-token:ghs_xxxxxxxxxxxx@github.com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // Tests for case insensitivity + + it('returns the owner and repo for URL with uppercase GITHUB.COM', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GITHUB.COM/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('returns the owner and repo for URL with mixed case GitHub.Com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://GitHub.Com/owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + // Tests for SSH format + + it('returns the owner and repo for SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@github.com:owner/repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub SSH URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'git@gitlab.com:owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + // Tests for edge cases + + it('returns the owner and repo for URL without .git suffix', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); + + it('throws for non-GitHub HTTPS URL', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://gitlab.com/owner/repo.git', + ); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('handles repo names containing .git substring', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/my.git.repo.git', + ); + expect(getGitHubRepoInfo()).toEqual({ + owner: 'owner', + repo: 'my.git.repo', + }); + }); }); describe('getGitRepoRoot', async () => { diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 35f58210..fcef4bf3 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -103,17 +103,38 @@ export function getGitHubRepoInfo(): { owner: string; repo: string } { encoding: 'utf-8', }).trim(); - // Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git - const match = remoteUrl.match( - /(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/, - ); - - // If the regex fails match, throw an error. - if (!match || !match[1] || !match[2]) { + // Handle SCP-style SSH URLs (git@github.com:owner/repo.git) + let urlToParse = remoteUrl; + if (remoteUrl.startsWith('git@github.com:')) { + urlToParse = remoteUrl.replace('git@github.com:', ''); + } else if (remoteUrl.startsWith('git@')) { + // SSH URL for a different provider (GitLab, Bitbucket, etc.) throw new Error( `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, ); } - return { owner: match[1], repo: match[2] }; + let parsedUrl: URL; + try { + parsedUrl = new URL(urlToParse, 'https://github.com'); + } catch { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + if (parsedUrl.host !== 'github.com') { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + const parts = parsedUrl.pathname.split('/').filter((part) => part !== ''); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') }; }