Skip to content

Commit 65a5f6c

Browse files
committed
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 and `parseRepoUrl` returned null. With GitLab MR detection from #72 in play, `sync` then threw "Repository info is required to sync a release with pull request references" (src/index.ts:407). The split mirrors `parseProjectPathName` in linear-app's common/src/models/IntegrationHelper.ts — owner is the first segment, name is everything else. That matches how Linear's GitLab webhook stores `attachment.metadata.repoLogin/repoName`, which is what the CLI's `repositoryOwner`/`repositoryName` join against during release sync. The convention is symmetric for single-segment URLs (GitHub, Bitbucket, simple GitLab), so behavior is unchanged for those. Adds a regression suite that locks the cross-repo contract by running `parseRepoUrl` against the literal linear-app split logic. Fixes #83
1 parent 9d63f17 commit 65a5f6c

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

src/git.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ describe("parseRepoUrl", () => {
191191
});
192192
});
193193

194+
it("should parse gitlab.com HTTPS URL with nested groups", () => {
195+
const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git");
196+
expect(result).toEqual({
197+
owner: "my-org",
198+
name: "my-group/my-repo",
199+
provider: "gitlab",
200+
url: "https://gitlab.com/my-org/my-group/my-repo",
201+
});
202+
});
203+
204+
it("should parse gitlab.com HTTPS URL with deeply nested groups", () => {
205+
const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git");
206+
expect(result).toEqual({
207+
owner: "org",
208+
name: "group/subgroup/repo",
209+
provider: "gitlab",
210+
url: "https://gitlab.com/org/group/subgroup/repo",
211+
});
212+
});
213+
214+
it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => {
215+
const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service");
216+
expect(result).toEqual({
217+
owner: "team",
218+
name: "platform/service",
219+
provider: "gitlab",
220+
url: "https://gitlab.internal.io/team/platform/service",
221+
});
222+
});
223+
194224
it("should parse bitbucket.org HTTPS URL", () => {
195225
const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git");
196226
expect(result).toEqual({
@@ -273,6 +303,26 @@ describe("parseRepoUrl", () => {
273303
});
274304
});
275305

306+
it("should parse gitlab.com SSH URL with nested groups", () => {
307+
const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git");
308+
expect(result).toEqual({
309+
owner: "my-org",
310+
name: "my-group/my-repo",
311+
provider: "gitlab",
312+
url: "https://gitlab.com/my-org/my-group/my-repo",
313+
});
314+
});
315+
316+
it("should parse gitlab.com SSH URL with deeply nested groups", () => {
317+
const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git");
318+
expect(result).toEqual({
319+
owner: "org",
320+
name: "group/subgroup/repo",
321+
provider: "gitlab",
322+
url: "https://gitlab.com/org/group/subgroup/repo",
323+
});
324+
});
325+
276326
it("should parse bitbucket.org SSH URL", () => {
277327
const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git");
278328
expect(result).toEqual({

src/git.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -431,8 +431,9 @@ function hostToProvider(host: string): string | null {
431431
* @returns Parsed repo info, or null if the URL could not be parsed.
432432
*/
433433
export function parseRepoUrl(remoteUrl: string): RepoInfo | null {
434-
// Handle HTTPS URLs: https://github.com/owner/repo.git
435-
const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
434+
// GitLab nested groups: split on the first slash so subgroup paths fold
435+
// into the name segment (e.g. owner=group, name=subgroup/repo).
436+
const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/);
436437
if (httpsMatch) {
437438
const host = httpsMatch[1];
438439
const owner = httpsMatch[2] || null;
@@ -445,8 +446,9 @@ export function parseRepoUrl(remoteUrl: string): RepoInfo | null {
445446
};
446447
}
447448

448-
// Handle SSH URLs: git@github.com:owner/repo.git
449-
const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
449+
// Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups
450+
// follow the same first-slash split as the HTTPS case above).
451+
const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/);
450452
if (sshMatch) {
451453
const host = sshMatch[1];
452454
const owner = sshMatch[2] || null;

0 commit comments

Comments
 (0)