Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/review/node/githubReviewAgent.ts
13399 views
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 { RequestType } from '@vscode/copilot-api';
7
import * as l10n from '@vscode/l10n';
8
import * as readline from 'readline';
9
import { Readable } from 'stream';
10
import type { Selection, TextDocument, TextEditor } from 'vscode';
11
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
12
import { ConfigKey } from '../../../platform/configuration/common/configurationService';
13
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
14
import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot';
15
import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient';
16
import { IDomainService } from '../../../platform/endpoint/common/domainService';
17
import { IEnvService } from '../../../platform/env/common/envService';
18
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
19
import { API, Repository } from '../../../platform/git/vscode/git';
20
import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';
21
import { ILogService } from '../../../platform/log/common/logService';
22
import { IFetcherService, Response } from '../../../platform/networking/common/fetcherService';
23
import { Progress } from '../../../platform/notification/common/notificationService';
24
import { ReviewComment, ReviewRequest } from '../../../platform/review/common/reviewService';
25
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
26
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
27
import * as path from '../../../util/vs/base/common/path';
28
import { generateUuid } from '../../../util/vs/base/common/uuid';
29
import { MarkdownString, Range, Uri } from '../../../vscodeTypes';
30
import { FeedbackResult } from '../../prompt/node/feedbackGenerator';
31
32
33
const testing = false;
34
35
/**
36
* Represents a file change to be reviewed.
37
*/
38
interface FileChange {
39
repository: Repository | undefined;
40
uri?: Uri;
41
relativePath: string;
42
before: string;
43
after: string;
44
selection?: Selection;
45
document: TextDocument;
46
}
47
48
/**
49
* Normalizes a file path to use forward slashes on all platforms.
50
*/
51
export function normalizePath(relativePath: string): string {
52
return process.platform === 'win32' ? relativePath.replace(/\\/g, '/') : relativePath;
53
}
54
55
/**
56
* Collects file change data for a selection-based review.
57
*/
58
function collectSelectionChanges(
59
git: API,
60
editor: TextEditor,
61
workspaceService: IWorkspaceService
62
): FileChange[] {
63
return [{
64
repository: git.getRepository(editor.document.uri) || undefined,
65
uri: editor.document.uri,
66
relativePath: workspaceService.asRelativePath(editor.document.uri),
67
before: '',
68
after: editor.document.getText(),
69
selection: editor.selection,
70
document: editor.document,
71
}];
72
}
73
74
/**
75
* Collects file change data for diff-based reviews (index, workingTree, or all).
76
*/
77
async function collectDiffChanges(
78
git: API,
79
group: 'index' | 'workingTree' | 'all',
80
workspaceService: IWorkspaceService
81
): Promise<(FileChange | undefined)[]> {
82
const repositoryChanges = await Promise.all(git.repositories.map(async repository => {
83
const uris = new Set<Uri>();
84
if (group === 'all' || group === 'index') {
85
repository.state.indexChanges.forEach(c => uris.add(c.uri));
86
}
87
if (group === 'all' || group === 'workingTree') {
88
repository.state.workingTreeChanges.forEach(c => uris.add(c.uri));
89
repository.state.untrackedChanges.forEach(c => uris.add(c.uri));
90
}
91
const changes = await Promise.all(Array.from(uris).map(async uri => {
92
const document = await workspaceService.openTextDocument(uri).then(undefined, () => undefined);
93
if (!document) {
94
return undefined; // Deleted files can be skipped.
95
}
96
const before = await (group === 'index' || group === 'all' ? repository.show('HEAD', uri.fsPath).catch(() => '') : repository.show('', uri.fsPath).catch(() => ''));
97
const after = group === 'index' ? await (repository.show('', uri.fsPath).catch(() => '')) : document.getText();
98
const relativePath = path.relative(repository.rootUri.fsPath, uri.fsPath);
99
return {
100
repository,
101
uri,
102
relativePath: normalizePath(relativePath),
103
before,
104
after,
105
document,
106
};
107
}));
108
return changes;
109
}));
110
return repositoryChanges.flat();
111
}
112
113
/**
114
* Collects file change data for patch-based reviews (e.g., PR reviews).
115
*/
116
async function collectPatchChanges(
117
git: API,
118
group: { repositoryRoot: string; commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] },
119
workspaceService: IWorkspaceService
120
): Promise<(FileChange | undefined)[]> {
121
return Promise.all(group.patches.map(async patch => {
122
const uri = Uri.parse(patch.fileUri);
123
const document = await workspaceService.openTextDocument(uri).then(undefined, () => undefined);
124
if (!document) {
125
return undefined; // Deleted files can be skipped.
126
}
127
const after = document.getText();
128
const before = reversePatch(after, patch.patch);
129
const relativePath = path.relative(group.repositoryRoot, uri.fsPath);
130
return {
131
repository: git.getRepository(Uri.parse(group.repositoryRoot))!,
132
relativePath: normalizePath(relativePath),
133
before,
134
after,
135
document,
136
};
137
}));
138
}
139
140
/**
141
* Collects file change data for single-file reviews.
142
*/
143
async function collectSingleFileChanges(
144
git: API,
145
group: { group: 'index' | 'workingTree'; file: Uri },
146
workspaceService: IWorkspaceService
147
): Promise<FileChange[]> {
148
const { group: g, file } = group;
149
const repository = git.getRepository(file);
150
const document = await workspaceService.openTextDocument(file).then(undefined, () => undefined);
151
if (!repository || !document) {
152
return [];
153
}
154
const before = await (g === 'index' ? repository.show('HEAD', file.fsPath).catch(() => '') : repository.show('', file.fsPath).catch(() => ''));
155
const after = g === 'index' ? await (repository.show('', file.fsPath).catch(() => '')) : document.getText();
156
const relativePath = path.relative(repository.rootUri.fsPath, file.fsPath);
157
return [{
158
repository,
159
relativePath: normalizePath(relativePath),
160
before,
161
after,
162
document,
163
}];
164
}
165
166
/**
167
* Collects all file changes based on the review group type.
168
*/
169
async function collectChanges(
170
git: API,
171
group: 'selection' | 'index' | 'workingTree' | 'all' | { group: 'index' | 'workingTree'; file: Uri } | { repositoryRoot: string; commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] },
172
editor: TextEditor | undefined,
173
workspaceService: IWorkspaceService
174
): Promise<FileChange[]> {
175
if (group === 'selection') {
176
return collectSelectionChanges(git, editor!, workspaceService);
177
}
178
if (typeof group === 'string') {
179
const changes = await collectDiffChanges(git, group, workspaceService);
180
return changes.filter((change): change is FileChange => !!change);
181
}
182
if ('repositoryRoot' in group) {
183
const changes = await collectPatchChanges(git, group, workspaceService);
184
return changes.filter((change): change is FileChange => !!change);
185
}
186
return collectSingleFileChanges(git, group, workspaceService);
187
}
188
189
export async function githubReview(
190
logService: ILogService,
191
gitExtensionService: IGitExtensionService,
192
authService: IAuthenticationService,
193
capiClientService: ICAPIClientService,
194
domainService: IDomainService,
195
fetcherService: IFetcherService,
196
envService: IEnvService,
197
ignoreService: IIgnoreService,
198
workspaceService: IWorkspaceService,
199
customInstructionsService: ICustomInstructionsService,
200
group: 'selection' | 'index' | 'workingTree' | 'all' | { group: 'index' | 'workingTree'; file: Uri } | { repositoryRoot: string; commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] },
201
editor: TextEditor | undefined,
202
progress: Progress<ReviewComment[]>,
203
cancellationToken: CancellationToken
204
): Promise<FeedbackResult> {
205
const git = gitExtensionService.getExtensionApi();
206
if (!git) {
207
return { type: 'success', comments: [] };
208
}
209
const changes = await collectChanges(git, group, editor, workspaceService);
210
211
if (!changes.length) {
212
return { type: 'success', comments: [] };
213
}
214
215
const ignored = await Promise.all(changes.map(i => ignoreService.isCopilotIgnored(i.document.uri)));
216
const filteredChanges = changes.filter((_, i) => !ignored[i]);
217
if (filteredChanges.length === 0) {
218
logService.info('All input documents are ignored. Skipping feedback generation.');
219
return {
220
type: 'error',
221
severity: 'info',
222
reason: l10n.t('All input documents are ignored by configuration. Check your .copilotignore file.')
223
};
224
}
225
logService.debug(`[github review agent] files: ${filteredChanges.map(change => change.relativePath).join(', ')}`);
226
227
const { requestId, rl } = !testing ? await fetchComments(
228
logService,
229
authService,
230
capiClientService,
231
fetcherService,
232
envService,
233
customInstructionsService,
234
workspaceService,
235
group === 'selection' ? 'selection' : 'diff',
236
filteredChanges[0].repository,
237
filteredChanges.map(change => ({ path: change.relativePath, content: change.before, languageId: change.document.languageId })),
238
filteredChanges.map(change => ({ path: change.relativePath, content: change.after, languageId: change.document.languageId, selection: 'selection' in change ? change.selection : undefined })),
239
cancellationToken,
240
) : {
241
requestId: 'test-request-id',
242
rl: [
243
'data: ...',
244
'data: [DONE]',
245
]
246
};
247
if (!rl || cancellationToken.isCancellationRequested) {
248
return { type: 'cancelled' };
249
}
250
251
logService.info(`[github review agent] request id: ${requestId}`);
252
253
const request: ReviewRequest = {
254
source: 'githubReviewAgent',
255
promptCount: -1,
256
messageId: requestId || generateUuid(),
257
inputType: 'change',
258
inputRanges: [],
259
};
260
const references: ResponseReference[] = [];
261
const comments: ReviewComment[] = [];
262
for await (const line of rl) {
263
if (cancellationToken.isCancellationRequested) {
264
return { type: 'cancelled' };
265
}
266
logService.debug(`[github review agent] response line: ${line}`);
267
const refs = parseLine(line);
268
references.push(...refs);
269
for (const ghComment of refs.filter(ref => ref.type === 'github.generated-pull-request-comment')) {
270
const change = filteredChanges.find(change => change.relativePath === ghComment.data.path);
271
if (!change) {
272
continue;
273
}
274
const comment = createReviewComment(ghComment, request, change.document, comments.length);
275
comments.push(comment);
276
progress.report([comment]);
277
}
278
}
279
const excludedComments = references.filter((ref): ref is ExcludedComment => ref.type === 'github.excluded-pull-request-comment')
280
.map(ghComment => {
281
const change = filteredChanges.find(change => change.relativePath === ghComment.data.path);
282
return { ghComment, change };
283
}).filter((item): item is { ghComment: ExcludedComment; change: NonNullable<typeof item.change> } => !!item.change)
284
.map(({ ghComment, change }, i) => createReviewComment(ghComment, request, change.document, comments.length + i));
285
const unsupportedLanguages = !comments.length ? [...new Set(references.filter((ref): ref is ExcludedFile => ref.type === 'github.excluded-file' && ref.data.reason === 'file_type_not_supported')
286
.map(ref => ref.data.language))] : [];
287
return { type: 'success', comments, excludedComments, reason: unsupportedLanguages.length ? l10n.t('Some of the submitted languages are currently not supported: {0}', unsupportedLanguages.join(', ')) : undefined };
288
}
289
290
/**
291
* Review files specified as URI pairs (current + base content).
292
* This is the entry point for the `github.copilot.chat.codeReview.run` command,
293
* bypassing git-based change collection.
294
*/
295
export async function githubReviewFileUris(
296
logService: ILogService,
297
authService: IAuthenticationService,
298
capiClientService: ICAPIClientService,
299
fetcherService: IFetcherService,
300
envService: IEnvService,
301
ignoreService: IIgnoreService,
302
workspaceService: IWorkspaceService,
303
customInstructionsService: ICustomInstructionsService,
304
fileInputs: readonly { readonly currentUri: Uri; readonly baseContent: string }[],
305
cancellationToken: CancellationToken,
306
): Promise<FeedbackResult> {
307
const changes: { readonly relativePath: string; readonly before: string; readonly after: string; readonly document: TextDocument; readonly uri: Uri }[] = [];
308
for (const input of fileInputs) {
309
const document = await workspaceService.openTextDocument(input.currentUri);
310
changes.push({
311
uri: input.currentUri,
312
relativePath: normalizePath(workspaceService.asRelativePath(input.currentUri)),
313
before: input.baseContent,
314
after: document.getText(),
315
document,
316
});
317
}
318
319
if (!changes.length) {
320
return { type: 'success', comments: [] };
321
}
322
323
const ignored = await Promise.all(changes.map(c => ignoreService.isCopilotIgnored(c.uri)));
324
const filteredChanges = changes.filter((_, i) => !ignored[i]);
325
if (filteredChanges.length === 0) {
326
logService.info('All input documents are ignored. Skipping feedback generation.');
327
return {
328
type: 'error',
329
severity: 'info',
330
reason: l10n.t('All input documents are ignored by configuration. Check your .copilotignore file.')
331
};
332
}
333
logService.debug(`[github review agent] files: ${filteredChanges.map(c => c.relativePath).join(', ')}`);
334
335
const { requestId, rl } = await fetchComments(
336
logService, authService, capiClientService, fetcherService, envService,
337
customInstructionsService, workspaceService,
338
'diff',
339
undefined,
340
filteredChanges.map(c => ({ path: c.relativePath, content: c.before, languageId: c.document.languageId })),
341
filteredChanges.map(c => ({ path: c.relativePath, content: c.after, languageId: c.document.languageId })),
342
cancellationToken,
343
);
344
if (!rl || cancellationToken.isCancellationRequested) {
345
return { type: 'cancelled' };
346
}
347
348
logService.info(`[github review agent] request id: ${requestId}`);
349
350
const request: ReviewRequest = {
351
source: 'githubReviewAgent',
352
promptCount: -1,
353
messageId: requestId || generateUuid(),
354
inputType: 'change',
355
inputRanges: [],
356
};
357
const references: ResponseReference[] = [];
358
const comments: ReviewComment[] = [];
359
for await (const line of rl) {
360
if (cancellationToken.isCancellationRequested) {
361
return { type: 'cancelled' };
362
}
363
logService.debug(`[github review agent] response line: ${line}`);
364
const refs = parseLine(line);
365
references.push(...refs);
366
for (const ghComment of refs.filter(ref => ref.type === 'github.generated-pull-request-comment')) {
367
const change = filteredChanges.find(c => c.relativePath === ghComment.data.path);
368
if (!change) {
369
continue;
370
}
371
const comment = createReviewComment(ghComment, request, change.document, comments.length);
372
comments.push(comment);
373
}
374
}
375
const excludedComments = references.filter((ref): ref is ExcludedComment => ref.type === 'github.excluded-pull-request-comment')
376
.map(ghComment => {
377
const change = filteredChanges.find(c => c.relativePath === ghComment.data.path);
378
return { ghComment, change };
379
}).filter((item): item is { ghComment: ExcludedComment; change: NonNullable<typeof item.change> } => !!item.change)
380
.map(({ ghComment, change }, i) => createReviewComment(ghComment, request, change.document, comments.length + i));
381
const unsupportedLanguages = !comments.length ? [...new Set(references.filter((ref): ref is ExcludedFile => ref.type === 'github.excluded-file' && ref.data.reason === 'file_type_not_supported')
382
.map(ref => ref.data.language))] : [];
383
return { type: 'success', comments, excludedComments, reason: unsupportedLanguages.length ? l10n.t('Some of the submitted languages are currently not supported: {0}', unsupportedLanguages.join(', ')) : undefined };
384
}
385
386
export function createReviewComment(ghComment: ResponseComment | ExcludedComment, request: ReviewRequest, document: TextDocument, index: number) {
387
const fromLine = document.lineAt(ghComment.data.line - 1);
388
const lastNonWhitespaceCharacterIndex = fromLine.text.trimEnd().length;
389
const range = new Range(fromLine.lineNumber, fromLine.firstNonWhitespaceCharacterIndex, fromLine.lineNumber, lastNonWhitespaceCharacterIndex);
390
const raw = ghComment.data.body;
391
// Remove suggestion because that interfers with our own suggestion rendering later.
392
const { content, suggestions } = removeSuggestion(raw);
393
const startLine = typeof ghComment.data.start_line === 'number' ? ghComment.data.start_line : ghComment.data.line;
394
const suggestionRange = new Range(startLine - 1, 0, ghComment.data.line, 0);
395
const comment: ReviewComment = {
396
request,
397
document: TextDocumentSnapshot.create(document),
398
uri: document.uri,
399
languageId: document.languageId,
400
range,
401
body: new MarkdownString(content),
402
kind: 'bug',
403
severity: 'medium',
404
originalIndex: index,
405
actionCount: 0,
406
skipSuggestion: true,
407
suggestion: {
408
markdown: '',
409
edits: suggestions.map(suggestion => {
410
const oldText = document.getText(suggestionRange);
411
return {
412
range: suggestionRange,
413
newText: suggestion,
414
oldText,
415
};
416
}),
417
},
418
};
419
return comment;
420
}
421
422
const SUGGESTION_EXPRESSION = /```suggestion(\u0020*(\r\n|\n))((?<suggestion>[\s\S]*?)(\r\n|\n))?```/g;
423
export function removeSuggestion(body: string) {
424
const suggestions: string[] = [];
425
const content = body.replaceAll(SUGGESTION_EXPRESSION, (_match, _ws, _nl, suggestion) => {
426
if (suggestion) {
427
suggestions.push(suggestion);
428
}
429
return '';
430
});
431
return { content, suggestions };
432
}
433
434
// Represents the "before" or "after" state of a file, sent to the agent
435
interface FileState {
436
// The path of the file
437
path: string;
438
// The file's contents. If the file does not exist in this state, this should be an empty string.
439
content: string;
440
// The language ID of the file
441
languageId: string;
442
// The selection within the file, if any
443
selection?: Selection;
444
}
445
446
// A generated pull request comment returned by the agent.
447
//
448
// NOTE: The shape of these return values is under active development and is likely to change.
449
//
450
// Example:
451
//
452
// {
453
// "type": "github.generated-pull-request-comment",
454
// "data": {
455
// "path": "packages/issues/test/models/referrer_and_referenceable_model_test.rb",
456
// "line": 82,
457
// "body": "The word 'Out' should be 'Our'.\n```suggestion\n # Our batched insert only hits the cross references table twice\n```",
458
// "side": "RIGHT"
459
// },
460
// "id": "",
461
// "is_implicit": false,
462
// "metadata": {
463
// "display_name": "",
464
// "display_icon": "",
465
// "display_url": ""
466
// }
467
// }
468
469
export type ResponseReference = ResponseComment | ExcludedComment | ExcludedFile | { type: 'unknown' };
470
471
export interface ResponseComment {
472
type: 'github.generated-pull-request-comment';
473
data: {
474
// The path of the file
475
path: string;
476
// The right-hand line number the comment relates to
477
line: number;
478
// The body of the comment, including a ```suggestion block if there is a suggested change
479
body: string;
480
start_line?: number;
481
};
482
}
483
484
export interface ExcludedComment {
485
type: 'github.excluded-pull-request-comment';
486
data: {
487
path: string;
488
line: number;
489
body: string;
490
start_line?: number;
491
exclusion_reason: 'denylisted_type' | 'unknown';
492
};
493
}
494
495
export interface ExcludedFile {
496
type: 'github.excluded-file';
497
data: {
498
file_path: string;
499
language: string;
500
reason: 'file_type_not_supported' | 'unknown';
501
};
502
}
503
504
/**
505
* Raw reference structure from the API response before type validation.
506
*/
507
interface RawReference {
508
type?: string;
509
data?: unknown;
510
}
511
512
/**
513
* Raw parsed response structure from the streaming API.
514
*/
515
interface ParsedResponse {
516
copilot_references?: RawReference[];
517
}
518
519
/**
520
* Type guard to check if a raw reference has a valid type field.
521
* Matches original behavior: filters to refs where ref.type is truthy.
522
*/
523
function hasType(ref: RawReference): ref is RawReference & { type: string } {
524
return !!ref.type;
525
}
526
527
export function parseLine(line: string): ResponseReference[] {
528
529
if (line === 'data: [DONE]') { return []; }
530
if (line === '') { return []; }
531
532
const parsedLine: ParsedResponse = JSON.parse(line.replace('data: ', ''));
533
534
if (Array.isArray(parsedLine.copilot_references) && parsedLine.copilot_references.length > 0) {
535
return parsedLine.copilot_references.filter(hasType) as ResponseReference[];
536
} else {
537
return [];
538
}
539
}
540
541
async function fetchComments(logService: ILogService, authService: IAuthenticationService, capiClientService: ICAPIClientService, fetcherService: IFetcherService, envService: IEnvService, customInstructionsService: ICustomInstructionsService, workspaceService: IWorkspaceService, kind: 'selection' | 'diff', repository: Repository | undefined, baseFileContents: FileState[], headFileContents: FileState[], cancellationToken: CancellationToken) {
542
// Collect languageId to file patterns mapping
543
const languageIdToFilePatterns = new Map<string, Set<string>>();
544
for (const file of [...baseFileContents, ...headFileContents]) {
545
const ext = path.extname(file.path);
546
if (ext) {
547
if (!languageIdToFilePatterns.has(file.languageId)) {
548
languageIdToFilePatterns.set(file.languageId, new Set());
549
}
550
languageIdToFilePatterns.get(file.languageId)!.add(`*${ext}`);
551
}
552
}
553
554
const customInstructions = await loadCustomInstructions(customInstructionsService, workspaceService, kind, languageIdToFilePatterns, 2);
555
556
const requestBody = {
557
messages: [{
558
role: 'user',
559
...(kind === 'selection' ? {
560
review_type: 'snippet',
561
snippet_files: headFileContents.map(f => ({
562
path: f.path,
563
regions: [
564
{
565
start_line: f.selection!.start.line + 1,
566
end_line: f.selection!.end.line + (f.selection!.end.character > 0 ? 1 : 0), // If selection ends at start of line, don't include that line
567
}
568
]
569
})),
570
} : {}),
571
copilot_references: [
572
{
573
type: 'github.pull_request',
574
id: '1',
575
data: {
576
type: 'pull-request',
577
headFileContents: headFileContents.map(({ path, content }) => ({ path, content })),
578
baseFileContents: baseFileContents.map(({ path, content }) => ({ path, content })),
579
},
580
},
581
...customInstructions,
582
],
583
}]
584
};
585
586
const abort = fetcherService.makeAbortController();
587
const disposable = cancellationToken.onCancellationRequested(() => abort.abort());
588
let response: Response;
589
try {
590
const copilotToken = await authService.getCopilotToken();
591
response = await capiClientService.makeRequest({
592
method: 'POST',
593
headers: {
594
Authorization: 'Bearer ' + copilotToken.token,
595
'X-Copilot-Code-Review-Mode': 'ide',
596
},
597
body: JSON.stringify(requestBody),
598
signal: abort.signal,
599
}, { type: RequestType.CodeReviewAgent });
600
} catch (err) {
601
if (fetcherService.isAbortError(err)) {
602
return {
603
requestId: undefined,
604
rl: undefined,
605
};
606
}
607
throw err;
608
} finally {
609
disposable.dispose();
610
}
611
612
const requestId = response.headers.get('x-github-request-id') || undefined;
613
614
if (!response.ok) {
615
if (response.status === 402) {
616
const err = new Error(`You have reached your Code Review quota limit.`);
617
(err as any).severity = 'info';
618
throw err;
619
}
620
throw new Error(`Agent returned an unexpected HTTP ${response.status} error (request id ${requestId || 'unknown'}).`);
621
}
622
623
return {
624
requestId,
625
rl: readline.createInterface({ input: Readable.fromWeb(response.body.toReadableStream()) }),
626
};
627
}
628
629
export function reversePatch(after: string, diff: string) {
630
const patch = parsePatch(diff.split(/\r?\n/));
631
const patchedLines = reverseParsedPatch(after.split(/\r?\n/), patch);
632
return patchedLines.join('\n');
633
}
634
635
export interface LineChange {
636
beforeLineNumber: number;
637
content: string;
638
type: 'add' | 'remove';
639
}
640
641
export function parsePatch(patchLines: string[]): LineChange[] {
642
const changes: LineChange[] = [];
643
let beforeLineNumber = -1;
644
645
for (const line of patchLines) {
646
if (line.startsWith('@@')) {
647
const match = /@@ -(\d+),\d+ \+\d+,\d+ @@/.exec(line);
648
if (match) {
649
beforeLineNumber = parseInt(match[1], 10);
650
}
651
} else if (beforeLineNumber !== -1) {
652
if (line.startsWith('+')) {
653
changes.push({ beforeLineNumber, content: line.slice(1), type: 'add' });
654
} else if (line.startsWith('-')) {
655
changes.push({ beforeLineNumber, content: line.slice(1), type: 'remove' });
656
beforeLineNumber++;
657
} else {
658
beforeLineNumber++;
659
}
660
}
661
}
662
663
return changes;
664
}
665
666
export function reverseParsedPatch(fileLines: string[], patch: LineChange[]): string[] {
667
for (const change of patch) {
668
if (change.type === 'add') {
669
fileLines.splice(change.beforeLineNumber - 1, 1);
670
} else if (change.type === 'remove') {
671
fileLines.splice(change.beforeLineNumber - 1, 0, change.content);
672
}
673
}
674
675
return fileLines;
676
}
677
678
export interface CodingGuideline {
679
type: string;
680
id: string;
681
data: {
682
id: number;
683
type: string;
684
name: string;
685
description: string;
686
filePatterns: string[];
687
};
688
}
689
690
export async function loadCustomInstructions(customInstructionsService: ICustomInstructionsService, workspaceService: IWorkspaceService, kind: 'selection' | 'diff', languageIdToFilePatterns: Map<string, Set<string>>, firstId: number): Promise<CodingGuideline[]> {
691
const customInstructionRefs = [];
692
let nextId = firstId;
693
694
// Collect instruction files from agent instructions
695
const agentInstructionUris = await customInstructionsService.getAgentInstructions();
696
for (const uri of agentInstructionUris) {
697
const instructions = await customInstructionsService.fetchInstructionsFromFile(Uri.from(uri));
698
if (instructions) {
699
const relativePath = workspaceService.asRelativePath(Uri.from(uri));
700
for (const instruction of instructions.content) {
701
// Skip instructions with languageId if not in map
702
if (instruction.languageId && !languageIdToFilePatterns.has(instruction.languageId)) {
703
continue;
704
}
705
const filePatterns = instruction.languageId ? Array.from(languageIdToFilePatterns.get(instruction.languageId)!) : ['*'];
706
customInstructionRefs.push({
707
type: 'github.coding_guideline',
708
id: `${nextId}`,
709
data: {
710
id: nextId,
711
type: 'coding-guideline',
712
name: `Instruction from ${relativePath}`,
713
description: instruction.instruction,
714
filePatterns,
715
},
716
});
717
nextId++;
718
}
719
}
720
}
721
722
// Collect instructions from settings
723
const settingsConfigs = [
724
{ config: ConfigKey.CodeGenerationInstructions, name: 'Code Generation Instruction' },
725
...(kind === 'selection' ? [{ config: ConfigKey.CodeFeedbackInstructions, name: 'Code Review Instruction' }] : []),
726
];
727
728
for (const { config, name } of settingsConfigs) {
729
const instructionsGroups = await customInstructionsService.fetchInstructionsFromSetting(config);
730
for (const instructionsGroup of instructionsGroups) {
731
for (const instruction of instructionsGroup.content) {
732
// Skip instructions with languageId if not in map
733
if (instruction.languageId && !languageIdToFilePatterns.has(instruction.languageId)) {
734
continue;
735
}
736
const filePatterns = instruction.languageId ? Array.from(languageIdToFilePatterns.get(instruction.languageId)!) : ['*'];
737
customInstructionRefs.push({
738
type: 'github.coding_guideline',
739
id: `${nextId}`,
740
data: {
741
id: nextId,
742
type: 'coding-guideline',
743
name,
744
description: instruction.instruction,
745
filePatterns,
746
},
747
});
748
nextId++;
749
}
750
}
751
}
752
753
return customInstructionRefs;
754
}
755
756