Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion apps/server/src/sourceControl/GitLabCli.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
});
});
33 changes: 32 additions & 1 deletion apps/server/src/sourceControl/GitLabCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded path>` 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High sourceControl/GitLabCli.ts:406

For a relative-root GitLab installation, normalizeGitLabRepositoryPath includes the instance base path in the project identifier: https://example.com/gitlab/group/project becomes gitlab/group/project instead of group/project, so getRepositoryCloneUrls queries the wrong project. Remove the host's configured GitLab base path before extracting the namespace/project path.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/sourceControl/GitLabCli.ts around line 406:

For a relative-root GitLab installation, `normalizeGitLabRepositoryPath` includes the instance base path in the project identifier: `https://example.com/gitlab/group/project` becomes `gitlab/group/project` instead of `group/project`, so `getRepositoryCloneUrls` queries the wrong project. Remove the host's configured GitLab base path before extracting the namespace/project path.

Comment on lines +405 to +406

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the pasted GitLab host during lookup

When a user is authenticated to multiple GitLab instances and pastes a URL whose host is not the host selected by glab for the current directory, this discards url.host, so URLs such as https://gitlab.example.com/group/project and https://gitlab.com/group/project invoke the same API command and can query the wrong instance. The lookup must carry the pasted host through to the glab invocation rather than using only the pathname.

Useful? React with 👍 / 👎.

Comment on lines +405 to +406

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for GitLab installations hosted below an origin path

For a self-hosted instance configured under a relative URL root, such as https://example.com/gitlab, the valid project URL https://example.com/gitlab/group/project is normalized to gitlab/group/project; the API project identifier is actually group/project, so the lookup still fails for this supported self-hosted layout. The installation prefix needs to be distinguished from the repository namespace instead of treating every pathname segment as part of the project ID.

Useful? React with 👍 / 👎.

// 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;
Expand Down Expand Up @@ -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) =>
Expand Down
Loading