Bladeren bron

Adds experimental OpenAI commit message generation

main
Eric Amodio 1 jaar geleden
bovenliggende
commit
a51032e63e
11 gewijzigde bestanden met toevoegingen van 370 en 3 verwijderingen
  1. +25
    -0
      package.json
  2. +1
    -0
      src/commands.ts
  3. +257
    -0
      src/commands/generateCommitMessage.ts
  4. +3
    -0
      src/config.ts
  5. +2
    -0
      src/constants.ts
  6. +25
    -0
      src/env/node/git/git.ts
  7. +32
    -0
      src/env/node/git/localGitProvider.ts
  8. +6
    -0
      src/git/gitProvider.ts
  9. +11
    -0
      src/git/gitProviderService.ts
  10. +2
    -2
      src/git/parsers/diffParser.ts
  11. +6
    -1
      src/storage.ts

+ 25
- 0
package.json Bestand weergeven

@ -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": [

+ 1
- 0
src/commands.ts Bestand weergeven

@ -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';

+ 257
- 0
src/commands/generateCommitMessage.ts Bestand weergeven

@ -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<string | undefined>(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<boolean> {
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;
}

+ 3
- 0
src/config.ts Bestand weergeven

@ -47,6 +47,9 @@ export interface Config {
defaultGravatarsStyle: GravatarDefaultStyle;
defaultTimeFormat: DateTimeFormat | string | null;
detectNestedRepositories: boolean;
experimental: {
generateCommitMessagePrompt: string;
};
fileAnnotations: {
command: string | null;
};

+ 2
- 0
src/constants.ts Bestand weergeven

@ -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',

+ 25
- 0
src/env/node/git/git.ts Bestand weergeven

@ -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<string>(
{
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,

+ 32
- 0
src/env/node/git/localGitProvider.ts Bestand weergeven

@ -2324,6 +2324,38 @@ export class LocalGitProvider implements GitProvider, Disposable {
}
@log()
async getDiff(
repoPath: string,
ref1: string,
ref2?: string,
options?: { includeRawDiff?: boolean },
): Promise<GitDiff | undefined> {
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<GitDiff | undefined> {
const scope = getLogScope();

+ 6
- 0
src/git/gitProvider.ts Bestand weergeven

@ -244,6 +244,12 @@ export interface GitProvider extends Disposable {
): Promise<GitContributor[]>;
getCurrentUser(repoPath: string): Promise<GitUser | undefined>;
getDefaultBranchName(repoPath: string | undefined, remote?: string): Promise<string | undefined>;
getDiff?(
repoPath: string | Uri,
ref1: string,
ref2?: string,
options?: { includeRawDiff?: boolean },
): Promise<GitDiff | undefined>;
/**
* Returns a file diff between two commits
* @param uri Uri of the file to diff

+ 11
- 0
src/git/gitProviderService.ts Bestand weergeven

@ -1462,6 +1462,17 @@ export class GitProviderService implements Disposable {
}
@log()
async getDiff(
repoPath: string | Uri,
ref1: string,
ref2?: string,
options?: { includeRawDiff?: boolean },
): Promise<GitDiff | undefined> {
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

+ 2
- 2
src/git/parsers/diffParser.ts Bestand weergeven

@ -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;

+ 6
- 1
src/storage.ts Bestand weergeven

@ -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<string, boolean>;

Laden…
Annuleren
Opslaan