feat: polish issue thread markdown and references

This commit is contained in:
Dotta 2026-04-10 22:26:21 -05:00
parent 548721248e
commit 958c11699e
16 changed files with 659 additions and 44 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);
};
}

View file

@ -2,11 +2,13 @@ import * as React from "react";
import * as RouterDom from "react-router-dom";
import type { NavigateOptions, To } from "react-router-dom";
import { useCompany } from "@/context/CompanyContext";
import { IssueLinkQuicklook } from "@/components/IssueLinkQuicklook";
import {
applyCompanyPrefix,
extractCompanyPrefixFromPath,
normalizeCompanyPrefix,
} from "@/lib/company-routes";
import { parseIssuePathIdFromPath } from "@/lib/issue-reference";
function resolveTo(to: To, companyPrefix: string | null): To {
if (typeof to === "string") {
@ -40,10 +42,23 @@ function useActiveCompanyPrefix(): string | null {
export * from "react-router-dom";
export const Link = React.forwardRef<HTMLAnchorElement, React.ComponentProps<typeof RouterDom.Link>>(
function CompanyLink({ to, ...props }, ref) {
type CompanyLinkProps = React.ComponentProps<typeof RouterDom.Link> & {
disableIssueQuicklook?: boolean;
};
export const Link = React.forwardRef<HTMLAnchorElement, CompanyLinkProps>(
function CompanyLink({ to, disableIssueQuicklook = false, ...props }, ref) {
const companyPrefix = useActiveCompanyPrefix();
return <RouterDom.Link ref={ref} to={resolveTo(to, companyPrefix)} {...props} />;
const resolvedTo = resolveTo(to, companyPrefix);
const issuePathId = disableIssueQuicklook
? null
: parseIssuePathIdFromPath(typeof resolvedTo === "string" ? resolvedTo : resolvedTo.pathname);
if (issuePathId) {
return <IssueLinkQuicklook ref={ref} to={resolvedTo} issuePathId={issuePathId} {...props} />;
}
return <RouterDom.Link ref={ref} to={resolvedTo} {...props} />;
},
);