fix(forge): preserve non-default port in forge web URLs (#2478)
"Open in browser" links for a self-hosted forge served on a non-standard port (e.g. Forgejo/Gitea on :60443) dropped the port, producing https://host/owner/repo/... instead of https://host:60443/owner/repo/..., which 404s or hits the wrong service. parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the origin from the portless host. Preserve the port on GitRemoteLocation and reattach it in the web-URL builders, only for self-hosted http(s) origins (an SSH port isn't the web port; a canonicalized cloud host uses the default port). Host-identity matching (forge detection, cloud-host checks) stays port-agnostic. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c596e058cd
commit
869edcbf11
@@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => {
|
|||||||
).toBe("https://codeberg.org/acme/repo/src/branch/main");
|
).toBe("https://codeberg.org/acme/repo/src/branch/main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||||
|
expect(
|
||||||
|
buildForgeBranchTreeUrl("forgejo", {
|
||||||
|
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||||
|
branch: "master",
|
||||||
|
}),
|
||||||
|
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the port for a self-hosted remote on the default port", () => {
|
||||||
|
expect(
|
||||||
|
buildForgeBranchTreeUrl("forgejo", {
|
||||||
|
remoteUrl: "https://home-git.example.com/team/repo.git",
|
||||||
|
branch: "master",
|
||||||
|
}),
|
||||||
|
).toBe("https://home-git.example.com/team/repo/src/branch/master");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when the current branch is unavailable", () => {
|
it("returns null when the current branch is unavailable", () => {
|
||||||
expect(
|
expect(
|
||||||
buildForgeBranchTreeUrl("github", {
|
buildForgeBranchTreeUrl("github", {
|
||||||
@@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => {
|
|||||||
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
|
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a non-default port for a self-hosted https remote", () => {
|
||||||
|
expect(
|
||||||
|
buildForgeBlobUrl("forgejo", {
|
||||||
|
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
|
||||||
|
branch: "master",
|
||||||
|
path: "src/index.ts",
|
||||||
|
lineStart: 12,
|
||||||
|
lineEnd: 20,
|
||||||
|
}),
|
||||||
|
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20");
|
||||||
|
});
|
||||||
|
|
||||||
it("canonicalizes the github.com SSH-alias host to the web host", () => {
|
it("canonicalizes the github.com SSH-alias host to the web host", () => {
|
||||||
expect(
|
expect(
|
||||||
buildForgeBlobUrl("github", {
|
buildForgeBlobUrl("github", {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput {
|
|||||||
|
|
||||||
interface ForgeWebLocation {
|
interface ForgeWebLocation {
|
||||||
host: string;
|
host: string;
|
||||||
|
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
||||||
|
port?: string;
|
||||||
repo: string;
|
repo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +60,28 @@ function resolveForgeWebLocation(
|
|||||||
if (!location || !isValidRepoPath(location.path)) {
|
if (!location || !isValidRepoPath(location.path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
|
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
||||||
const webHost =
|
const isCloudHost = cloudHosts.includes(location.host);
|
||||||
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
|
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
||||||
? normalizeHost(cloudHosts[0])
|
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
||||||
: location.host;
|
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
||||||
return { host: webHost, repo: location.path };
|
// web port, and a canonicalized cloud host always serves on the default port.
|
||||||
|
const port =
|
||||||
|
!isCloudHost && (location.transport === "http" || location.transport === "https")
|
||||||
|
? location.port
|
||||||
|
: undefined;
|
||||||
|
return { host: webHost, port, repo: location.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeBranch(branch: string): string {
|
function encodeBranch(branch: string): string {
|
||||||
return branch.split("/").map(encodeURIComponent).join("/");
|
return branch.split("/").map(encodeURIComponent).join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host, plus `:port` when the remote pins a non-default port. */
|
||||||
|
function forgeAuthority(location: ForgeWebLocation): string {
|
||||||
|
return location.port ? `${location.host}:${location.port}` : location.host;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBlobPath(path: string | null | undefined): string | null {
|
function normalizeBlobPath(path: string | null | undefined): string | null {
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
@@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl(
|
|||||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||||
@@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||||
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||||
if (input.lineStart && input.lineStart > 0) {
|
if (input.lineStart && input.lineStart > 0) {
|
||||||
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,3 +27,25 @@ describe("isCompleteGitRemote", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseGitRemoteLocation port", () => {
|
||||||
|
it("preserves an explicit non-default port from an https remote", () => {
|
||||||
|
expect(parseGitRemoteLocation("https://home-git.example.com:60443/team/repo.git")?.port).toBe(
|
||||||
|
"60443",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a port from a plain http remote", () => {
|
||||||
|
expect(parseGitRemoteLocation("http://internal.example.com:3000/team/repo.git")?.port).toBe(
|
||||||
|
"3000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the port for a default-port remote", () => {
|
||||||
|
expect(parseGitRemoteLocation("https://github.com/acme/repo.git")?.port).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no port for an scp-form remote", () => {
|
||||||
|
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
|
|||||||
export interface GitRemoteLocation {
|
export interface GitRemoteLocation {
|
||||||
transport: "scp" | "ssh" | "http" | "https";
|
transport: "scp" | "ssh" | "http" | "https";
|
||||||
host: string;
|
host: string;
|
||||||
|
/**
|
||||||
|
* Explicit non-default port from the remote (e.g. a self-hosted forge on
|
||||||
|
* `:60443`), or undefined for a default-port or scp-form remote. Kept separate
|
||||||
|
* from `host` so host-identity matching (forge detection, cloud-host checks)
|
||||||
|
* stays port-agnostic; only consumers that reconstruct a URL (web links) use it.
|
||||||
|
*/
|
||||||
|
port?: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +78,7 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
|
|||||||
const normalizedPath = normalizeRemotePath(path);
|
const normalizedPath = normalizeRemotePath(path);
|
||||||
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
if (!isValidRemoteHost(host) || !normalizedPath) return null;
|
||||||
|
|
||||||
return { transport, host, path: normalizedPath };
|
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user