Skip to content

Commit e594b60

Browse files
Copilotalexr00
andcommitted
Add function to convert issue/PR references to clickable links in chat output
Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent a7eb70d commit e594b60

File tree

4 files changed

+120
-7
lines changed

4 files changed

+120
-7
lines changed

src/common/uri.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,3 +793,25 @@ export function resolvePath(from: vscode.Uri, to: string) {
793793
return pathUtils.posix.resolve(from.path, to);
794794
}
795795
}
796+
797+
/**
798+
* Converts issue and PR number references in text to clickable markdown links.
799+
* Matches patterns like "#123", "issue 123", "issue #123", "PR 123", "PR #123"
800+
* @param text The text to process
801+
* @param owner The repository owner
802+
* @param repo The repository name
803+
* @returns The text with issue/PR references converted to markdown links
804+
*/
805+
export function convertIssuePRReferencesToLinks(text: string, owner: string, repo: string): string {
806+
// Pattern matches:
807+
// - #123 (standalone hash with number)
808+
// - issue 123 or issue #123 (case-insensitive)
809+
// - PR 123 or PR #123 (case-insensitive)
810+
// Uses word boundaries to avoid matching in the middle of words
811+
const pattern = /\b(?:issue\s+#?|PR\s+#?|#)(\d+)\b/gi;
812+
813+
return text.replace(pattern, (match, number) => {
814+
const url = `https://github.com/${owner}/${repo}/issues/${number}`;
815+
return `[${match}](${url})`;
816+
});
817+
}

src/github/copilotRemoteAgent.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import Logger from '../common/logger';
2121
import { GitHubRemote } from '../common/remote';
2222
import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH } from '../common/settingKeys';
2323
import { ITelemetry } from '../common/telemetry';
24-
import { toOpenPullRequestWebviewUri } from '../common/uri';
24+
import { convertIssuePRReferencesToLinks, toOpenPullRequestWebviewUri } from '../common/uri';
2525
import { ChatSessionContentBuilder } from './copilotRemoteAgent/chatSessionContentBuilder';
2626
import { GitOperationsManager } from './copilotRemoteAgent/gitOperationsManager';
2727
import { extractTitle, formatBodyPlaceholder, truncatePrompt } from './copilotRemoteAgentUtils';
@@ -1246,7 +1246,12 @@ export class CopilotRemoteAgentManager extends Disposable {
12461246
} else {
12471247
if (delta.content) {
12481248
if (!delta.content.startsWith('<pr_title>')) {
1249-
stream.markdown(delta.content);
1249+
const convertedContent = convertIssuePRReferencesToLinks(
1250+
delta.content,
1251+
pullRequest.remote.owner,
1252+
pullRequest.remote.repositoryName
1253+
);
1254+
stream.markdown(convertedContent);
12501255
hasStreamedContent = true;
12511256
}
12521257
}

src/github/copilotRemoteAgent/chatSessionContentBuilder.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { parseSessionLogs, parseToolCallDetails, StrReplaceEditorToolData } from
1010
import { COPILOT_SWE_AGENT } from '../../common/copilot';
1111
import Logger from '../../common/logger';
1212
import { CommentEvent, CopilotFinishedEvent, CopilotStartedEvent, EventType, ReviewEvent, TimelineEvent } from '../../common/timelineEvent';
13-
import { toOpenPullRequestWebviewUri } from '../../common/uri';
13+
import { convertIssuePRReferencesToLinks, toOpenPullRequestWebviewUri } from '../../common/uri';
1414
import { InMemFileChangeModel, RemoteFileChangeModel } from '../../view/fileChangeModel';
1515
import { AssistantDelta, Choice, ToolCall } from '../common';
1616
import { CopilotApi, SessionInfo } from '../copilotApi';
@@ -288,7 +288,12 @@ export class ChatSessionContentBuilder {
288288
}
289289

290290
if (currentResponseContent.trim()) {
291-
responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim()));
291+
const convertedContent = convertIssuePRReferencesToLinks(
292+
currentResponseContent.trim(),
293+
pullRequest.remote.owner,
294+
pullRequest.remote.repositoryName
295+
);
296+
responseParts.push(new vscode.ChatResponseMarkdownPart(convertedContent));
292297
}
293298

294299
if (session.state === 'completed' || session.state === 'failed' /** session can fail with proposed changes */) {
@@ -336,7 +341,12 @@ export class ChatSessionContentBuilder {
336341
if (delta.content && delta.content.trim()) {
337342
// Add any accumulated content as markdown first
338343
if (currentResponseContent.trim()) {
339-
responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim()));
344+
const convertedContent = convertIssuePRReferencesToLinks(
345+
currentResponseContent.trim(),
346+
pullRequest.remote.owner,
347+
pullRequest.remote.repositoryName
348+
);
349+
responseParts.push(new vscode.ChatResponseMarkdownPart(convertedContent));
340350
currentResponseContent = '';
341351
}
342352

@@ -357,7 +367,12 @@ export class ChatSessionContentBuilder {
357367
if (delta.tool_calls) {
358368
// Add any accumulated content as markdown first
359369
if (currentResponseContent.trim()) {
360-
responseParts.push(new vscode.ChatResponseMarkdownPart(currentResponseContent.trim()));
370+
const convertedContent = convertIssuePRReferencesToLinks(
371+
currentResponseContent.trim(),
372+
pullRequest.remote.owner,
373+
pullRequest.remote.repositoryName
374+
);
375+
responseParts.push(new vscode.ChatResponseMarkdownPart(convertedContent));
361376
currentResponseContent = '';
362377
}
363378

src/test/common/uri.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { default as assert } from 'assert';
77
import * as vscode from 'vscode';
8-
import { fromOpenOrCheckoutPullRequestWebviewUri } from '../../common/uri';
8+
import { convertIssuePRReferencesToLinks, fromOpenOrCheckoutPullRequestWebviewUri } from '../../common/uri';
99

1010
describe('uri', () => {
1111
describe('fromOpenOrCheckoutPullRequestWebviewUri', () => {
@@ -110,4 +110,75 @@ describe('uri', () => {
110110
assert.strictEqual(result2, undefined);
111111
});
112112
});
113+
114+
describe('convertIssuePRReferencesToLinks', () => {
115+
const owner = 'microsoft';
116+
const repo = 'vscode-pull-request-github';
117+
118+
it('should convert standalone issue numbers with # prefix', () => {
119+
const text = 'This PR addresses issue #7280.';
120+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
121+
assert.strictEqual(result, 'This PR addresses issue [#7280](https://github.com/microsoft/vscode-pull-request-github/issues/7280).');
122+
});
123+
124+
it('should convert issue references without # prefix', () => {
125+
const text = 'This fixes issue 123.';
126+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
127+
assert.strictEqual(result, 'This fixes [issue 123](https://github.com/microsoft/vscode-pull-request-github/issues/123).');
128+
});
129+
130+
it('should convert PR references with # prefix', () => {
131+
const text = 'See PR #456 for details.';
132+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
133+
assert.strictEqual(result, 'See [PR #456](https://github.com/microsoft/vscode-pull-request-github/issues/456) for details.');
134+
});
135+
136+
it('should convert PR references without # prefix', () => {
137+
const text = 'Related to PR 789.';
138+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
139+
assert.strictEqual(result, 'Related to [PR 789](https://github.com/microsoft/vscode-pull-request-github/issues/789).');
140+
});
141+
142+
it('should convert multiple issue/PR references in the same text', () => {
143+
const text = 'This fixes issue #123 and PR #456.';
144+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
145+
assert.strictEqual(result, 'This fixes [issue #123](https://github.com/microsoft/vscode-pull-request-github/issues/123) and [PR #456](https://github.com/microsoft/vscode-pull-request-github/issues/456).');
146+
});
147+
148+
it('should handle case-insensitive issue/PR keywords', () => {
149+
const text = 'See Issue #100 and pr #200.';
150+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
151+
assert.strictEqual(result, 'See [Issue #100](https://github.com/microsoft/vscode-pull-request-github/issues/100) and [pr #200](https://github.com/microsoft/vscode-pull-request-github/issues/200).');
152+
});
153+
154+
it('should not convert issue/PR references in the middle of words', () => {
155+
const text = 'This is not#123 an issue.';
156+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
157+
assert.strictEqual(result, 'This is not#123 an issue.');
158+
});
159+
160+
it('should not convert # followed by non-numeric characters', () => {
161+
const text = 'This is #notanissue.';
162+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
163+
assert.strictEqual(result, 'This is #notanissue.');
164+
});
165+
166+
it('should convert standalone # followed by number', () => {
167+
const text = 'See #42 for more info.';
168+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
169+
assert.strictEqual(result, 'See [#42](https://github.com/microsoft/vscode-pull-request-github/issues/42) for more info.');
170+
});
171+
172+
it('should handle text with no issue/PR references', () => {
173+
const text = 'This is just regular text.';
174+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
175+
assert.strictEqual(result, 'This is just regular text.');
176+
});
177+
178+
it('should handle empty text', () => {
179+
const text = '';
180+
const result = convertIssuePRReferencesToLinks(text, owner, repo);
181+
assert.strictEqual(result, '');
182+
});
183+
});
113184
});

0 commit comments

Comments
 (0)