diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8..45b3f48c6d7c 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -1,4 +1,4 @@ -import { assert, it, afterEach, expect, vi } from "@effect/vitest"; +import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -386,4 +386,83 @@ layer("GitLabCli.layer", (it) => { assert.strictEqual(error.cause, cause); }), ); + + it.effect("normalizes pasted repository URLs before the projects API lookup", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + path_with_namespace: "group/sub/project", + web_url: "https://sourcecontrol.example.com/group/sub/project", + http_url_to_repo: "https://sourcecontrol.example.com/group/sub/project.git", + ssh_url_to_repo: "git@sourcecontrol.example.com:group/sub/project.git", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "https://sourcecontrol.example.com/group/sub/project/", + }); + }); + + assert.deepStrictEqual(result.nameWithOwner, "group/sub/project"); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", `projects/${encodeURIComponent("group/sub/project")}`], + }), + ); + }), + ); +}); + +describe("normalizeGitLabRepositoryPath", () => { + it("keeps bare namespace/project paths untouched", () => { + expect(GitLabCli.normalizeGitLabRepositoryPath("group/project")).toBe("group/project"); + expect(GitLabCli.normalizeGitLabRepositoryPath(" group/sub/project ")).toBe( + "group/sub/project", + ); + }); + + it("extracts the project path from gitlab.com URLs", () => { + expect(GitLabCli.normalizeGitLabRepositoryPath("https://gitlab.com/group/project")).toBe( + "group/project", + ); + }); + + it("extracts the project path from self-hosted URLs on any hostname", () => { + expect( + GitLabCli.normalizeGitLabRepositoryPath("https://sourcecontrol.example.com/group/project"), + ).toBe("group/project"); + }); + + it("strips a .git suffix", () => { + expect( + GitLabCli.normalizeGitLabRepositoryPath( + "https://sourcecontrol.example.com/group/project.git", + ), + ).toBe("group/project"); + }); + + it("keeps nested group segments", () => { + expect( + GitLabCli.normalizeGitLabRepositoryPath("https://gitlab.com/group/sub/team/project"), + ).toBe("group/sub/team/project"); + }); + + it("ignores web UI sections and trailing slashes", () => { + expect(GitLabCli.normalizeGitLabRepositoryPath("https://gitlab.com/group/project/")).toBe( + "group/project", + ); + expect( + GitLabCli.normalizeGitLabRepositoryPath("https://gitlab.com/group/project/-/tree/main"), + ).toBe("group/project"); + }); }); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360247..690fc0e03964 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -390,6 +390,34 @@ function toSummaryWithOptionalUpdatedAt( return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; } +/** + * Accept either a bare `namespace/project` path or a pasted repository URL + * (any host, since glab resolves against its authenticated default host) and + * return the project path `glab api projects/` expects. + */ +export function normalizeGitLabRepositoryPath(repository: string): string { + const trimmed = repository.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + try { + const url = new URL(trimmed); + const segments = url.pathname.split("/").filter((segment) => segment.length > 0); + // Web URLs continue past the project into `/-/tree/main` style sections; + // everything from the `-` separator on belongs to the UI, not the project. + const separatorIndex = segments.indexOf("-"); + const projectSegments = separatorIndex > 0 ? segments.slice(0, separatorIndex) : segments; + const last = projectSegments.at(-1)?.replace(/\.git$/i, "") ?? ""; + if (projectSegments.length > 0) { + projectSegments[projectSegments.length - 1] = last; + } + return projectSegments.join("/"); + } catch { + return trimmed; + } +} + function parseRepositoryPath(repository: string): { readonly namespacePath: string | null; readonly projectPath: string; @@ -519,7 +547,10 @@ export const make = Effect.gen(function* () { getRepositoryCloneUrls: (input) => execute({ cwd: input.cwd, - args: ["api", `projects/${encodeURIComponent(input.repository)}`], + args: [ + "api", + `projects/${encodeURIComponent(normalizeGitLabRepositoryPath(input.repository))}`, + ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) =>