Skip to content

Commit de50db6

Browse files
committed
Implement clickable issue links in @githubpr chat output
1 parent 5a9a1c9 commit de50db6

2 files changed

Lines changed: 112 additions & 21 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { PullInfo } from './messages';
7+
8+
// Issue/PR reference patterns - copied from src/github/utils.ts
9+
export const ISSUE_EXPRESSION = /(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/;
10+
export const ISSUE_OR_URL_EXPRESSION = /(https?:\/\/github\.com\/(([^\s]+)\/([^\s]+))\/([^\s]+\/)?(issues|pull)\/([0-9]+)(#issuecomment\-([0-9]+))?)|(([A-Za-z0-9_.\-]+)\/([A-Za-z0-9_.\-]+))?(#|GH-)([1-9][0-9]*)($|\b)/;
11+
12+
export type ParsedIssue = {
13+
owner: string | undefined;
14+
name: string | undefined;
15+
issueNumber: number;
16+
commentNumber?: number;
17+
};
18+
19+
export function parseIssueExpressionOutput(output: RegExpMatchArray | null): ParsedIssue | undefined {
20+
if (!output) {
21+
return undefined;
22+
}
23+
const issue: ParsedIssue = { owner: undefined, name: undefined, issueNumber: 0 };
24+
if (output.length === 7) {
25+
issue.owner = output[2];
26+
issue.name = output[3];
27+
issue.issueNumber = parseInt(output[5]);
28+
return issue;
29+
} else if (output.length === 16) {
30+
issue.owner = output[3] || output[11];
31+
issue.name = output[4] || output[12];
32+
issue.issueNumber = parseInt(output[7] || output[14]);
33+
issue.commentNumber = output[9] !== undefined ? parseInt(output[9]) : undefined;
34+
return issue;
35+
} else {
36+
return undefined;
37+
}
38+
}
39+
40+
export function getIssueNumberLabelFromParsed(parsed: ParsedIssue): string {
41+
if (parsed.owner && parsed.name) {
42+
return `${parsed.owner}/${parsed.name}#${parsed.issueNumber}`;
43+
}
44+
return `#${parsed.issueNumber}`;
45+
}
46+
47+
/**
48+
* Converts issue/PR references in text to clickable links
49+
* @param text The text to process
50+
* @param pullInfo Repository context for creating links
51+
* @returns The text with issue references converted to markdown links
52+
*/
53+
export async function convertIssueReferencesToLinks(text: string, pullInfo: PullInfo | undefined): Promise<string> {
54+
if (!pullInfo) {
55+
return text;
56+
}
57+
58+
// Use a simple approach to find and replace issue references
59+
return text.replace(ISSUE_OR_URL_EXPRESSION, (match) => {
60+
const parsed = parseIssueExpressionOutput(match.match(ISSUE_OR_URL_EXPRESSION));
61+
if (!parsed) {
62+
return match;
63+
}
64+
65+
// If no owner/name specified, use the current repository context
66+
if (!parsed.owner || !parsed.name) {
67+
parsed.owner = pullInfo.owner;
68+
parsed.name = pullInfo.repo;
69+
}
70+
71+
const issueNumberLabel = getIssueNumberLabelFromParsed(parsed);
72+
73+
// Create GitHub URL for the issue/PR
74+
const githubUrl = `https://${pullInfo.host || 'github.com'}/${parsed.owner}/${parsed.name}/issues/${parsed.issueNumber}`;
75+
76+
return `[${issueNumberLabel}](${githubUrl})`;
77+
});
78+
}

webviews/sessionLogView/sessionView.tsx

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { parseDiff, SessionResponseLogChunk, toFileLabel } from '../../common/se
1313
import { vscode } from '../common/message';
1414
import { CodeView } from './codeView';
1515
import './index.css'; // Create this file for styling
16+
import { convertIssueReferencesToLinks } from './issueLinker';
1617
import { PullInfo } from './messages';
1718
import { type SessionInfo, type SessionSetupStepResponse } from './sessionsApi';
1819

@@ -30,7 +31,7 @@ export const SessionView: React.FC<SessionViewProps> = (props) => {
3031
{props.logs.length === 0 && props.setupSteps && props.setupSteps.length > 0 && (
3132
<SetupStageLog setupSteps={props.setupSteps} />
3233
)}
33-
<SessionLog logs={props.logs} />
34+
<SessionLog logs={props.logs} pullInfo={props.pullInfo} />
3435
{props.info.state === 'in_progress' && !(props.logs.length === 0 && props.setupSteps && props.setupSteps.length > 0) && (
3536
<div className="session-in-progress-indicator">
3637
<span className="icon"><i className="codicon codicon-loading"></i></span>
@@ -102,9 +103,10 @@ const SessionHeader: React.FC<SessionHeaderProps> = ({ info, pullInfo }) => {
102103
// Session Log component
103104
interface SessionLogProps {
104105
readonly logs: readonly SessionResponseLogChunk[];
106+
readonly pullInfo: PullInfo | undefined;
105107
}
106108

107-
const SessionLog: React.FC<SessionLogProps> = ({ logs }) => {
109+
const SessionLog: React.FC<SessionLogProps> = ({ logs, pullInfo }) => {
108110
const components = logs.flatMap(x => x.choices).map((choice, index) => {
109111
if (!choice.delta.content) {
110112
return;
@@ -129,6 +131,7 @@ const SessionLog: React.FC<SessionLogProps> = ({ logs }) => {
129131
<MarkdownContent
130132
key={`markdown-${index}`}
131133
content={choice.delta.content}
134+
pullInfo={pullInfo}
132135
/>
133136
);
134137
}
@@ -224,9 +227,10 @@ const SessionLog: React.FC<SessionLogProps> = ({ logs }) => {
224227
// Custom component for rendering markdown content
225228
interface MarkdownContentProps {
226229
content: string;
230+
pullInfo: PullInfo | undefined;
227231
}
228232

229-
const MarkdownContent: React.FC<MarkdownContentProps> = ({ content }) => {
233+
const MarkdownContent: React.FC<MarkdownContentProps> = ({ content, pullInfo }) => {
230234
const containerRef = React.useRef<HTMLDivElement>(null);
231235
const md = React.useMemo(() => {
232236
const mdInstance = new MarkdownIt();
@@ -245,27 +249,36 @@ const MarkdownContent: React.FC<MarkdownContentProps> = ({ content }) => {
245249
React.useEffect(() => {
246250
if (!containerRef.current) return;
247251

248-
// Render markdown
249-
containerRef.current.innerHTML = md.render(content);
252+
// Process issue references and convert to clickable links
253+
const processContent = async () => {
254+
const processedContent = await convertIssueReferencesToLinks(content, pullInfo);
255+
256+
// Render markdown
257+
if (containerRef.current) {
258+
containerRef.current.innerHTML = md.render(processedContent);
250259

251-
// Find all code blocks and render them using CodeView
252-
const codeBlocks = containerRef.current.querySelectorAll('.markdown-code-block');
253-
codeBlocks.forEach((block) => {
254-
const code = decodeURIComponent(block.getAttribute('data-code') || '');
255-
const lang = block.getAttribute('data-lang') || 'plaintext';
260+
// Find all code blocks and render them using CodeView
261+
const codeBlocks = containerRef.current.querySelectorAll('.markdown-code-block');
262+
codeBlocks.forEach((block) => {
263+
const code = decodeURIComponent(block.getAttribute('data-code') || '');
264+
const lang = block.getAttribute('data-lang') || 'plaintext';
256265

257-
const codeViewElement = document.createElement('div');
258-
block.replaceWith(codeViewElement);
266+
const codeViewElement = document.createElement('div');
267+
block.replaceWith(codeViewElement);
259268

260-
ReactDOM.render(
261-
<CodeView
262-
label="Code Block"
263-
content={{ value: code, lang }}
264-
/>,
265-
codeViewElement
266-
);
267-
});
268-
}, [content]);
269+
ReactDOM.render(
270+
<CodeView
271+
label="Code Block"
272+
content={{ value: code, lang }}
273+
/>,
274+
codeViewElement
275+
);
276+
});
277+
}
278+
};
279+
280+
processContent();
281+
}, [content, pullInfo, md]);
269282

270283
return <div className="markdown-content" ref={containerRef} />;
271284
};

0 commit comments

Comments
 (0)