mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +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.
This commit is contained in:
@@ -41,8 +41,15 @@ export interface ForgeReferencePath {
|
||||
kind: "change_request" | "issue";
|
||||
/** Path between the repository path and the numeric local id. */
|
||||
infix: string;
|
||||
/**
|
||||
* Shorthand marker the forge links in prose, e.g. GitHub `#12`, GitLab `!12`.
|
||||
* Absent when the forge has no shorthand for this kind.
|
||||
*/
|
||||
sigil?: ForgeReferenceSigil;
|
||||
}
|
||||
|
||||
export type ForgeReferenceSigil = "#" | "!";
|
||||
|
||||
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
|
||||
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
|
||||
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
|
||||
@@ -54,8 +61,8 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
|
||||
blobInfix: "/src/branch/",
|
||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/pulls/" },
|
||||
{ kind: "issue", infix: "/issues/" },
|
||||
{ kind: "change_request", infix: "/pulls/", sigil: "!" },
|
||||
{ kind: "issue", infix: "/issues/", sigil: "#" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
export { createForgeShorthand, type ForgeShorthand } from "./shorthand";
|
||||
|
||||
const ForgeShorthandContext = createContext<ForgeShorthand | null>(null);
|
||||
|
||||
/** Makes `#12`-style references in descendant Markdown open on the workspace forge. */
|
||||
export function ForgeShorthandProvider({
|
||||
forge,
|
||||
remoteUrl,
|
||||
children,
|
||||
}: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(() => createForgeShorthand({ forge, remoteUrl }), [forge, remoteUrl]);
|
||||
return <ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>;
|
||||
}
|
||||
|
||||
export function useForgeShorthand(): ForgeShorthand | null {
|
||||
return useContext(ForgeShorthandContext);
|
||||
}
|
||||
|
||||
/** Carries the shorthand environment across a portal. */
|
||||
export function useForgeShorthandBridge() {
|
||||
const value = useContext(ForgeShorthandContext);
|
||||
return useCallback(
|
||||
(children: ReactNode) => (
|
||||
<ForgeShorthandContext.Provider value={value}>{children}</ForgeShorthandContext.Provider>
|
||||
),
|
||||
[value],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createForgeShorthand } from "./shorthand";
|
||||
|
||||
function matches(shorthand: { pattern: string }, text: string): string[] {
|
||||
return [...text.matchAll(new RegExp(shorthand.pattern, "g"))].map((match) => match[0]);
|
||||
}
|
||||
|
||||
describe("createForgeShorthand", () => {
|
||||
const github = createForgeShorthand({
|
||||
forge: "github",
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
})!;
|
||||
const gitlab = createForgeShorthand({
|
||||
forge: "gitlab",
|
||||
remoteUrl: "https://gitlab.com/g/p.git",
|
||||
})!;
|
||||
|
||||
it("resolves same-repo and cross-repo references on the remote's host", () => {
|
||||
expect(github.resolve("#8613")).toBe("https://github.com/getpaseo/paseo/issues/8613");
|
||||
expect(github.resolve("acme/tools#4")).toBe("https://github.com/acme/tools/issues/4");
|
||||
expect(gitlab.resolve("!7")).toBe("https://gitlab.com/g/p/-/merge_requests/7");
|
||||
expect(gitlab.resolve("g/sub/q#3")).toBe("https://gitlab.com/g/sub/q/-/issues/3");
|
||||
});
|
||||
|
||||
it("only recognizes the sigils the forge declares", () => {
|
||||
expect(matches(github, "see #1 and !2")).toEqual(["#1"]);
|
||||
expect(github.resolve("!2")).toBeNull();
|
||||
expect(matches(gitlab, "see #1 and !2")).toEqual(["#1", "!2"]);
|
||||
});
|
||||
|
||||
it("recognizes references only at word boundaries", () => {
|
||||
expect(
|
||||
matches(
|
||||
github,
|
||||
"(#1), #2. acme/tools#3, foo#4 #5abc  https://x/#7 #0 #08 wow!9 user@host#10",
|
||||
),
|
||||
).toEqual(["#1", "#2", "acme/tools#3"]);
|
||||
});
|
||||
|
||||
it("falls back to the cloud host forge when the daemon reports none", () => {
|
||||
expect(
|
||||
createForgeShorthand({ forge: undefined, remoteUrl: "git@github.com:a/b.git" })?.resolve(
|
||||
"#1",
|
||||
),
|
||||
).toBe("https://github.com/a/b/issues/1");
|
||||
});
|
||||
|
||||
it("is absent without a remote or a known forge", () => {
|
||||
expect(createForgeShorthand({ forge: "github", remoteUrl: null })).toBeNull();
|
||||
expect(
|
||||
createForgeShorthand({ forge: undefined, remoteUrl: "git@git.corp:a/b.git" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ForgeReferenceSigil } from "@/git/client-forge-module";
|
||||
import { forgeFromRemoteUrl } from "@/git/forge";
|
||||
import { getClientForgeLogicModule } from "@/git/forges";
|
||||
import { buildForgeReferenceUrl } from "@/git/forge-url";
|
||||
|
||||
/**
|
||||
* Shorthand references in prose, such as `#12`, `!12` or `owner/repo#12`,
|
||||
* resolved against one workspace remote.
|
||||
*/
|
||||
export interface ForgeShorthand {
|
||||
/** Regex source for recognizing references in Markdown text. */
|
||||
pattern: string;
|
||||
/** Web URL for a recognized reference, or null. */
|
||||
resolve(text: string): string | null;
|
||||
}
|
||||
|
||||
// Keep these in the syntax shared by JavaScript, ICU and java.util.regex: the
|
||||
// pattern runs in the native Markdown engines too.
|
||||
const REPO = String.raw`[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)+`;
|
||||
const NUMBER = String.raw`[1-9][0-9]{0,8}`;
|
||||
// A reference never continues a word, path, URL fragment or entity.
|
||||
const BEFORE = String.raw`(?<![A-Za-z0-9_./@#!&-])`;
|
||||
const AFTER = String.raw`(?![A-Za-z0-9_])`;
|
||||
const REFERENCE = new RegExp(String.raw`^(?:(${REPO}))?([#!])(${NUMBER})$`);
|
||||
|
||||
export function createForgeShorthand(input: {
|
||||
forge: string | null | undefined;
|
||||
remoteUrl: string | null | undefined;
|
||||
}): ForgeShorthand | null {
|
||||
const remoteUrl = input.remoteUrl;
|
||||
const forge = input.forge || forgeFromRemoteUrl(remoteUrl);
|
||||
if (!forge || !remoteUrl) return null;
|
||||
const sigils = forgeSigils(forge).filter(
|
||||
(sigil) => buildForgeReferenceUrl(forge, { remoteUrl, sigil, number: 1 }) !== null,
|
||||
);
|
||||
if (sigils.length === 0) return null;
|
||||
return {
|
||||
pattern: `${BEFORE}(?:${REPO})?[${sigils.join("")}]${NUMBER}${AFTER}`,
|
||||
resolve(text) {
|
||||
const match = REFERENCE.exec(text);
|
||||
if (!match) return null;
|
||||
return buildForgeReferenceUrl(forge, {
|
||||
remoteUrl,
|
||||
repo: match[1] ?? null,
|
||||
sigil: match[2] as ForgeReferenceSigil,
|
||||
number: Number(match[3]),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function forgeSigils(forge: string): ForgeReferenceSigil[] {
|
||||
const paths = getClientForgeLogicModule(forge)?.urlGrammar?.referencePaths ?? [];
|
||||
return paths.flatMap((path) => (path.sigil ? [path.sigil] : []));
|
||||
}
|
||||
@@ -3,9 +3,77 @@ import {
|
||||
buildForgeBlobUrl,
|
||||
buildForgeBranchTreeUrl,
|
||||
buildForgeChecksUrl,
|
||||
buildForgeReferenceUrl,
|
||||
hasForgeWebUrls,
|
||||
} from "./forge-url";
|
||||
|
||||
describe("buildForgeReferenceUrl", () => {
|
||||
it.each([
|
||||
["github", "git@github.com:acme/repo.git", "#", null, "https://github.com/acme/repo/issues/12"],
|
||||
["github", "git@github.com:acme/repo.git", "!", null, null],
|
||||
[
|
||||
"github",
|
||||
"https://github.com/acme/repo",
|
||||
"#",
|
||||
"other/tools",
|
||||
"https://github.com/other/tools/issues/12",
|
||||
],
|
||||
["gitlab", "git@gitlab.com:g/sub/p.git", "#", null, "https://gitlab.com/g/sub/p/-/issues/12"],
|
||||
[
|
||||
"gitlab",
|
||||
"git@gitlab.com:g/sub/p.git",
|
||||
"!",
|
||||
null,
|
||||
"https://gitlab.com/g/sub/p/-/merge_requests/12",
|
||||
],
|
||||
[
|
||||
"gitlab",
|
||||
"https://gitlab.corp:8443/g/p.git",
|
||||
"!",
|
||||
"g/q",
|
||||
"https://gitlab.corp:8443/g/q/-/merge_requests/12",
|
||||
],
|
||||
[
|
||||
"codeberg",
|
||||
"https://codeberg.org/acme/repo.git",
|
||||
"!",
|
||||
null,
|
||||
"https://codeberg.org/acme/repo/pulls/12",
|
||||
],
|
||||
[
|
||||
"gitea",
|
||||
"http://forge.test:3000/team/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"http://forge.test:3000/team/repo/issues/12",
|
||||
],
|
||||
[
|
||||
"github",
|
||||
"http://github.com/acme/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"https://github.com/acme/repo/issues/12",
|
||||
],
|
||||
[
|
||||
"forgejo",
|
||||
"git@forge.example:acme/repo.git",
|
||||
"#",
|
||||
null,
|
||||
"https://forge.example/acme/repo/issues/12",
|
||||
],
|
||||
["bitbucket", "git@bitbucket.org:acme/repo.git", "#", null, null],
|
||||
["github", null, "#", null, null],
|
||||
["github", "git@github.com:acme/repo.git", "#", "../escape", null],
|
||||
] as const)("builds %s %s %s in %s", (forge, remoteUrl, sigil, repo, expected) => {
|
||||
expect(buildForgeReferenceUrl(forge, { remoteUrl, repo, sigil, number: 12 })).toBe(expected);
|
||||
});
|
||||
|
||||
it("rejects non-positive numbers", () => {
|
||||
const remoteUrl = "git@github.com:acme/repo.git";
|
||||
expect(buildForgeReferenceUrl("github", { remoteUrl, sigil: "#", number: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildForgeChecksUrl", () => {
|
||||
it.each([
|
||||
[
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
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 {
|
||||
@@ -27,6 +28,14 @@ export interface ForgeBranchTreeUrlInput {
|
||||
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) {
|
||||
@@ -45,6 +54,8 @@ export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): st
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -87,16 +98,18 @@ function resolveForgeWebLocation(
|
||||
!isCloudHost && (location.transport === "http" || location.transport === "https")
|
||||
? location.port
|
||||
: undefined;
|
||||
return { host: webHost, port, repo: location.path };
|
||||
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("/");
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 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 {
|
||||
@@ -131,7 +144,7 @@ export function buildForgeBranchTreeUrl(
|
||||
if (!grammar || !location || !branch || branch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
return `${forgeOrigin(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
|
||||
}
|
||||
|
||||
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
|
||||
@@ -143,13 +156,40 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
|
||||
return null;
|
||||
}
|
||||
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
||||
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
|
||||
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;
|
||||
|
||||
@@ -89,7 +89,8 @@ export const githubForgeLogic = {
|
||||
changeRequestChecksSuffix: "/checks",
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/pull/" },
|
||||
{ kind: "issue", infix: "/issues/" },
|
||||
// GitHub redirects /issues/N to /pull/N, so `#N` covers both kinds.
|
||||
{ kind: "issue", infix: "/issues/", sigil: "#" },
|
||||
],
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
|
||||
@@ -166,8 +166,8 @@ export const gitlabForgeLogic = {
|
||||
lineAnchor: gitlabLineAnchor,
|
||||
changeRequestChecksSuffix: "/pipelines",
|
||||
referencePaths: [
|
||||
{ kind: "change_request", infix: "/-/merge_requests/" },
|
||||
{ kind: "issue", infix: "/-/issues/" },
|
||||
{ kind: "change_request", infix: "/-/merge_requests/", sigil: "!" },
|
||||
{ kind: "issue", infix: "/-/issues/", sigil: "#" },
|
||||
],
|
||||
},
|
||||
facts: defineForgeFacts({
|
||||
|
||||
Reference in New Issue
Block a user