From c56e23fb789235999005a597fb7a331a0aeed1f0 Mon Sep 17 00:00:00 2001 From: John Date: Sun, 3 May 2026 19:38:06 -0700 Subject: [PATCH] fix(platform): allow dots in repo names when parsing remote URLs The repo capture group in `parseGitHubRemote` and `parseAzureDevOpsRemote` used `[^/.]+?`, which excluded `.` and so failed on any repo whose name contained a dot (which both GitHub and Azure DevOps permit). The failure surfaced to users as `Could not detect platform: Could not parse GitHub remote URL: ...` from `squad watch`. Widen the repo capture to `[^/]+?` in all four regexes (HTTPS + SSH for both GitHub and ADO, plus legacy `*.visualstudio.com`). The trailing `(?:\.git)?$` plus the non-greedy quantifier still strip a trailing `.git` correctly. Adds regression coverage in `test/platform-adapter.test.ts` for repos named like `JADE.xlighthousepipelines`, `foo.bar.baz`, and the `.github` community-health repo convention, on both HTTPS and SSH for GitHub, dev.azure.com, and visualstudio.com. Closes #1077 --- packages/squad-sdk/src/platform/detect.ts | 17 +++++--- test/platform-adapter.test.ts | 51 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/squad-sdk/src/platform/detect.ts b/packages/squad-sdk/src/platform/detect.ts index 1d351b3a7..68824b866 100644 --- a/packages/squad-sdk/src/platform/detect.ts +++ b/packages/squad-sdk/src/platform/detect.ts @@ -27,14 +27,17 @@ export interface AzureDevOpsRemoteInfo { * git@github.com:owner/repo.git */ export function parseGitHubRemote(url: string): GitHubRemoteInfo | null { + // Repo capture allows dots (GitHub permits them, e.g. `foo/bar.baz`); the + // optional `\.git$` plus the non-greedy quantifier still strips a trailing + // `.git` correctly. // HTTPS: https://github.com/owner/repo.git - const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/i); + const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i); if (httpsMatch) { return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; } // SSH: git@github.com:owner/repo.git - const sshMatch = url.match(/github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/i); + const sshMatch = url.match(/github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i); if (sshMatch) { return { owner: sshMatch[1]!, repo: sshMatch[2]! }; } @@ -51,10 +54,12 @@ export function parseGitHubRemote(url: string): GitHubRemoteInfo | null { * https://org.visualstudio.com/project/_git/repo */ export function parseAzureDevOpsRemote(url: string): AzureDevOpsRemoteInfo | null { + // Repo capture allows dots in repo names; the literal `/_git/` segment keeps + // the match anchored unambiguously. // HTTPS dev.azure.com: https://dev.azure.com/org/project/_git/repo // Also handles: https://org@dev.azure.com/org/project/_git/repo const devAzureHttps = url.match( - /dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?$/i, + /dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/i, ); if (devAzureHttps) { return { org: devAzureHttps[1]!, project: devAzureHttps[2]!, repo: devAzureHttps[3]! }; @@ -62,15 +67,17 @@ export function parseAzureDevOpsRemote(url: string): AzureDevOpsRemoteInfo | nul // SSH dev.azure.com: git@ssh.dev.azure.com:v3/org/project/repo const devAzureSsh = url.match( - /ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/.]+?)(?:\.git)?$/i, + /ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/i, ); if (devAzureSsh) { return { org: devAzureSsh[1]!, project: devAzureSsh[2]!, repo: devAzureSsh[3]! }; } // Legacy visualstudio.com: https://org.visualstudio.com/project/_git/repo + // (Org subdomain keeps the no-dot constraint — the `.visualstudio.com` anchor + // requires it. Only the repo capture is widened.) const vsMatch = url.match( - /([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/.]+?)(?:\.git)?$/i, + /([^/.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/]+?)(?:\.git)?$/i, ); if (vsMatch) { return { org: vsMatch[1]!, project: vsMatch[2]!, repo: vsMatch[3]! }; diff --git a/test/platform-adapter.test.ts b/test/platform-adapter.test.ts index c14748973..4e14989cc 100644 --- a/test/platform-adapter.test.ts +++ b/test/platform-adapter.test.ts @@ -96,6 +96,36 @@ describe('parseGitHubRemote', () => { // trailing slash is not standard git remote but shouldn't crash expect(parseGitHubRemote('https://github.com/owner/')).toBeNull(); }); + + // Regression: the repo capture used to disallow `.`, so any GitHub repo whose + // name contained a dot (which GitHub permits) failed to parse and bubbled up + // as `Could not detect platform: Could not parse GitHub remote URL: ...` in + // `squad watch`. See issue #1077. + it('parses HTTPS URL with dots in repo name', () => { + const result = parseGitHubRemote('https://github.com/myorg/JADE.xlighthousepipelines.git'); + expect(result).toEqual({ owner: 'myorg', repo: 'JADE.xlighthousepipelines' }); + }); + + it('parses HTTPS URL with dots in repo name and no .git suffix', () => { + const result = parseGitHubRemote('https://github.com/myorg/JADE.xlighthousepipelines'); + expect(result).toEqual({ owner: 'myorg', repo: 'JADE.xlighthousepipelines' }); + }); + + it('parses SSH URL with dots in repo name', () => { + const result = parseGitHubRemote('git@github.com:myorg/foo.bar.baz.git'); + expect(result).toEqual({ owner: 'myorg', repo: 'foo.bar.baz' }); + }); + + it('parses SSH URL with dots in repo name and no .git suffix', () => { + const result = parseGitHubRemote('git@github.com:myorg/foo.bar.baz'); + expect(result).toEqual({ owner: 'myorg', repo: 'foo.bar.baz' }); + }); + + it('parses repo named exactly ".github" (with leading dot before .git)', () => { + // owner/.github is a real convention for community-health files + const result = parseGitHubRemote('https://github.com/myorg/.github.git'); + expect(result).toEqual({ owner: 'myorg', repo: '.github' }); + }); }); // ─── Azure DevOps Remote Parsing ─────────────────────────────────────── @@ -152,6 +182,27 @@ describe('parseAzureDevOpsRemote', () => { const result = parseAzureDevOpsRemote('https://dev.azure.com/org/My-Project/_git/my-repo'); expect(result).toEqual({ org: 'org', project: 'My-Project', repo: 'my-repo' }); }); + + // Regression: see issue #1077 — repo capture used to disallow `.`. + it('parses HTTPS dev.azure.com URL with dots in repo name', () => { + const result = parseAzureDevOpsRemote('https://dev.azure.com/org/proj/_git/repo.with.dots'); + expect(result).toEqual({ org: 'org', project: 'proj', repo: 'repo.with.dots' }); + }); + + it('parses HTTPS dev.azure.com URL with dots in repo name and .git suffix', () => { + const result = parseAzureDevOpsRemote('https://dev.azure.com/org/proj/_git/repo.with.dots.git'); + expect(result).toEqual({ org: 'org', project: 'proj', repo: 'repo.with.dots' }); + }); + + it('parses SSH dev.azure.com URL with dots in repo name', () => { + const result = parseAzureDevOpsRemote('git@ssh.dev.azure.com:v3/org/proj/repo.with.dots.git'); + expect(result).toEqual({ org: 'org', project: 'proj', repo: 'repo.with.dots' }); + }); + + it('parses legacy visualstudio.com URL with dots in repo name', () => { + const result = parseAzureDevOpsRemote('https://contoso.visualstudio.com/proj/_git/repo.with.dots.git'); + expect(result).toEqual({ org: 'contoso', project: 'proj', repo: 'repo.with.dots' }); + }); }); // ─── WorkItem Type Shape ───────────────────────────────────────────────