Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/review/node/doReview.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 * as l10n from '@vscode/l10n';
7
import type { Selection, TextEditor, Uri } from 'vscode';
8
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
9
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
10
import { TextDocumentSnapshot } from '../../../platform/editing/common/textDocumentSnapshot';
11
import { ICAPIClientService } from '../../../platform/endpoint/common/capiClient';
12
import { IDomainService } from '../../../platform/endpoint/common/domainService';
13
import { IEnvService } from '../../../platform/env/common/envService';
14
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
15
import { FileType } from '../../../platform/filesystem/common/fileTypes';
16
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
17
import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';
18
import { ILogService } from '../../../platform/log/common/logService';
19
import { IFetcherService } from '../../../platform/networking/common/fetcherService';
20
import { INotificationService, Progress, ProgressLocation } from '../../../platform/notification/common/notificationService';
21
import { CodeReviewInput, CodeReviewResult, toCodeReviewResult } from '../../../platform/review/common/reviewCommand';
22
import { IReviewService, ReviewComment } from '../../../platform/review/common/reviewService';
23
import { IScopeSelector } from '../../../platform/scopeSelection/common/scopeSelection';
24
import { ITabsAndEditorsService } from '../../../platform/tabs/common/tabsAndEditorsService';
25
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
26
import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
27
import { isCancellationError } from '../../../util/vs/base/common/errors';
28
import * as path from '../../../util/vs/base/common/path';
29
import { URI } from '../../../util/vs/base/common/uri';
30
import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation';
31
import { FeedbackGenerator, FeedbackResult } from '../../prompt/node/feedbackGenerator';
32
import { CurrentChange, CurrentChangeInput } from '../../prompts/node/feedback/currentChange';
33
import { githubReview, githubReviewFileUris } from './githubReviewAgent';
34
35
/**
36
* Dependencies for handleReviewResult function.
37
*/
38
export interface HandleResultDependencies {
39
notificationService: INotificationService;
40
logService: ILogService;
41
reviewService: IReviewService;
42
}
43
44
/**
45
* Handles the review result by showing appropriate notifications.
46
* Extracted for testability.
47
*/
48
export async function handleReviewResult(
49
result: FeedbackResult,
50
deps: HandleResultDependencies
51
): Promise<void> {
52
const { notificationService, logService, reviewService } = deps;
53
54
if (result.type === 'error') {
55
const showLog = l10n.t('Show Log');
56
const res = await (result.severity === 'info'
57
? notificationService.showInformationMessage(result.reason, { modal: true })
58
: notificationService.showInformationMessage(
59
l10n.t('Code review generation failed.'),
60
{ modal: true, detail: result.reason },
61
showLog
62
)
63
);
64
if (res === showLog) {
65
logService.show();
66
}
67
} else if (result.type === 'success' && result.comments.length === 0) {
68
if (result.excludedComments?.length) {
69
const show = l10n.t('Show Skipped');
70
const res = await notificationService.showInformationMessage(
71
l10n.t('Reviewing your code did not provide any feedback.'),
72
{
73
modal: true,
74
detail: l10n.t('{0} comments were skipped due to low confidence.', result.excludedComments.length)
75
},
76
show
77
);
78
if (res === show) {
79
reviewService.addReviewComments(result.excludedComments);
80
}
81
} else {
82
await notificationService.showInformationMessage(
83
l10n.t('Reviewing your code did not provide any feedback.'),
84
{
85
modal: true,
86
detail: result.reason || l10n.t('Copilot only keeps its highest confidence comments to reduce noise and keep you focused.')
87
}
88
);
89
}
90
}
91
}
92
93
// Module-level variable to track in-progress review across all sessions.
94
// This ensures that starting a new review cancels any previous in-progress review.
95
let inProgress: CancellationTokenSource | undefined;
96
97
export class ReviewSession {
98
99
constructor(
100
@IScopeSelector private readonly scopeSelector: IScopeSelector,
101
@IInstantiationService private readonly instantiationService: IInstantiationService,
102
@IReviewService private readonly reviewService: IReviewService,
103
@IAuthenticationService private readonly authService: IAuthenticationService,
104
@ILogService private readonly logService: ILogService,
105
@IGitExtensionService private readonly gitExtensionService: IGitExtensionService,
106
@IDomainService private readonly domainService: IDomainService,
107
@ICAPIClientService private readonly capiClientService: ICAPIClientService,
108
@IFetcherService private readonly fetcherService: IFetcherService,
109
@IEnvService private readonly envService: IEnvService,
110
@IIgnoreService private readonly ignoreService: IIgnoreService,
111
@ITabsAndEditorsService private readonly tabsAndEditorsService: ITabsAndEditorsService,
112
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
113
@INotificationService private readonly notificationService: INotificationService,
114
@ICustomInstructionsService private readonly customInstructionsService: ICustomInstructionsService,
115
) { }
116
117
async review(
118
group: ReviewGroup,
119
progressLocation: ProgressLocation,
120
cancellationToken?: CancellationToken
121
): Promise<FeedbackResult | undefined> {
122
if (!await this.checkAuthentication()) {
123
return undefined;
124
}
125
126
const editor = this.tabsAndEditorsService.activeTextEditor;
127
const selection = await this.resolveSelection(group, editor);
128
if (group === 'selection' && selection === undefined) {
129
return undefined;
130
}
131
132
const title = getReviewTitle(group, editor);
133
return this.executeWithProgress(group, editor, title, progressLocation, cancellationToken);
134
}
135
136
/**
137
* Checks if the user is authenticated. Shows sign-in dialog if not.
138
* @returns true if authenticated, false if user needs to sign in
139
*/
140
private async checkAuthentication(): Promise<boolean> {
141
if (this.authService.copilotToken?.isNoAuthUser) {
142
await this.notificationService.showQuotaExceededDialog({ isNoAuthUser: true });
143
return false;
144
}
145
return true;
146
}
147
148
/**
149
* Resolves the selection for 'selection' group reviews.
150
* @returns The selection range, or undefined if selection cannot be determined
151
*/
152
private async resolveSelection(group: ReviewGroup, editor: TextEditor | undefined): Promise<Selection | undefined> {
153
if (group !== 'selection') {
154
return editor?.selection;
155
}
156
if (!editor) {
157
return undefined;
158
}
159
let selection = editor.selection;
160
if (!selection || selection.isEmpty) {
161
try {
162
const rangeOfEnclosingSymbol = await this.scopeSelector.selectEnclosingScope(editor, {
163
reason: l10n.t('Select an enclosing range to review'),
164
includeBlocks: true
165
});
166
if (!rangeOfEnclosingSymbol) {
167
return undefined;
168
}
169
selection = rangeOfEnclosingSymbol;
170
} catch (err) {
171
if (isCancellationError(err)) {
172
return undefined;
173
}
174
// Original behavior: non-cancellation errors are silently ignored
175
// and we fall through with whatever selection we have
176
// Possibly causes https://github.com/microsoft/vscode/issues/276240
177
}
178
}
179
return selection;
180
}
181
182
/**
183
* Executes the review with progress UI.
184
*/
185
private async executeWithProgress(
186
group: ReviewGroup,
187
editor: TextEditor | undefined,
188
title: string,
189
progressLocation: ProgressLocation,
190
cancellationToken?: CancellationToken
191
): Promise<FeedbackResult | undefined> {
192
return this.notificationService.withProgress({
193
location: progressLocation,
194
title,
195
cancellable: true,
196
}, async (_progress, progressToken) => {
197
if (inProgress) {
198
inProgress.cancel();
199
}
200
const tokenSource = inProgress = new CancellationTokenSource(
201
cancellationToken ? combineCancellationTokens(cancellationToken, progressToken) : progressToken
202
);
203
204
this.reviewService.removeReviewComments(this.reviewService.getReviewComments());
205
const progress: Progress<ReviewComment[]> = {
206
report: comments => {
207
if (!tokenSource.token.isCancellationRequested) {
208
this.reviewService.addReviewComments(comments);
209
}
210
}
211
};
212
213
const result = await this.performReview(group, editor, progress, tokenSource);
214
215
if (tokenSource.token.isCancellationRequested) {
216
return { type: 'cancelled' };
217
}
218
219
await this.handleResult(result);
220
return result;
221
});
222
}
223
224
/**
225
* Performs the actual code review using either GitHub agent or legacy feedback generator.
226
*/
227
private async performReview(
228
group: ReviewGroup,
229
editor: TextEditor | undefined,
230
progress: Progress<ReviewComment[]>,
231
tokenSource: CancellationTokenSource
232
): Promise<FeedbackResult> {
233
try {
234
const copilotToken = await this.authService.getCopilotToken();
235
const canUseGitHubAgent = copilotToken.isCopilotCodeReviewEnabled;
236
237
if (canUseGitHubAgent) {
238
return await githubReview(
239
this.logService, this.gitExtensionService, this.authService,
240
this.capiClientService, this.domainService, this.fetcherService,
241
this.envService, this.ignoreService, this.workspaceService,
242
this.customInstructionsService, group, editor, progress, tokenSource.token
243
);
244
} else {
245
const legacyGroup = typeof group === 'object' && 'group' in group ? group.group : group;
246
return await review(
247
this.instantiationService, this.gitExtensionService, this.workspaceService,
248
legacyGroup, editor, progress, tokenSource.token
249
);
250
}
251
} catch (err) {
252
this.logService.error(err, 'Error during code review');
253
return { type: 'error', reason: err.message, severity: err.severity };
254
} finally {
255
if (tokenSource === inProgress) {
256
inProgress = undefined;
257
}
258
tokenSource.dispose();
259
}
260
}
261
262
/**
263
* Handles the review result by showing appropriate notifications.
264
*/
265
private async handleResult(result: FeedbackResult): Promise<void> {
266
return handleReviewResult(result, {
267
notificationService: this.notificationService,
268
logService: this.logService,
269
reviewService: this.reviewService,
270
});
271
}
272
}
273
274
export type ReviewGroup = 'selection' | 'index' | 'workingTree' | 'all' | { group: 'index' | 'workingTree'; file: Uri } | { repositoryRoot: string; commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] };
275
276
/**
277
* Gets the progress title for a review operation based on the review group type.
278
*/
279
export function getReviewTitle(group: ReviewGroup, editor?: TextEditor): string {
280
if (group === 'selection') {
281
return l10n.t('Reviewing selected code in {0}...', path.posix.basename(editor!.document.uri.path));
282
}
283
if (group === 'index') {
284
return l10n.t('Reviewing staged changes...');
285
}
286
if (group === 'workingTree') {
287
return l10n.t('Reviewing unstaged changes...');
288
}
289
if (group === 'all') {
290
return l10n.t('Reviewing uncommitted changes...');
291
}
292
if ('repositoryRoot' in group) {
293
return l10n.t('Reviewing changes...');
294
}
295
if (group.group === 'index') {
296
return l10n.t('Reviewing staged changes in {0}...', path.posix.basename(group.file.path));
297
}
298
return l10n.t('Reviewing unstaged changes in {0}...', path.posix.basename(group.file.path));
299
}
300
301
export function combineCancellationTokens(token1: CancellationToken, token2: CancellationToken): CancellationToken {
302
const combinedSource = new CancellationTokenSource();
303
304
const subscription1 = token1.onCancellationRequested(() => {
305
combinedSource.cancel();
306
cleanup();
307
});
308
309
const subscription2 = token2.onCancellationRequested(() => {
310
combinedSource.cancel();
311
cleanup();
312
});
313
314
function cleanup() {
315
subscription1.dispose();
316
subscription2.dispose();
317
}
318
319
return combinedSource.token;
320
}
321
322
async function review(
323
instantiationService: IInstantiationService,
324
gitExtensionService: IGitExtensionService,
325
workspaceService: IWorkspaceService,
326
group: 'selection' | 'index' | 'workingTree' | 'all' | { repositoryRoot: string; commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] },
327
editor: TextEditor | undefined,
328
progress: Progress<ReviewComment[]>,
329
cancellationToken: CancellationToken
330
) {
331
const feedbackGenerator = instantiationService.createInstance(FeedbackGenerator);
332
const input: CurrentChangeInput[] = [];
333
if (group === 'index' || group === 'workingTree' || group === 'all') {
334
const changes = await CurrentChange.getCurrentChanges(gitExtensionService, group);
335
const documentsAndChanges = await Promise.all<CurrentChangeInput | undefined>(changes.map(async (change) => {
336
try {
337
const document = await workspaceService.openTextDocument(change.uri);
338
return {
339
document: TextDocumentSnapshot.create(document),
340
relativeDocumentPath: path.relative(change.repository.rootUri.fsPath, change.uri.fsPath),
341
change,
342
};
343
} catch (err) {
344
try {
345
if ((await workspaceService.fs.stat(change.uri)).type === FileType.File) {
346
throw err;
347
}
348
return undefined;
349
} catch (inner) {
350
if (inner.code === 'FileNotFound') {
351
return undefined;
352
}
353
throw err;
354
}
355
}
356
}));
357
documentsAndChanges.map(i => {
358
if (i) {
359
input.push(i);
360
}
361
});
362
} else if (group === 'selection') {
363
input.push({
364
document: TextDocumentSnapshot.create(editor!.document),
365
relativeDocumentPath: path.basename(editor!.document.uri.fsPath),
366
selection: editor!.selection,
367
});
368
} else {
369
for (const patch of group.patches) {
370
const uri = URI.parse(patch.fileUri);
371
input.push({
372
document: TextDocumentSnapshot.create(await workspaceService.openTextDocument(uri)),
373
relativeDocumentPath: path.relative(group.repositoryRoot, uri.fsPath),
374
change: await CurrentChange.getChanges(gitExtensionService, URI.file(group.repositoryRoot), uri, patch.patch)
375
});
376
}
377
}
378
return feedbackGenerator.generateComments(input, cancellationToken, progress);
379
}
380
381
/**
382
* Runs a code review on file URI pairs and returns structured results.
383
* This is the handler for the `github.copilot.chat.codeReview.run` command.
384
* It bypasses the comment controller — results are returned directly to the caller.
385
*/
386
export async function reviewFileChanges(
387
accessor: ServicesAccessor,
388
input: CodeReviewInput,
389
): Promise<CodeReviewResult> {
390
const logService = accessor.get(ILogService);
391
const authService = accessor.get(IAuthenticationService);
392
const capiClientService = accessor.get(ICAPIClientService);
393
const fetcherService = accessor.get(IFetcherService);
394
const envService = accessor.get(IEnvService);
395
const ignoreService = accessor.get(IIgnoreService);
396
const workspaceService = accessor.get(IWorkspaceService);
397
const fileSystemService = accessor.get(IFileSystemService);
398
const customInstructionsService = accessor.get(ICustomInstructionsService);
399
400
const copilotToken = await authService.getCopilotToken();
401
if (!copilotToken.isCopilotCodeReviewEnabled) {
402
return { type: 'error', reason: 'Code review is not enabled for this account.' };
403
}
404
405
const tokenSource = new CancellationTokenSource();
406
try {
407
const fileInputs = await Promise.all(input.files.map(async file => {
408
let baseContent = '';
409
if (file.baseUri) {
410
const bytes = await fileSystemService.readFile(file.baseUri);
411
baseContent = new TextDecoder().decode(bytes);
412
}
413
return { currentUri: file.currentUri, baseContent };
414
}));
415
416
const result = await githubReviewFileUris(
417
logService, authService, capiClientService, fetcherService, envService,
418
ignoreService, workspaceService, customInstructionsService,
419
fileInputs, tokenSource.token,
420
);
421
422
if (result.type === 'success') {
423
return toCodeReviewResult(result.comments);
424
}
425
if (result.type === 'error') {
426
return { type: 'error', reason: result.reason };
427
}
428
return { type: 'cancelled' };
429
} catch (err) {
430
logService.error(err, 'Error during code review command');
431
return { type: 'error', reason: err.message };
432
} finally {
433
tokenSource.dispose();
434
}
435
}
436