From 9ac2938f99bbd4a058d4afe100701fa71f7ead8e Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Tue, 19 May 2026 08:04:22 +0100 Subject: [PATCH] Parse nested GitLab group paths in parseRepoUrl GitLab supports subgroup nesting up to 20 levels, so a remote URL like `git@gitlab.com:my-org/my-group/my-repo.git` is valid but the existing `[^/]+/([^/]+?)` regex rejected it. `parseRepoUrl` returned null and, once GitLab MR-trailer detection (#72) fires for a sync, syncRelease threw "Repository info is required to sync a release with pull request references" (src/index.ts:407). The split keeps the first path segment as owner and folds the rest into name (e.g. owner=group, name=subgroup/repo). This matches the identifier Linear's API uses for GitLab merge requests during release sync, so the (owner, name) pair we send up joins correctly against stored MR metadata. Single-segment URLs (GitHub, Bitbucket, simple GitLab) are symmetric under this split, so behavior is unchanged for them - all existing tests pass without modification. Fixes #83 --- src/git.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ src/git.ts | 10 ++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/git.test.ts b/src/git.test.ts index 3215d3b..ff5670a 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -191,6 +191,36 @@ describe("parseRepoUrl", () => { }); }); + it("should parse gitlab.com HTTPS URL with nested groups", () => { + const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git"); + expect(result).toEqual({ + owner: "my-org", + name: "my-group/my-repo", + provider: "gitlab", + url: "https://gitlab.com/my-org/my-group/my-repo", + }); + }); + + it("should parse gitlab.com HTTPS URL with deeply nested groups", () => { + const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git"); + expect(result).toEqual({ + owner: "org", + name: "group/subgroup/repo", + provider: "gitlab", + url: "https://gitlab.com/org/group/subgroup/repo", + }); + }); + + it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => { + const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service"); + expect(result).toEqual({ + owner: "team", + name: "platform/service", + provider: "gitlab", + url: "https://gitlab.internal.io/team/platform/service", + }); + }); + it("should parse bitbucket.org HTTPS URL", () => { const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git"); expect(result).toEqual({ @@ -273,6 +303,26 @@ describe("parseRepoUrl", () => { }); }); + it("should parse gitlab.com SSH URL with nested groups", () => { + const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git"); + expect(result).toEqual({ + owner: "my-org", + name: "my-group/my-repo", + provider: "gitlab", + url: "https://gitlab.com/my-org/my-group/my-repo", + }); + }); + + it("should parse gitlab.com SSH URL with deeply nested groups", () => { + const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git"); + expect(result).toEqual({ + owner: "org", + name: "group/subgroup/repo", + provider: "gitlab", + url: "https://gitlab.com/org/group/subgroup/repo", + }); + }); + it("should parse bitbucket.org SSH URL", () => { const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git"); expect(result).toEqual({ diff --git a/src/git.ts b/src/git.ts index 00a9f61..6305b3b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -431,8 +431,9 @@ function hostToProvider(host: string): string | null { * @returns Parsed repo info, or null if the URL could not be parsed. */ export function parseRepoUrl(remoteUrl: string): RepoInfo | null { - // Handle HTTPS URLs: https://github.com/owner/repo.git - const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/); + // GitLab nested groups: split on the first slash so subgroup paths fold + // into the name segment (e.g. owner=group, name=subgroup/repo). + const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); if (httpsMatch) { const host = httpsMatch[1]; const owner = httpsMatch[2] || null; @@ -445,8 +446,9 @@ export function parseRepoUrl(remoteUrl: string): RepoInfo | null { }; } - // Handle SSH URLs: git@github.com:owner/repo.git - const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/); + // Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups + // follow the same first-slash split as the HTTPS case above). + const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); if (sshMatch) { const host = sshMatch[1]; const owner = sshMatch[2] || null;