Link #123 issue and pull request references in chat (#8636)

* 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:
Mohamed Boudra
2026-11-03 13:00:24 +07:00
committed by GitHub
parent f4eeb8a538
commit 85be2121b8
17 changed files with 401 additions and 30 deletions
+9 -2
View File
@@ -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 &#6; 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] : []));
}
+68
View File
@@ -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([
[
+46 -6
View File
@@ -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;
+2 -1
View File
@@ -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({
+2 -2
View File
@@ -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({