mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
* feat(app): link #123 issue and pull request references in Markdown Agents cite issues and pull requests by number; reading one meant looking up the URL by hand. Markdown in a workspace now links forge shorthand against the workspace's origin remote: `#N`, GitLab and Gitea `!N`, and cross-repository `owner/repo#N` on the same host. Each forge's URL grammar declares which sigil maps to which path, so the syntax stays per forge: GitHub has no `!`, and its `#` targets /issues/, which GitHub redirects to the pull request. Recognition goes through the Markdown engine's text link regex so code spans, fences and authored links stay plain without a second Markdown parse while streaming. The reference alternative sits before path recognition so `group/project#12` is not read as a file path. * fix(app): keep http:// for self-hosted forge web links A self-hosted forge cloned over http:// serves its web UI over http on the same origin. Forcing https made reference, blob and tree links fail against an HTTP-only port. Cloud hosts and SSH remotes stay on https.
197 lines
6.9 KiB
TypeScript
197 lines
6.9 KiB
TypeScript
/**
|
|
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
|
|
* branch tree). The host comes from the workspace remote — not a hardcoded
|
|
* cloud host — so self-hosted and Enterprise instances link correctly. Each
|
|
* forge contributes a small URL grammar (the path infixes and line-anchor
|
|
* format); an unknown forge has no grammar and yields null, so the action is
|
|
* simply absent rather than wrong.
|
|
*
|
|
* URL grammar lives on each client forge module. The repo identity and host
|
|
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
|
|
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
|
|
*/
|
|
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
|
|
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
|
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
|
import { getClientForgeLogicModule } from "@/git/forges";
|
|
|
|
export interface ForgeBlobUrlInput {
|
|
remoteUrl: string | null | undefined;
|
|
branch: string | null | undefined;
|
|
path: string | null | undefined;
|
|
lineStart?: number;
|
|
lineEnd?: number;
|
|
}
|
|
|
|
export interface ForgeBranchTreeUrlInput {
|
|
remoteUrl: string | null | undefined;
|
|
branch: string | null | undefined;
|
|
}
|
|
|
|
export interface ForgeReferenceUrlInput {
|
|
remoteUrl: string | null | undefined;
|
|
/** Repository path of a cross-repository reference; the remote's repository when absent. */
|
|
repo?: string | null;
|
|
sigil: ForgeReferenceSigil;
|
|
number: number;
|
|
}
|
|
|
|
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
|
|
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
|
|
if (!suffix) {
|
|
return null;
|
|
}
|
|
try {
|
|
const url = new URL(changeRequestUrl);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
url.pathname = `${url.pathname.replace(/\/$/, "")}${suffix}`;
|
|
return url.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface ForgeWebLocation {
|
|
/** "http" only for a self-hosted http:// remote; every other remote browses over https. */
|
|
scheme: "http" | "https";
|
|
host: string;
|
|
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
|
port?: string;
|
|
repo: string;
|
|
}
|
|
|
|
/**
|
|
* Web host + repo path from a remote. The host is the remote's host, except a
|
|
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
|
|
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
|
|
* Self-hosted hosts are returned untouched. The repo path supports nested
|
|
* groups (e.g. GitLab subgroups) since it is the full remote path.
|
|
*/
|
|
function isValidRepoPath(path: string): boolean {
|
|
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
if (segments.length === 0) {
|
|
return false;
|
|
}
|
|
return !segments.includes("..");
|
|
}
|
|
|
|
function resolveForgeWebLocation(
|
|
forge: string,
|
|
remoteUrl: string | null | undefined,
|
|
): ForgeWebLocation | null {
|
|
if (!remoteUrl) {
|
|
return null;
|
|
}
|
|
const location = parseGitRemoteLocation(remoteUrl);
|
|
if (!location || !isValidRepoPath(location.path)) {
|
|
return null;
|
|
}
|
|
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
|
|
const isCloudHost = cloudHosts.includes(location.host);
|
|
const webHost = isCloudHost ? cloudHosts[0] : location.host;
|
|
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
|
|
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
|
|
// 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;
|
|
const scheme = !isCloudHost && location.transport === "http" ? "http" : "https";
|
|
return { scheme, host: webHost, port, repo: location.path };
|
|
}
|
|
|
|
function encodeBranch(branch: string): string {
|
|
return branch.split("/").map(encodeURIComponent).join("/");
|
|
}
|
|
|
|
/** Scheme and host, plus `:port` when the remote pins a non-default port. */
|
|
function forgeOrigin(location: ForgeWebLocation): string {
|
|
const authority = location.port ? `${location.host}:${location.port}` : location.host;
|
|
return `${location.scheme}://${authority}`;
|
|
}
|
|
|
|
function normalizeBlobPath(path: string | null | undefined): string | null {
|
|
const segments: string[] = [];
|
|
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
for (const segment of trimmed.split("/")) {
|
|
if (!segment || segment === ".") {
|
|
continue;
|
|
}
|
|
if (segment === "..") {
|
|
if (segments.length === 0) {
|
|
return null;
|
|
}
|
|
segments.pop();
|
|
continue;
|
|
}
|
|
segments.push(segment);
|
|
}
|
|
return segments.join("/") || null;
|
|
}
|
|
|
|
export function buildForgeBranchTreeUrl(
|
|
forge: string,
|
|
input: ForgeBranchTreeUrlInput,
|
|
): string | null {
|
|
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
|
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
|
const branch = input.branch?.trim();
|
|
if (!grammar || !location || !branch || branch === "HEAD") {
|
|
return null;
|
|
}
|
|
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
|
}
|
|
|
|
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
|
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
|
|
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
|
const branch = input.branch?.trim();
|
|
const filePath = normalizeBlobPath(input.path);
|
|
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
|
|
return null;
|
|
}
|
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
let url = `${forgeOrigin(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
|
if (input.lineStart && input.lineStart > 0) {
|
|
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Web URL of a shorthand reference such as `#12` or `group/project!12`. A
|
|
* cross-repository reference stays on the remote's host. Null when the forge
|
|
* has no shorthand for the sigil.
|
|
*/
|
|
export function buildForgeReferenceUrl(
|
|
forge: string,
|
|
input: ForgeReferenceUrlInput,
|
|
): string | null {
|
|
const referencePath = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths?.find(
|
|
(path) => path.sigil === input.sigil,
|
|
);
|
|
const location = resolveForgeWebLocation(forge, input.remoteUrl);
|
|
const repo = input.repo ?? location?.repo;
|
|
if (
|
|
!referencePath ||
|
|
!location ||
|
|
!repo ||
|
|
!isValidRepoPath(repo) ||
|
|
!Number.isSafeInteger(input.number) ||
|
|
input.number <= 0
|
|
) {
|
|
return null;
|
|
}
|
|
return `${forgeOrigin(location)}/${repo}${referencePath.infix}${input.number}`;
|
|
}
|
|
|
|
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
|
|
export function hasForgeWebUrls(forge: string): boolean {
|
|
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
|
|
}
|