From 3e7fc8e1010c7d15ebf541d02caaf6541732effe Mon Sep 17 00:00:00 2001 From: Mikhail Dubov Date: Fri, 12 Jun 2026 14:43:53 +0100 Subject: [PATCH] fix(control-plane): use full namespace path as GitLab repo owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitLab provider mapped namespace.path (the last path segment only) into the repository owner. For nested-group projects ("group/subgroup/project") this returned owner="subgroup", so the owner/name pairs stored on sessions and returned from listRepositories could not be resolved back to a project — lookups reconstructed "subgroup/project" and GitLab returned 404. Use namespace.full_path, which carries the entire group hierarchy and equals namespace.path for top-level groups. --- .../providers/gitlab-provider.test.ts | 73 +++++++++++++++++-- .../providers/gitlab-provider.ts | 14 ++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts index 3b01bb0dd..062cd93f0 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.test.ts @@ -37,7 +37,7 @@ describe("GitLabSourceControlProvider", () => { name: "My Web App", // display name — should NOT be used path: "web", // URL slug — should be used as name path_with_namespace: "acme/web", - namespace: { path: "acme" }, + namespace: { path: "acme", full_path: "acme" }, default_branch: "main", visibility: "private", }) @@ -59,6 +59,29 @@ describe("GitLabSourceControlProvider", () => { }); }); + it("returns the full namespace path as owner for nested-group projects", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + id: 43, + name: "Web App", + path: "web", + path_with_namespace: "acme/backend/web", + namespace: { path: "backend", full_path: "acme/backend" }, + default_branch: "main", + visibility: "private", + }) + ); + + const provider = new GitLabSourceControlProvider(fakeConfig); + const repo = await provider.getRepository( + { authType: "pat", token: "user-token" }, + { owner: "acme/backend", name: "web" } + ); + + expect(repo.owner).toBe("acme/backend"); + expect(repo.fullName).toBe("acme/backend/web"); + }); + it("marks public repos as not private", async () => { mockFetch.mockResolvedValueOnce( makeResponse({ @@ -66,7 +89,7 @@ describe("GitLabSourceControlProvider", () => { name: "OSS Project", path: "oss", path_with_namespace: "acme/oss", - namespace: { path: "acme" }, + namespace: { path: "acme", full_path: "acme" }, default_branch: "main", visibility: "public", }) @@ -342,7 +365,7 @@ describe("GitLabSourceControlProvider", () => { mockFetch.mockResolvedValueOnce( makeResponse({ id: 99, - namespace: { path: "acme" }, + namespace: { path: "acme", full_path: "acme" }, path: "web", default_branch: "main", }) @@ -359,6 +382,23 @@ describe("GitLabSourceControlProvider", () => { }); }); + it("returns the full namespace path as repoOwner for nested-group projects", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + id: 100, + namespace: { path: "backend", full_path: "acme/backend" }, + path: "web", + default_branch: "main", + }) + ); + + const provider = new GitLabSourceControlProvider(fakeConfig); + const result = await provider.checkRepositoryAccess({ owner: "acme/backend", name: "web" }); + + expect(result?.repoOwner).toBe("acme/backend"); + expect(result?.repoName).toBe("web"); + }); + it("returns null for 404", async () => { mockFetch.mockResolvedValueOnce(makeResponse("not found", 404)); @@ -384,7 +424,7 @@ describe("GitLabSourceControlProvider", () => { mockFetch.mockResolvedValueOnce( makeResponse({ id: 1, - namespace: { path: "ACME" }, + namespace: { path: "ACME", full_path: "ACME" }, path: "WEB", default_branch: "main", }) @@ -407,7 +447,7 @@ describe("GitLabSourceControlProvider", () => { name: "My Web App", // display name — should NOT be used path: "web", // URL slug — should be used as name path_with_namespace: "acme/web", - namespace: { path: "acme" }, + namespace: { path: "acme", full_path: "acme" }, description: "The web app", visibility: "private", default_branch: "main", @@ -434,6 +474,29 @@ describe("GitLabSourceControlProvider", () => { }); }); + it("returns the full namespace path as owner for nested-group projects", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse([ + { + id: 2, + name: "Web App", + path: "web", + path_with_namespace: "acme/backend/web", + namespace: { path: "backend", full_path: "acme/backend" }, + description: null, + visibility: "private", + default_branch: "main", + }, + ]) + ); + + const provider = new GitLabSourceControlProvider(fakeConfig); + const repos = await provider.listRepositories(); + + expect(repos[0].owner).toBe("acme/backend"); + expect(repos[0].fullName).toBe("acme/backend/web"); + }); + it("fetches from group endpoint when namespace is configured", async () => { mockFetch.mockResolvedValueOnce(makeResponse([])); diff --git a/packages/control-plane/src/source-control/providers/gitlab-provider.ts b/packages/control-plane/src/source-control/providers/gitlab-provider.ts index bfaee47c3..4d9fa450d 100644 --- a/packages/control-plane/src/source-control/providers/gitlab-provider.ts +++ b/packages/control-plane/src/source-control/providers/gitlab-provider.ts @@ -104,13 +104,15 @@ export class GitLabSourceControlProvider implements SourceControlProvider { name: string; path: string; path_with_namespace: string; - namespace: { path: string }; + namespace: { full_path: string }; default_branch: string; visibility: string; }; + // full_path, not path: nested groups ("group/subgroup") need the + // entire namespace so owner/name lookups reconstruct the project path. return { - owner: data.namespace.path, + owner: data.namespace.full_path, name: data.path, fullName: data.path_with_namespace, defaultBranch: data.default_branch, @@ -229,14 +231,14 @@ export class GitLabSourceControlProvider implements SourceControlProvider { const data = (await response.json()) as { id: number; - namespace: { path: string }; + namespace: { full_path: string }; path: string; default_branch: string; }; return { repoId: data.id, - repoOwner: data.namespace.path.toLowerCase(), + repoOwner: data.namespace.full_path.toLowerCase(), repoName: data.path.toLowerCase(), defaultBranch: data.default_branch, }; @@ -281,7 +283,7 @@ export class GitLabSourceControlProvider implements SourceControlProvider { name: string; path: string; path_with_namespace: string; - namespace: { path: string }; + namespace: { full_path: string }; description: string | null; visibility: string; default_branch: string; @@ -289,7 +291,7 @@ export class GitLabSourceControlProvider implements SourceControlProvider { return data.map((project) => ({ id: project.id, - owner: project.namespace.path, + owner: project.namespace.full_path, name: project.path, fullName: project.path_with_namespace, description: project.description,