Path: blob/main/extensions/copilot/src/extension/prompt/node/githubPullRequestTitleAndDescriptionGenerator.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 { RenderPromptResult } from '@vscode/prompt-tsx';6import { IAuthenticationService } from '../../../platform/authentication/common/authentication';7import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes';8import { IConversationOptions } from '../../../platform/chat/common/conversationOptions';9import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';10import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';11import { ILogService } from '../../../platform/log/common/logService';12import { INotificationService } from '../../../platform/notification/common/notificationService';13import { CancellationToken } from '../../../util/vs/base/common/cancellation';14import { DisposableStore, } from '../../../util/vs/base/common/lifecycle';15import { isStringArray } from '../../../util/vs/base/common/types';16import { URI } from '../../../util/vs/base/common/uri';17import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';18import { TitleAndDescriptionProvider } from '../../githubPullRequest';19import { PromptRenderer } from '../../prompts/node/base/promptRenderer';20import { GitHubPullRequestPrompt } from '../../prompts/node/github/pullRequestDescriptionPrompt';2122export class GitHubPullRequestTitleAndDescriptionGenerator implements TitleAndDescriptionProvider {23protected readonly disposables: DisposableStore = new DisposableStore();24private lastContext: { commitMessages: string[]; patches: string[] } = { commitMessages: [], patches: [] };2526constructor(27@ILogService protected readonly logService: ILogService,28@IConversationOptions private readonly options: IConversationOptions,29@IIgnoreService private readonly ignoreService: IIgnoreService,30@IEndpointProvider private readonly endpointProvider: IEndpointProvider,31@IInstantiationService private readonly instantiationService: IInstantiationService,32@INotificationService private readonly notificationService: INotificationService,33@IAuthenticationService private readonly authService: IAuthenticationService,34) {35this.logService.info('[githubTitleAndDescriptionProvider] Initializing GitHub PR title and description provider provider.');36}3738dispose() {39this.disposables.dispose();40}4142private isRegenerate(commitMessages: string[], patches: string[]): boolean {43if (commitMessages.length !== this.lastContext.commitMessages.length || patches.length !== this.lastContext.patches.length) {44return false;45}46for (let i = 0; i < commitMessages.length; i++) {47if (commitMessages[i] !== this.lastContext.commitMessages[i]) {48return false;49}50}51for (let i = 0; i < patches.length; i++) {52if (patches[i] !== this.lastContext.patches[i]) {53return false;54}55}56return true;57}5859private async excludePatches(allPatches: { patch: string; fileUri?: string; previousFileUri?: string }[]): Promise<string[]> {60const patches: string[] = [];61for (const patch of allPatches) {62if (patch.fileUri && await this.ignoreService.isCopilotIgnored(URI.parse(patch.fileUri))) {63continue;64}6566if (patch.previousFileUri && patch.previousFileUri !== patch.fileUri && await this.ignoreService.isCopilotIgnored(URI.parse(patch.previousFileUri))) {67continue;68}6970patches.push(patch.patch);71}72return patches;73}7475async provideTitleAndDescription(context: { commitMessages: string[]; patches: string[] | { patch: string; fileUri: string; previousFileUri?: string }[]; issues?: { reference: string; content: string }[]; template?: string; compareBranch?: string }, token: CancellationToken): Promise<{ title: string; description?: string } | undefined> {76const commitMessages: string[] = context.commitMessages;77const allPatches: { patch: string; fileUri?: string; previousFileUri?: string }[] = isStringArray(context.patches) ? context.patches.map(patch => ({ patch })) : context.patches;78const patches = await this.excludePatches(allPatches);79const issues: { reference: string; content: string }[] | undefined = context.issues;80const template: string | undefined = context.template;81const compareBranch: string | undefined = context.compareBranch;8283const endpoint = await this.endpointProvider.getChatEndpoint('copilot-fast');84const charLimit = Math.floor((endpoint.modelMaxPromptTokens * 4) / 3);8586const prompt = await this.createPRTitleAndDescriptionPrompt(commitMessages, patches, issues, template, compareBranch, charLimit);87const fetchResult = await endpoint88.makeChatRequest(89'githubPullRequestTitleAndDescriptionGenerator',90prompt.messages,91undefined,92token,93ChatLocation.Other,94undefined,95{96temperature: this.isRegenerate(commitMessages, patches) ? this.options.temperature + 0.1 : this.options.temperature,97},98);99100this.lastContext = { commitMessages, patches };101if (fetchResult.type === ChatFetchResponseType.QuotaExceeded || (fetchResult.type === ChatFetchResponseType.RateLimited && this.authService.copilotToken?.isNoAuthUser)) {102await this.notificationService.showQuotaExceededDialog({ isNoAuthUser: this.authService.copilotToken?.isNoAuthUser ?? false });103}104105if (fetchResult.type !== ChatFetchResponseType.Success) {106return undefined;107}108109return GitHubPullRequestTitleAndDescriptionGenerator.parseFetchResult(fetchResult.value, !!template);110}111112public static parseFetchResult(value: string, hasTemplate: boolean = false, retry: boolean = true): { title: string; description?: string } | undefined {113value = value.trim();114let workingValue = value;115let delimiter = '+++';116const firstIndexOfDelimiter = workingValue.indexOf(delimiter);117if (firstIndexOfDelimiter === -1) {118return undefined;119}120121// adjust delimter as the model sometimes adds more +s122while (workingValue.charAt(firstIndexOfDelimiter + delimiter.length) === '+') {123delimiter += '+';124}125126const lastIndexOfDelimiter = workingValue.lastIndexOf(delimiter);127workingValue = workingValue.substring(firstIndexOfDelimiter + delimiter.length, lastIndexOfDelimiter > firstIndexOfDelimiter + delimiter.length ? lastIndexOfDelimiter : undefined).trim().replace(/\++?(\n)\++/, delimiter);128const splitOnPlus = workingValue.split(delimiter).filter(s => s.trim().length > 0);129let splitOnLines: string[];130if (splitOnPlus.length === 1) {131// If there's only one line, split on newlines as the model has left out some +++ delimiters132splitOnLines = splitOnPlus[0].split('\n');133} else if (splitOnPlus.length > 1) {134if (hasTemplate) {135// When using a template, keep description whitespace as-is.136splitOnLines = splitOnPlus;137} else {138const descriptionLines = splitOnPlus.slice(1).map(line => line.split('\n')).flat().filter(s => s.trim().length > 0);139splitOnLines = [splitOnPlus[0], ...descriptionLines];140}141} else {142return undefined;143}144145let title: string | undefined;146let description: string | undefined;147if (splitOnLines.length === 1) {148title = splitOnLines[0].trim();149if (retry && value.includes('\n') && (value.split(delimiter).length === 3)) {150return this.parseFetchResult(value + delimiter, hasTemplate, false);151}152} else if (splitOnLines.length > 1) {153title = splitOnLines[0].trim();154155description = '';156const descriptionLines = splitOnLines.slice(1);157// The description can be kind of self referential. Clean it up.158for (const line of descriptionLines) {159if (line.includes('commit message')) {160continue;161}162description += `${line.trim()}\n\n`;163}164}165if (title) {166title = title.replace(/Title\:\s/, '').trim();167title = title.replace(/^\"(?<title>.+)\"$/, (_match, title) => title);168if (description && !hasTemplate) {169description = description.replace(/Description\:\s/, '').trim();170}171return { title, description };172}173}174175private async createPRTitleAndDescriptionPrompt(commitMessages: string[], patches: string[], issues: { reference: string; content: string }[] | undefined, template: string | undefined, compareBranch: string | undefined, charLimit: number): Promise<RenderPromptResult> {176// Reserve 20% of the character limit for the safety rules and instructions177const availableChars = charLimit - Math.floor(charLimit * 0.2);178179// Remove diffs if needed (shortest diffs first)180let totalChars = patches.join('\n\n').length;181if (totalChars > availableChars) {182// Sort diffs by length183patches.sort((a, b) => a.length - b.length);184185// Remove diff(s) until we are under the character limit186while (totalChars > availableChars && patches.length > 0) {187const lastPatch = patches.pop()!;188totalChars -= lastPatch.length;189}190}191192const endpoint = await this.endpointProvider.getChatEndpoint('copilot-fast');193const promptRenderer = PromptRenderer.create(this.instantiationService, endpoint, GitHubPullRequestPrompt, { commitMessages, issues, patches, template, compareBranch });194return promptRenderer.render(undefined, undefined);195}196}197198199