Merge pull request #3355 from cryppadotta/pap-1331-issue-thread-ux

feat: polish issue thread markdown and references
This commit is contained in:
Dotta 2026-04-11 06:55:26 -05:00 committed by GitHub
commit e1bf9d66a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 660 additions and 27 deletions

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { parseIssuePathIdFromPath, parseIssueReferenceFromHref } from "./issue-reference";
describe("issue-reference", () => {
it("extracts issue ids from company-scoped issue paths", () => {
expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271");
expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179");
});
it("extracts issue ids from full issue URLs", () => {
expect(parseIssuePathIdFromPath("http://localhost:3100/PAP/issues/PAP-1179")).toBe("PAP-1179");
});
it("normalizes bare identifiers and issue URLs 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",
});
});
it("normalizes exact inline-code-like issue identifiers", () => {
expect(parseIssueReferenceFromHref("PAP-1271")).toEqual({
issuePathId: "PAP-1271",
href: "/issues/PAP-1271",
});
});
});

View file

@ -0,0 +1,143 @@
type MarkdownNode = {
type: string;
value?: string;
url?: string;
children?: MarkdownNode[];
};
const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i;
const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\b[A-Z][A-Z0-9]+-\d+\b/gi;
export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null {
if (!pathOrUrl) return null;
let pathname = pathOrUrl.trim();
if (!pathname) return null;
if (/^https?:\/\//i.test(pathname)) {
try {
pathname = new URL(pathname).pathname;
} catch {
return null;
}
}
const segments = pathname.split("/").filter(Boolean);
const issueIndex = segments.findIndex((segment) => segment === "issues");
if (issueIndex === -1 || issueIndex === segments.length - 1) return null;
return decodeURIComponent(segments[issueIndex + 1] ?? "");
}
export function parseIssueReferenceFromHref(href: string | null | undefined) {
if (!href) return null;
const pathId = parseIssuePathIdFromPath(href);
if (pathId) {
return {
issuePathId: pathId,
href: `/issues/${encodeURIComponent(pathId)}`,
};
}
const trimmed = href.trim();
if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null;
const normalized = trimmed.toUpperCase();
return {
issuePathId: normalized,
href: `/issues/${encodeURIComponent(normalized)}`,
};
}
function splitTrailingPunctuation(token: string) {
let core = token;
let trailing = "";
while (core.length > 0) {
const lastChar = core.at(-1);
if (!lastChar || !/[),.;!?]/.test(lastChar)) 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);
}
return { core, trailing };
}
function createIssueLinkNode(value: string, href: string, childType: "text" | "inlineCode" = "text"): MarkdownNode {
return {
type: "link",
url: href,
children: [{ type: childType, value }],
};
}
function linkifyIssueReferencesInText(value: string): MarkdownNode[] | null {
const nodes: MarkdownNode[] = [];
let cursor = 0;
let matched = false;
for (const match of value.matchAll(ISSUE_REFERENCE_TOKEN_RE)) {
const raw = match[0];
if (!raw) continue;
const start = match.index ?? 0;
const end = start + raw.length;
const { core, trailing } = splitTrailingPunctuation(raw);
const issueRef = parseIssueReferenceFromHref(core);
if (!issueRef) continue;
matched = true;
if (start > cursor) {
nodes.push({ type: "text", value: value.slice(cursor, start) });
}
nodes.push(createIssueLinkNode(core, issueRef.href));
if (trailing) {
nodes.push({ type: "text", value: trailing });
}
cursor = end;
}
if (!matched) return null;
if (cursor < value.length) {
nodes.push({ type: "text", value: value.slice(cursor) });
}
return nodes;
}
function rewriteMarkdownTree(node: MarkdownNode) {
if (!Array.isArray(node.children) || node.children.length === 0) return;
if (node.type === "link" || node.type === "linkReference" || node.type === "code" || node.type === "definition" || node.type === "html") {
return;
}
const nextChildren: MarkdownNode[] = [];
for (const child of node.children) {
if (child.type === "inlineCode" && typeof child.value === "string") {
const issueRef = parseIssueReferenceFromHref(child.value);
if (issueRef) {
nextChildren.push(createIssueLinkNode(child.value, issueRef.href, "inlineCode"));
continue;
}
}
if (child.type === "text" && typeof child.value === "string") {
const linked = linkifyIssueReferencesInText(child.value);
if (linked) {
nextChildren.push(...linked);
continue;
}
}
rewriteMarkdownTree(child);
nextChildren.push(child);
}
node.children = nextChildren;
}
export function remarkLinkIssueReferences() {
return (tree: MarkdownNode) => {
rewriteMarkdownTree(tree);
};
}

View file

@ -312,6 +312,22 @@ describe("optimistic issue comments", () => {
projectWorkspaceId: "workspace-1",
goalId: null,
parentId: null,
ancestors: [
{
id: "issue-9",
identifier: "PAP-9",
title: "Old parent",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
goalId: null,
project: null,
goal: null,
},
],
title: "Fix property pane",
description: null,
status: "todo",
@ -449,6 +465,7 @@ describe("optimistic issue comments", () => {
assigneeUserId: "board-2",
labelIds: ["label-2"],
blockedByIssueIds: ["issue-3"],
parentId: "issue-4",
projectId: "project-2",
executionWorkspaceId: "exec-2",
},
@ -460,6 +477,8 @@ describe("optimistic issue comments", () => {
expect(next?.labelIds).toEqual(["label-2"]);
expect(next?.labels?.map((label) => label.id)).toEqual(["label-2"]);
expect(next?.blockedBy?.map((relation) => relation.id)).toEqual(["issue-3"]);
expect(next?.parentId).toBe("issue-4");
expect(next?.ancestors).toBeUndefined();
expect(next?.projectId).toBe("project-2");
expect(next?.project).toBeNull();
expect(next?.executionWorkspaceId).toBe("exec-2");

View file

@ -169,6 +169,7 @@ export function applyOptimisticIssueFieldUpdate(
assign("assigneeAgentId");
assign("assigneeUserId");
assign("projectId");
assign("parentId");
assign("projectWorkspaceId");
assign("executionWorkspaceId");
assign("executionWorkspacePreference");
@ -194,6 +195,10 @@ export function applyOptimisticIssueFieldUpdate(
nextIssue.project = issue.project?.id === nextIssue.projectId ? issue.project : null;
}
if (hasOwn("parentId")) {
nextIssue.ancestors = undefined;
}
if (hasOwn("executionWorkspaceId")) {
nextIssue.currentExecutionWorkspace =
issue.currentExecutionWorkspace?.id === nextIssue.executionWorkspaceId

View file

@ -0,0 +1,63 @@
type MarkdownNode = {
type?: unknown;
value?: unknown;
children?: unknown;
};
type MarkdownTextNode = {
type: "text";
value: string;
};
type MarkdownBreakNode = {
type: "break";
};
type MarkdownParentNode = {
children: MarkdownTreeNode[];
};
type MarkdownTreeNode = MarkdownTextNode | MarkdownBreakNode | (MarkdownNode & { children?: MarkdownTreeNode[] });
function isParentNode(value: unknown): value is MarkdownParentNode {
return typeof value === "object" && value !== null && Array.isArray((value as MarkdownNode).children);
}
function buildSoftBreakReplacement(value: string): Array<MarkdownTextNode | MarkdownBreakNode> {
const parts = value.split("\n");
const replacement: Array<MarkdownTextNode | MarkdownBreakNode> = [];
for (let index = 0; index < parts.length; index += 1) {
const part = parts[index];
if (part.length > 0) {
replacement.push({ type: "text", value: part });
}
if (index < parts.length - 1) {
replacement.push({ type: "break" });
}
}
return replacement.length > 0 ? replacement : [{ type: "text", value: "" }];
}
function transformNode(node: MarkdownTreeNode) {
if (!isParentNode(node)) return;
for (let index = 0; index < node.children.length; index += 1) {
const child = node.children[index];
if (child?.type === "text" && typeof child.value === "string" && child.value.includes("\n")) {
const replacement = buildSoftBreakReplacement(child.value);
node.children.splice(index, 1, ...replacement);
index += replacement.length - 1;
continue;
}
transformNode(child);
}
}
export function remarkSoftBreaks() {
return (tree: MarkdownTreeNode) => {
transformNode(tree);
};
}