From 879fde73f681a9cc64e66340b320ed252168263a Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 00:36:57 -0500 Subject: [PATCH] PAP-2118 fix markdown issue path linkification Co-Authored-By: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 29 +++++++++++++++++++++++++ ui/src/lib/issue-reference.test.ts | 5 +++++ ui/src/lib/issue-reference.ts | 11 +++++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 8ebae373c5c..a267dee0e0d 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -173,6 +173,20 @@ describe("MarkdownBody", () => { expect(html).not.toContain("paperclip-mention-chip--issue"); }); + it("linkifies plain internal issue paths in markdown text", () => { + const html = renderMarkdown("See /issues/PAP-1179 and /PAP/issues/pap-1180 for context.", [ + { identifier: "PAP-1179", status: "blocked" }, + { identifier: "PAP-1180", status: "done" }, + ]); + + expect(html).toContain('href="/issues/PAP-1179"'); + expect(html).toContain('href="/issues/PAP-1180"'); + expect(html).toContain(">/issues/PAP-1179<"); + expect(html).toContain(">/PAP/issues/pap-1180<"); + expect(html).toContain("text-red-600"); + expect(html).toContain("text-green-600"); + }); + it("rewrites issue scheme links to internal issue links", () => { const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [ { identifier: "PAP-1310", status: "done" }, @@ -198,6 +212,21 @@ describe("MarkdownBody", () => { expect(html).toContain("paperclip-markdown-issue-ref"); }); + it("keeps trailing punctuation outside auto-linked issue references", () => { + const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [ + { identifier: "PAP-1271", status: "done" }, + { identifier: "PAP-1272", status: "blocked" }, + { identifier: "PAP-1273", status: "todo" }, + ]); + + expect(html).toContain('PAP-1271:'); + expect(html).toContain('/issues/PAP-1272]'); + expect(html).toContain('issue://PAP-1273.'); + }); + it("can opt out of issue reference linkification for offline previews", () => { const html = renderToStaticMarkup( diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index 8eeca53a19b..cd0f038a7ec 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -4,6 +4,7 @@ import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-r describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); + expect(parseIssuePathIdFromPath("/PAP/issues/pap-1272")).toBe("PAP-1272"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); @@ -32,6 +33,10 @@ describe("issue-reference", () => { issuePathId: "PAP-1179", href: "/issues/PAP-1179", }); + expect(parseIssueReferenceFromHref("/PAP/issues/pap-1180")).toEqual({ + issuePathId: "PAP-1180", + href: "/issues/PAP-1180", + }); expect(parseIssueReferenceFromHref("issue://PAP-1310")).toEqual({ issuePathId: "PAP-1310", href: "/issues/PAP-1310", diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 5e03e10eb26..1596766079e 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -7,7 +7,7 @@ type MarkdownNode = { const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i; const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i; -const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi; +const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi; export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null { if (!pathOrUrl) return null; @@ -29,7 +29,7 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): if (issueIndex === -1 || issueIndex === segments.length - 1) return null; const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); if (!issuePathId || issuePathId.startsWith(":")) return null; - return issuePathId; + return BARE_ISSUE_IDENTIFIER_RE.test(issuePathId) ? issuePathId.toUpperCase() : issuePathId; } export function parseIssueReferenceFromHref(href: string | null | undefined) { @@ -66,12 +66,17 @@ function splitTrailingPunctuation(token: string) { while (core.length > 0) { const lastChar = core.at(-1); - if (!lastChar || !/[),.;!?]/.test(lastChar)) break; + if (!lastChar || !/[),.;!?:\]]/.test(lastChar)) break; if (lastChar === ")") { const openCount = (core.match(/\(/g) ?? []).length; const closeCount = (core.match(/\)/g) ?? []).length; if (closeCount <= openCount) break; } + if (lastChar === "]") { + const openCount = (core.match(/\[/g) ?? []).length; + const closeCount = (core.match(/\]/g) ?? []).length; + if (closeCount <= openCount) break; + } trailing = `${lastChar}${trailing}`; core = core.slice(0, -1); }