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