Skip to content

Commit dfd9d66

Browse files
committed
feat(pull-requests): link GitHub references in markdown
1 parent e4f7b14 commit dfd9d66

5 files changed

Lines changed: 179 additions & 7 deletions

File tree

‎apps/web/src/components/ChatMarkdown.tsx‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ interface ChatMarkdownProps {
179179
onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
180180
imageBaseDir?: string | undefined;
181181
onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined;
182+
extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
182183
}
183184

184185
export function canUseMarkdownFileShellActions(
@@ -211,6 +212,7 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: {
211212
}
212213

213214
const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
215+
const EMPTY_REMARK_PLUGINS: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [];
214216

215217
const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
216218
document: FileTextIcon,
@@ -360,6 +362,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
360362
code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"],
361363
blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"],
362364
div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES],
365+
a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"],
363366
img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"],
364367
},
365368
protocols: {
@@ -1812,6 +1815,7 @@ function ChatMarkdown({
18121815
onUseArtifactTemplate,
18131816
imageBaseDir,
18141817
onImageExpand,
1818+
extraRemarkPlugins = EMPTY_REMARK_PLUGINS,
18151819
}: ChatMarkdownProps) {
18161820
const { resolvedTheme } = useTheme();
18171821
const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
@@ -2208,13 +2212,25 @@ function ChatMarkdown({
22082212
: null;
22092213
if (!fileLinkMeta) {
22102214
const faviconHost = resolveExternalWebLinkHost(href);
2215+
const pullRequestAutolink = String(
2216+
(props as Record<string, unknown>)["data-pull-request-autolink"] ?? "",
2217+
);
2218+
const pullRequestCopy =
2219+
pullRequestAutolink === "commit"
2220+
? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1]
2221+
: pullRequestAutolink === "reference"
2222+
? plainHastText(node)
2223+
: undefined;
2224+
const isPullRequestAutolink = pullRequestCopy !== undefined;
22112225
const isSameDocumentLink = href?.startsWith("#") ?? false;
22122226
const onClick = props.onClick;
22132227
const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime();
22142228
const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>;
22152229
const link = (
22162230
<a
22172231
{...props}
2232+
className={cn(props.className, pullRequestAutolink === "commit" && "font-mono")}
2233+
data-markdown-copy={pullRequestCopy}
22182234
href={href}
22192235
target={isSameDocumentLink ? undefined : "_blank"}
22202236
rel={isSameDocumentLink ? undefined : "noopener noreferrer"}
@@ -2286,7 +2302,7 @@ function ChatMarkdown({
22862302
});
22872303
}}
22882304
>
2289-
{faviconHost && hastHasText(node) ? (
2305+
{faviconHost && hastHasText(node) && !isPullRequestAutolink ? (
22902306
<MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}>
22912307
{linkChildren}
22922308
</MarkdownExternalLinkContent>
@@ -2443,6 +2459,14 @@ function ChatMarkdown({
24432459
]);
24442460
/* eslint-enable react/no-unstable-nested-components */
24452461

2462+
const remarkPlugins = useMemo(
2463+
() => [
2464+
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
2465+
...extraRemarkPlugins,
2466+
],
2467+
[extraRemarkPlugins, lineBreaks],
2468+
);
2469+
24462470
// react-markdown converts unparsed HTML nodes to text when skipHtml is false.
24472471
// Keep that behavior explicit because literal mode depends on escaping the
24482472
// complete source token instead of dropping it from the rendered message.
@@ -2455,9 +2479,7 @@ function ChatMarkdown({
24552479
onCopy={handleCopy}
24562480
>
24572481
<ReactMarkdown
2458-
remarkPlugins={
2459-
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
2460-
}
2482+
remarkPlugins={remarkPlugins}
24612483
rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined}
24622484
skipHtml={false}
24632485
components={markdownComponents}

‎apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import { DiffPanelLoadingState } from "../DiffPanelShell";
9696
import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState";
9797
import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab";
9898
import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu";
99+
import { PullRequestMarkdownContext } from "./PullRequestMarkdown";
99100
import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
100101
import { PullRequestTimelineTab } from "./PullRequestTimelineTab";
101102
import {
@@ -1918,7 +1919,7 @@ export function PullRequestDetailPanel({
19181919
{...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})}
19191920
/>
19201921
) : detail ? (
1921-
<>
1922+
<PullRequestMarkdownContext value={detail.provider === "github" ? repositoryUrl : null}>
19221923
{mountedTabs.has("summary") ? (
19231924
<div className={cn("absolute inset-0", tab !== "summary" && "invisible")}>
19241925
<PullRequestSummaryTab
@@ -1975,7 +1976,7 @@ export function PullRequestDetailPanel({
19751976
</Suspense>
19761977
</div>
19771978
) : null}
1978-
</>
1979+
</PullRequestMarkdownContext>
19791980
) : null}
19801981
</div>
19811982

‎apps/web/src/components/pullRequest/PullRequestMarkdown.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { ExternalLinkIcon, PaperclipIcon, PlayIcon } from "lucide-react";
22
import type { EnvironmentId } from "@t3tools/contracts";
3+
import { createContext, useContext, useMemo } from "react";
4+
import type { Options as ReactMarkdownOptions } from "react-markdown";
35

46
import { cn } from "~/lib/utils";
57

68
import ChatMarkdown from "../ChatMarkdown";
7-
import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
9+
import { remarkPullRequestAutolinks, splitPullRequestBody } from "./pullRequestMarkdown.logic";
10+
11+
export const PullRequestMarkdownContext = createContext<string | null>(null);
812

913
/**
1014
* A pull request body, rendered with the app's markdown renderer plus a card for each upload
@@ -27,6 +31,11 @@ export function PullRequestMarkdown({
2731
className?: string;
2832
}) {
2933
const segments = splitPullRequestBody(text);
34+
const repositoryUrl = useContext(PullRequestMarkdownContext);
35+
const extraRemarkPlugins = useMemo<NonNullable<ReactMarkdownOptions["remarkPlugins"]>>(
36+
() => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []),
37+
[repositoryUrl],
38+
);
3039
return (
3140
<div className={cn("space-y-3", className)}>
3241
{segments.map((segment) => {
@@ -37,6 +46,7 @@ export function PullRequestMarkdown({
3746
text={segment.text}
3847
cwd={cwd}
3948
environmentId={environmentId}
49+
extraRemarkPlugins={extraRemarkPlugins}
4050
/>
4151
);
4252
}

‎apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
findAndReplaceText,
3+
type MarkdownNode,
4+
type TextMatch,
5+
} from "~/vendor/mdast-find-and-replace";
6+
17
/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
28
export type PullRequestBodySegment =
39
| { readonly id: string; readonly kind: "markdown"; readonly text: string }
@@ -141,3 +147,46 @@ export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBod
141147
flushMarkdown();
142148
return segments;
143149
}
150+
151+
const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu;
152+
const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u;
153+
const AUTOLINK_COMMIT_PREFIX_PATTERN = /[\s([{]/u;
154+
const AUTOLINK_IGNORED_TYPES = new Set(["link", "linkReference"]);
155+
156+
/** GitHub-style same-repository references for pull request prose, never code or authored links. */
157+
export function remarkPullRequestAutolinks(options: { readonly repositoryUrl: string }) {
158+
const repositoryUrl = options.repositoryUrl.replace(/\/+$/u, "");
159+
return (tree: MarkdownNode) => {
160+
findAndReplaceText(
161+
tree,
162+
AUTOLINK_CANDIDATE_PATTERN,
163+
(matched: string, match: TextMatch) => {
164+
const reference = matched.startsWith("#");
165+
const before = match.input[match.index - 1];
166+
const after = match.input[match.index + matched.length];
167+
if (
168+
(before !== undefined &&
169+
(reference
170+
? AUTOLINK_WORD_CHARACTER_PATTERN.test(before)
171+
: !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) ||
172+
(after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after))
173+
) {
174+
return false;
175+
}
176+
return {
177+
type: "link",
178+
url: reference
179+
? `${repositoryUrl}/issues/${matched.slice(1)}`
180+
: `${repositoryUrl}/commit/${matched}`,
181+
data: {
182+
hProperties: {
183+
dataPullRequestAutolink: reference ? "reference" : "commit",
184+
},
185+
},
186+
children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }],
187+
};
188+
},
189+
AUTOLINK_IGNORED_TYPES,
190+
);
191+
};
192+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Adapted from mdast-util-find-and-replace:
3+
* https://github.com/syntax-tree/mdast-util-find-and-replace/blob/main/lib/index.js
4+
*
5+
* The MIT License
6+
*
7+
* Copyright (c) Titus Wormer <[email protected]>
8+
*
9+
* Permission is hereby granted, free of charge, to any person obtaining a copy
10+
* of this software and associated documentation files (the "Software"), to deal
11+
* in the Software without restriction, including without limitation the rights
12+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13+
* copies of the Software, and to permit persons to whom the Software is
14+
* furnished to do so, subject to the following conditions:
15+
*
16+
* The above copyright notice and this permission notice shall be included in
17+
* all copies or substantial portions of the Software.
18+
*
19+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25+
* SOFTWARE.
26+
*/
27+
28+
export type MarkdownNode = {
29+
type: string;
30+
value?: string;
31+
children?: MarkdownNode[];
32+
url?: string;
33+
data?: unknown;
34+
};
35+
36+
export type TextMatch = {
37+
index: number;
38+
input: string;
39+
};
40+
41+
/** The dependency-free subset of mdast-util-find-and-replace used by PR autolinks. */
42+
export function findAndReplaceText(
43+
tree: MarkdownNode,
44+
find: RegExp,
45+
replace: (matched: string, match: TextMatch) => MarkdownNode | false,
46+
ignoredTypes: ReadonlySet<string>,
47+
): void {
48+
visit(tree);
49+
50+
function visit(node: MarkdownNode): void {
51+
if (node.children === undefined) return;
52+
for (let childIndex = 0; childIndex < node.children.length; childIndex += 1) {
53+
const child = node.children[childIndex]!;
54+
if (ignoredTypes.has(child.type)) continue;
55+
if (child.type !== "text" || child.value === undefined) {
56+
visit(child);
57+
continue;
58+
}
59+
60+
const replacements: MarkdownNode[] = [];
61+
let start = 0;
62+
let changed = false;
63+
find.lastIndex = 0;
64+
let match = find.exec(child.value);
65+
while (match !== null) {
66+
const position = match.index;
67+
const replacement = replace(match[0], { index: position, input: match.input });
68+
if (replacement === false) {
69+
find.lastIndex = position + 1;
70+
} else {
71+
if (start < position) {
72+
replacements.push({ type: "text", value: child.value.slice(start, position) });
73+
}
74+
replacements.push(replacement);
75+
start = position + match[0].length;
76+
changed = true;
77+
}
78+
if (!find.global) break;
79+
match = find.exec(child.value);
80+
}
81+
82+
if (!changed) continue;
83+
if (start < child.value.length) {
84+
replacements.push({ type: "text", value: child.value.slice(start) });
85+
}
86+
node.children.splice(childIndex, 1, ...replacements);
87+
childIndex += replacements.length - 1;
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)