diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 8f64bf8f..0cae828d 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -187,16 +187,17 @@ describe("MarkdownBody", () => { expect(html).not.toContain('aria-label="Issue PAP-1271: PAP-1271"'); }); - it("rewrites full issue URLs to internal issue links", () => { - const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [ - { identifier: "PAP-1179", status: "blocked" }, + it("preserves absolute issue URLs as external links", () => { + const url = "http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d"; + const html = renderMarkdown(`See ${url}.`, [ + { identifier: "PAPA-115", status: "blocked" }, ]); - expect(html).toContain('href="/issues/PAP-1179"'); - expect(html).toContain("text-red-600"); - expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<"); - expect(html).toContain('data-mention-kind="issue"'); - expect(html).not.toContain("paperclip-mention-chip--issue"); + expect(html).toContain(`href="${url}"`); + expect(html).toContain('target="_blank"'); + expect(html).toContain("lucide-external-link"); + expect(html).not.toContain('href="/issues/PAPA-115"'); + expect(html).not.toContain("paperclip-markdown-issue-ref"); }); it("linkifies plain internal issue paths in markdown text", () => { diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index cd0f038a..54224bfb 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -9,8 +9,9 @@ describe("issue-reference", () => { expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); - it("extracts issue ids from full issue URLs", () => { - expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179"); + it("does not treat full issue URLs as internal issue paths", () => { + expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBeNull(); + expect(parseIssuePathIdFromPath("http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d")).toBeNull(); }); it("does not treat GitHub issue URLs as internal Paperclip issue links", () => { @@ -24,15 +25,11 @@ describe("issue-reference", () => { expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); }); - it("normalizes bare identifiers, issue URLs, and issue scheme links into internal links", () => { + it("normalizes bare identifiers, relative issue paths, and issue scheme links into internal links", () => { expect(parseIssueReferenceFromHref("pap-1271")).toEqual({ issuePathId: "PAP-1271", href: "/issues/PAP-1271", }); - expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toEqual({ - issuePathId: "PAP-1179", - href: "/issues/PAP-1179", - }); expect(parseIssueReferenceFromHref("/PAP/issues/pap-1180")).toEqual({ issuePathId: "PAP-1180", href: "/issues/PAP-1180", @@ -54,6 +51,11 @@ describe("issue-reference", () => { }); }); + it("preserves absolute Paperclip issue URLs so origin, port, and hash are not lost", () => { + expect(parseIssueReferenceFromHref("http://localhost:3100/PAP/issues/PAP-1179")).toBeNull(); + expect(parseIssueReferenceFromHref("http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d")).toBeNull(); + }); + it("ignores literal route placeholder paths", () => { expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull(); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index cbef86ff..616bb876 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -11,18 +11,9 @@ const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null { if (!pathOrUrl) return null; - let pathname = pathOrUrl.trim(); + const pathname = pathOrUrl.trim(); if (!pathname) return null; - - if (/^https?:\/\//i.test(pathname)) { - try { - const url = new URL(pathname); - if (url.hostname === "github.com" || url.hostname === "www.github.com") return null; - pathname = url.pathname; - } catch { - return null; - } - } + if (/^https?:\/\//i.test(pathname)) return null; const segments = pathname.split("/").filter(Boolean); const issueIndex = segments.findIndex((segment) => segment === "issues"); diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d2dbe1c7..b12311b4 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -37,6 +37,10 @@ export type ParsedMentionChip = const iconMaskCache = new Map(); export function parseMentionChipHref(href: string): ParsedMentionChip | null { + if (/^https?:\/\//i.test(href.trim())) { + return null; + } + const issue = parseIssueReferenceHref(href); if (issue) { return {