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);
}