From a51032e63ef0e3cc00094d0a198ec38d97b98979 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Tue, 7 Mar 2023 00:08:35 -0500 Subject: [PATCH] Adds experimental OpenAI commit message generation --- package.json | 25 ++++ src/commands.ts | 1 + src/commands/generateCommitMessage.ts | 257 ++++++++++++++++++++++++++++++++++ src/config.ts | 3 + src/constants.ts | 2 + src/env/node/git/git.ts | 25 ++++ src/env/node/git/localGitProvider.ts | 32 +++++ src/git/gitProvider.ts | 6 + src/git/gitProviderService.ts | 11 ++ src/git/parsers/diffParser.ts | 4 +- src/storage.ts | 7 +- 11 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 src/commands/generateCommitMessage.ts diff --git a/package.json b/package.json index 991ba7f..7772d2f 100644 --- a/package.json +++ b/package.json @@ -3634,6 +3634,13 @@ "scope": "window", "order": 50 }, + "gitlens.experimental.generateCommitMessagePrompt": { + "type": "string", + "default": "The commit message must have a short description that is less than 50 characters long followed by a more detailed description on a new line.", + "markdownDescription": "Specifies the prompt to use to tell OpenAI how to structure or format the generated commit message", + "scope": "window", + "order": 55 + }, "gitlens.advanced.externalDiffTool": { "type": [ "string", @@ -4425,6 +4432,16 @@ ], "commands": [ { + "command": "gitlens.generateCommitMessage", + "title": "Generate Commit Message (Experimental)", + "category": "GitLens" + }, + { + "command": "gitlens.resetOpenAIKey", + "title": "Reset Stored OpenAI Key", + "category": "GitLens" + }, + { "command": "gitlens.plus.learn", "title": "Learn about GitLens+ Features", "category": "GitLens+" @@ -9238,6 +9255,14 @@ { "command": "gitlens.disableDebugLogging", "when": "config.gitlens.outputLevel != errors" + }, + { + "command": "gitlens.generateCommitMessage", + "when": "gitlens:prerelease" + }, + { + "command": "gitlens.resetOpenAIKey", + "when": "gitlens:prerelease" } ], "editor/context": [ diff --git a/src/commands.ts b/src/commands.ts index 07b9d88..9e19888 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -17,6 +17,7 @@ export * from './commands/diffWithRevision'; export * from './commands/diffWithRevisionFrom'; export * from './commands/diffWithWorking'; export * from './commands/externalDiff'; +export * from './commands/generateCommitMessage'; export * from './commands/ghpr/openOrCreateWorktree'; export * from './commands/gitCommands'; export * from './commands/inviteToLiveShare'; diff --git a/src/commands/generateCommitMessage.ts b/src/commands/generateCommitMessage.ts new file mode 100644 index 0000000..73e38d7 --- /dev/null +++ b/src/commands/generateCommitMessage.ts @@ -0,0 +1,257 @@ +import type { Disposable, MessageItem, QuickInputButton, TextEditor } from 'vscode'; +import { env, ProgressLocation, ThemeIcon, Uri, window } from 'vscode'; +import { fetch } from '@env/fetch'; +import { Commands, CoreCommands } from '../constants'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import { uncommittedStaged } from '../git/models/constants'; +import { showGenericErrorMessage } from '../messages'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import type { Storage } from '../storage'; +import { command, executeCoreCommand } from '../system/command'; +import { configuration } from '../system/configuration'; +import { Logger } from '../system/logger'; +import { ActiveEditorCommand, Command, getCommandUri } from './base'; + +const maxCodeCharacters = 12000; + +export interface GenerateCommitMessageCommandArgs { + repoPath?: string; +} + +@command() +export class GenerateCommitMessageCommand extends ActiveEditorCommand { + constructor(private readonly container: Container) { + super(Commands.GenerateCommitMessage); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: GenerateCommitMessageCommandArgs) { + args = { ...args }; + + let repository; + if (args.repoPath != null) { + repository = this.container.git.getRepository(args.repoPath); + } else { + uri = getCommandUri(uri, editor); + + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + + repository = await getBestRepositoryOrShowPicker(gitUri, editor, 'Generate Commit Message'); + } + if (repository == null) return; + + const scmRepo = await this.container.git.getScmRepository(repository.path); + if (scmRepo == null) return; + + try { + const diff = await this.container.git.getDiff(repository.uri, uncommittedStaged, undefined, { + includeRawDiff: true, + }); + if (diff?.diff == null) { + void window.showInformationMessage('No staged changes to generate a commit message from.'); + + return; + } + + const confirmed = await confirmSendToOpenAI(this.container.storage); + if (!confirmed) return; + + let openaiApiKey = await this.container.storage.getSecret('gitlens.openai.key'); + if (!openaiApiKey) { + const input = window.createInputBox(); + input.ignoreFocusOut = true; + + const disposables: Disposable[] = []; + + try { + const infoButton: QuickInputButton = { + iconPath: new ThemeIcon(`link-external`), + tooltip: 'Open OpenAI API key page', + }; + + openaiApiKey = await new Promise(resolve => { + disposables.push( + input.onDidHide(() => resolve(undefined)), + input.onDidChangeValue(value => { + if (value && !/sk-[a-zA-Z0-9]{32}/.test(value)) { + input.validationMessage = 'Please enter a valid OpenAI API key'; + return; + } + input.validationMessage = undefined; + }), + input.onDidAccept(() => { + const value = input.value.trim(); + if (!value || !/sk-[a-zA-Z0-9]{32}/.test(value)) { + input.validationMessage = 'Please enter a valid OpenAI API key'; + return; + } + + resolve(value); + }), + input.onDidTriggerButton(e => { + if (e === infoButton) { + void env.openExternal(Uri.parse('https://platform.openai.com/account/api-keys')); + } + }), + ); + + input.password = true; + input.title = 'Connect to OpenAI'; + input.placeholder = 'Please enter your OpenAI API key to use this feature'; + input.prompt = 'Enter your OpenAI API key'; + input.buttons = [infoButton]; + + input.show(); + }); + } finally { + input.dispose(); + disposables.forEach(d => void d.dispose()); + } + + if (!openaiApiKey) return; + + void this.container.storage.storeSecret('gitlens.openai.key', openaiApiKey); + } + + const currentMessage = scmRepo.inputBox.value; + const code = diff.diff.substring(0, maxCodeCharacters); + + const data: OpenAIChatCompletionRequest = { + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'system', + content: `You are a highly skilled software engineer and are tasked with writing, in an informal tone, a concise but meaningful commit message summarizing the changes you made to a codebase. ${configuration.get( + 'experimental.generateCommitMessagePrompt', + )} Don't repeat yourself and don't make anything up. Avoid specific names from the code. Avoid phrases like "this commit", "this change", etc.`, + }, + ], + }; + + if (currentMessage) { + data.messages.push({ + role: 'user', + content: `Use the following additional context to craft the commit message: ${currentMessage}`, + }); + } + data.messages.push({ role: 'user', content: code }); + + await window.withProgress( + { location: ProgressLocation.Notification, title: 'Generating commit message...' }, + async () => { + const rsp = await fetch('https://api.openai.com/v1/chat/completions', { + headers: { + Authorization: `Bearer ${openaiApiKey}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify(data), + }); + + if (!rsp.ok) { + void showGenericErrorMessage( + `Unable to generate commit message: ${rsp.status}: ${rsp.statusText}`, + ); + + return; + } + + const completion: OpenAIChatCompletionResponse = await rsp.json(); + + void executeCoreCommand(CoreCommands.ShowSCM); + + const message = completion.choices[0].message.content.trim(); + scmRepo.inputBox.value = `${currentMessage ? `${currentMessage}\n\n` : ''}${message}`; + }, + ); + if (diff.diff.length > maxCodeCharacters) { + void window.showWarningMessage( + `The diff of the staged changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`, + ); + } + } catch (ex) { + Logger.error(ex, 'GenerateCommitMessageCommand'); + void showGenericErrorMessage('Unable to generate commit message'); + } + } +} + +interface OpenAIChatCompletionRequest { + model: 'gpt-3.5-turbo' | 'gpt-3.5-turbo-0301'; + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; + temperature?: number; + top_p?: number; + n?: number; + stream?: boolean; + stop?: string | string[]; + max_tokens?: number; + presence_penalty?: number; + frequency_penalty?: number; + logit_bias?: { [token: string]: number }; + user?: string; +} + +interface OpenAIChatCompletionResponse { + id: string; + object: 'chat.completion'; + created: number; + model: string; + choices: { + index: number; + message: { + role: 'system' | 'user' | 'assistant'; + content: string; + }; + finish_reason: string; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +@command() +export class ResetOpenAIKeyCommand extends Command { + constructor(private readonly container: Container) { + super(Commands.ResetOpenAIKey); + } + + execute() { + void this.container.storage.deleteSecret('gitlens.openai.key'); + void this.container.storage.delete('confirm:sendToOpenAI'); + void this.container.storage.deleteWorkspace('confirm:sendToOpenAI'); + } +} + +export async function confirmSendToOpenAI(storage: Storage): Promise { + const confirmed = storage.get('confirm:sendToOpenAI', false) || storage.getWorkspace('confirm:sendToOpenAI', false); + if (confirmed) return true; + + const accept: MessageItem = { title: 'Yes' }; + const acceptWorkspace: MessageItem = { title: 'Always for this Workspace' }; + const acceptAlways: MessageItem = { title: 'Always' }; + const decline: MessageItem = { title: 'No', isCloseAffordance: true }; + const result = await window.showInformationMessage( + 'To automatically generate commit messages, the diff of your staged changes is sent to OpenAI. This may contain sensitive information.\n\nDo you want to continue?', + { modal: true }, + accept, + acceptWorkspace, + acceptAlways, + decline, + ); + + if (result === accept) return true; + + if (result === acceptWorkspace) { + void storage.storeWorkspace('confirm:sendToOpenAI', true); + return true; + } + + if (result === acceptAlways) { + void storage.store('confirm:sendToOpenAI', true); + return true; + } + + return false; +} diff --git a/src/config.ts b/src/config.ts index c7dc6eb..6e5f5f0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,9 @@ export interface Config { defaultGravatarsStyle: GravatarDefaultStyle; defaultTimeFormat: DateTimeFormat | string | null; detectNestedRepositories: boolean; + experimental: { + generateCommitMessagePrompt: string; + }; fileAnnotations: { command: string | null; }; diff --git a/src/constants.ts b/src/constants.ts index 8eaeca2..64d886d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -130,6 +130,7 @@ export const enum Commands { ExternalDiff = 'gitlens.externalDiff', ExternalDiffAll = 'gitlens.externalDiffAll', FetchRepositories = 'gitlens.fetchRepositories', + GenerateCommitMessage = 'gitlens.generateCommitMessage', GetStarted = 'gitlens.getStarted', InviteToLiveShare = 'gitlens.inviteToLiveShare', OpenAutolinkUrl = 'gitlens.openAutolinkUrl', @@ -189,6 +190,7 @@ export const enum Commands { RefreshHover = 'gitlens.refreshHover', RefreshTimelinePage = 'gitlens.refreshTimelinePage', ResetAvatarCache = 'gitlens.resetAvatarCache', + ResetOpenAIKey = 'gitlens.resetOpenAIKey', ResetSuppressedWarnings = 'gitlens.resetSuppressedWarnings', ResetTrackedUsage = 'gitlens.resetTrackedUsage', RevealCommitInView = 'gitlens.revealCommitInView', diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts index 000b60e..97216ba 100644 --- a/src/env/node/git/git.ts +++ b/src/env/node/git/git.ts @@ -598,6 +598,31 @@ export class Git { } } + async diff2( + repoPath: string, + options?: { + cancellation?: CancellationToken; + configs?: readonly string[]; + errors?: GitErrorHandling; + stdin?: string; + }, + ...args: string[] + ) { + return this.git( + { + cwd: repoPath, + cancellation: options?.cancellation, + configs: options?.configs ?? gitLogDefaultConfigs, + errors: options?.errors, + stdin: options?.stdin, + }, + 'diff', + ...(options?.stdin ? ['--stdin'] : emptyArray), + ...args, + ...(!args.includes('--') ? ['--'] : emptyArray), + ); + } + async diff__contents( repoPath: string, fileName: string, diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index f5458a1..a470641 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -2324,6 +2324,38 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() + async getDiff( + repoPath: string, + ref1: string, + ref2?: string, + options?: { includeRawDiff?: boolean }, + ): Promise { + let data; + if (ref1 === uncommitted) { + if (ref2 == null) { + data = await this.git.diff2(repoPath, undefined, '-U3'); + } else { + data = await this.git.diff2(repoPath, undefined, '-U3', ref2); + } + } else if (ref1 === uncommittedStaged) { + if (ref2 == null) { + data = await this.git.diff2(repoPath, undefined, '-U3', '--staged'); + } else { + data = await this.git.diff2(repoPath, undefined, '-U3', '--staged', ref2); + } + } else if (ref2 == null) { + data = await this.git.diff2(repoPath, undefined, '-U3', `${ref1}^`, ref1); + } else { + data = await this.git.diff2(repoPath, undefined, '-U3', ref1, ref2); + } + + if (!data) return undefined; + + const diff = GitDiffParser.parse(data, options?.includeRawDiff); + return diff; + } + + @log() async getDiffForFile(uri: GitUri, ref1: string | undefined, ref2?: string): Promise { const scope = getLogScope(); diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index e598cc9..a0aa90b 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -244,6 +244,12 @@ export interface GitProvider extends Disposable { ): Promise; getCurrentUser(repoPath: string): Promise; getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise; + getDiff?( + repoPath: string | Uri, + ref1: string, + ref2?: string, + options?: { includeRawDiff?: boolean }, + ): Promise; /** * Returns a file diff between two commits * @param uri Uri of the file to diff diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 7b16b12..2b096b5 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -1462,6 +1462,17 @@ export class GitProviderService implements Disposable { } @log() + async getDiff( + repoPath: string | Uri, + ref1: string, + ref2?: string, + options?: { includeRawDiff?: boolean }, + ): Promise { + const { provider, path } = this.getProvider(repoPath); + return provider.getDiff?.(path, ref1, ref2, options); + } + + @log() /** * Returns a file diff between two commits * @param uri Uri of the file to diff diff --git a/src/git/parsers/diffParser.ts b/src/git/parsers/diffParser.ts index f988d25..c9f4e80 100644 --- a/src/git/parsers/diffParser.ts +++ b/src/git/parsers/diffParser.ts @@ -10,7 +10,7 @@ const unifiedDiffRegex = /^@@ -([\d]+)(?:,([\d]+))? \+([\d]+)(?:,([\d]+))? @@(?: // eslint-disable-next-line @typescript-eslint/no-extraneous-class export class GitDiffParser { @debug({ args: false, singleLine: true }) - static parse(data: string, debug: boolean = false): GitDiff | undefined { + static parse(data: string, includeRawDiff: boolean = false): GitDiff | undefined { if (!data) return undefined; const hunks: GitDiffHunk[] = []; @@ -58,7 +58,7 @@ export class GitDiffParser { if (!hunks.length) return undefined; const diff: GitDiff = { - diff: debug ? data : undefined, + diff: includeRawDiff ? data : undefined, hunks: hunks, }; return diff; diff --git a/src/storage.ts b/src/storage.ts index f749baa..faaac63 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -105,7 +105,10 @@ export class Storage implements Disposable { } } -export type SecretKeys = `gitlens.integration.auth:${string}` | `gitlens.plus.auth:${Environment}`; +export type SecretKeys = + | `gitlens.integration.auth:${string}` + | 'gitlens.openai.key' + | `gitlens.plus.auth:${Environment}`; export const enum SyncedStorageKeys { Version = 'gitlens:synced:version', @@ -120,6 +123,7 @@ export type DeprecatedGlobalStorage = { export type GlobalStorage = { avatars: [string, StoredAvatar][]; + 'confirm:sendToOpenAI': boolean; 'deepLinks:pending': StoredDeepLinkContext; 'home:actions:completed': CompletedActions[]; 'home:steps:completed': string[]; @@ -155,6 +159,7 @@ export type DeprecatedWorkspaceStorage = { export type WorkspaceStorage = { assumeRepositoriesOnStartup?: boolean; 'branch:comparisons': StoredBranchComparisons; + 'confirm:sendToOpenAI': boolean; 'gitComandPalette:usage': RecentUsage; gitPath: string; 'graph:banners:dismissed': Record;