Browse Source

Hooks up commit explain to UI

main
Eric Amodio 1 year ago
parent
commit
e166ae1cd1
8 changed files with 428 additions and 195 deletions
  1. +336
    -0
      src/aiService.ts
  2. +15
    -179
      src/commands/generateCommitMessage.ts
  3. +12
    -5
      src/container.ts
  4. +1
    -1
      src/webviews/apps/commitDetails/commitDetails.html
  5. +14
    -0
      src/webviews/apps/commitDetails/commitDetails.scss
  6. +21
    -9
      src/webviews/apps/commitDetails/commitDetails.ts
  7. +19
    -1
      src/webviews/commitDetails/commitDetailsWebview.ts
  8. +10
    -0
      src/webviews/commitDetails/protocol.ts

+ 336
- 0
src/aiService.ts View File

@ -0,0 +1,336 @@
import type { Disposable, MessageItem, ProgressOptions, QuickInputButton } from 'vscode';
import { env, ThemeIcon, Uri, window } from 'vscode';
import { fetch } from '@env/fetch';
import type { Container } from './container';
import type { GitCommit } from './git/models/commit';
import { isCommit } from './git/models/commit';
import { uncommittedStaged } from './git/models/constants';
import type { GitRevisionReference } from './git/models/reference';
import type { Repository } from './git/models/repository';
import { isRepository } from './git/models/repository';
import { configuration } from './system/configuration';
import type { Storage } from './system/storage';
import { supportedInVSCodeVersion } from './system/utils';
const maxCodeCharacters = 12000;
export class AIService implements Disposable {
constructor(private readonly container: Container) {}
dispose() {}
public async generateCommitMessage(
repoPath: string | Uri,
options?: { context?: string; progress?: ProgressOptions },
): Promise<string | undefined>;
public async generateCommitMessage(
repository: Repository,
options?: { context?: string; progress?: ProgressOptions },
): Promise<string | undefined>;
public async generateCommitMessage(
repoOrPath: string | Uri | Repository,
options?: { context?: string; progress?: ProgressOptions },
): Promise<string | undefined> {
const repository = isRepository(repoOrPath) ? repoOrPath : this.container.git.getRepository(repoOrPath);
if (repository == null) throw new Error('Unable to find repository');
const diff = await this.container.git.getDiff(repository.uri, uncommittedStaged, undefined, {
includeRawDiff: true,
});
if (diff?.diff == null) throw new Error('No staged changes to generate a commit message from.');
const openaiApiKey = await confirmAndRequestApiKey(this.container.storage);
if (openaiApiKey == null) return undefined;
const code = diff.diff.substring(0, maxCodeCharacters);
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.`,
);
}
async function openAI() {
let customPrompt = configuration.get('experimental.generateCommitMessagePrompt');
if (!customPrompt.endsWith('.')) {
customPrompt += '.';
}
const data: OpenAIChatCompletionRequest = {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
"You are an AI programming assistant tasked with writing a meaningful commit message by summarizing code changes.\n\n- Follow the user's instructions carefully & to the letter!\n- Don't repeat yourself or make anything up!\n- Minimize any other prose.",
},
{
role: 'user',
content: `${customPrompt}\n- Avoid phrases like "this commit", "this change", etc.`,
},
],
};
if (options?.context) {
data.messages.push({
role: 'user',
content: `Use "${options.context}" to help craft the commit message.`,
});
}
data.messages.push({
role: 'user',
content: `Write a meaningful commit message for the following code changes:\n\n${code}`,
});
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) {
debugger;
throw new Error(`Unable to generate commit message: ${rsp.status}: ${rsp.statusText}`);
}
const completion: OpenAIChatCompletionResponse = await rsp.json();
const message = completion.choices[0].message.content.trim();
return message;
}
if (options?.progress != null) {
return window.withProgress(options.progress, async () => openAI());
}
return openAI();
}
async explainCommit(
repoPath: string | Uri,
sha: string,
options?: { progress?: ProgressOptions },
): Promise<string | undefined>;
async explainCommit(
commit: GitRevisionReference | GitCommit,
options?: { progress?: ProgressOptions },
): Promise<string | undefined>;
async explainCommit(
commitOrRepoPath: string | Uri | GitRevisionReference | GitCommit,
shaOrOptions?: string | { progress?: ProgressOptions },
options?: { progress?: ProgressOptions },
): Promise<string | undefined> {
const openaiApiKey = await confirmAndRequestApiKey(this.container.storage);
if (openaiApiKey == null) return undefined;
let commit: GitCommit | undefined;
if (typeof commitOrRepoPath === 'string' || commitOrRepoPath instanceof Uri) {
if (typeof shaOrOptions !== 'string' || !shaOrOptions) throw new Error('Invalid arguments provided');
commit = await this.container.git.getCommit(commitOrRepoPath, shaOrOptions);
} else {
if (typeof shaOrOptions === 'string') throw new Error('Invalid arguments provided');
commit = isCommit(commitOrRepoPath)
? commitOrRepoPath
: await this.container.git.getCommit(commitOrRepoPath.repoPath, commitOrRepoPath.ref);
options = shaOrOptions;
}
if (commit == null) throw new Error('Unable to find commit');
const diff = await this.container.git.getDiff(commit.repoPath, commit.sha, undefined, {
includeRawDiff: true,
});
if (diff?.diff == null) throw new Error('No changes found to explain.');
const code = diff.diff.substring(0, maxCodeCharacters);
if (diff.diff.length > maxCodeCharacters) {
void window.showWarningMessage(
`The diff of the commit changes had to be truncated to ${maxCodeCharacters} characters to fit within the OpenAI's limits.`,
);
}
async function openAI() {
const data: OpenAIChatCompletionRequest = {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
"You are an AI programming assistant tasked with providing a detailed explanation of a commit by summarizing the code changes while also using the commit message as additional context and framing.\n\n- Don't make anything up!",
},
{
role: 'user',
content: `Use the following user-provided commit message, which should provide some explanation to why these changes where made, when attempting to generate the rich explanation:\n\n${
commit!.message
}`,
},
{
role: 'assistant',
content: 'OK',
},
{
role: 'user',
content: `Explain the following code changes:\n\n${code}`,
},
],
};
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) {
debugger;
throw new Error(`Unable to explain commit: ${rsp.status}: ${rsp.statusText}`);
}
const completion: OpenAIChatCompletionResponse = await rsp.json();
const message = completion.choices[0].message.content.trim();
return message;
}
if (options?.progress != null) {
return window.withProgress(options.progress, async () => openAI());
}
return openAI();
}
}
async function confirmAndRequestApiKey(storage: Storage): Promise<string | undefined> {
const confirmed = await confirmSendToOpenAI(storage);
if (!confirmed) return undefined;
let openaiApiKey = await 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 the 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 = supportedInVSCodeVersion('input-prompt-links')
? 'Enter your [OpenAI API Key](https://platform.openai.com/account/api-keys "Get your OpenAI API key")'
: 'Enter your OpenAI API Key';
input.buttons = [infoButton];
input.show();
});
} finally {
input.dispose();
disposables.forEach(d => void d.dispose());
}
if (!openaiApiKey) return undefined;
void storage.storeSecret('gitlens.openai.key', openaiApiKey);
}
return openaiApiKey;
}
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(
'This GitLens experimental feature requires sending a diff of the code changes 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;
}
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;
};
}

+ 15
- 179
src/commands/generateCommitMessage.ts View File

@ -1,21 +1,15 @@
import type { Disposable, MessageItem, QuickInputButton, TextEditor } from 'vscode';
import { env, ProgressLocation, ThemeIcon, Uri, window } from 'vscode';
import { fetch } from '@env/fetch';
import type { MessageItem, TextEditor, Uri } from 'vscode';
import { ProgressLocation, window } from 'vscode';
import { Commands } 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 { command, executeCoreCommand } from '../system/command';
import { configuration } from '../system/configuration';
import { Logger } from '../system/logger';
import type { Storage } from '../system/storage';
import { supportedInVSCodeVersion } from '../system/utils';
import { ActiveEditorCommand, Command, getCommandUri } from './base';
const maxCodeCharacters = 12000;
export interface GenerateCommitMessageCommandArgs {
repoPath?: string;
}
@ -45,184 +39,26 @@ export class GenerateCommitMessageCommand extends ActiveEditorCommand {
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 the 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 = supportedInVSCodeVersion('input-prompt-links')
? 'Enter your [OpenAI API Key](https://platform.openai.com/account/api-keys "Get your OpenAI API key")'
: '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);
let customPrompt = configuration.get('experimental.generateCommitMessagePrompt');
if (!customPrompt.endsWith('.')) {
customPrompt += '.';
}
const data: OpenAIChatCompletionRequest = {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content:
"You are an AI programming assistant tasked with writing a meaningful commit message by summarizing code changes.\n\n- Follow the user's instructions carefully & to the letter!\n- Don't repeat yourself or make anything up!\n- Minimize any other prose.",
},
{
role: 'user',
content: `${customPrompt}\n- Avoid phrases like "this commit", "this change", etc.`,
},
],
};
if (currentMessage) {
data.messages.push({
role: 'user',
content: `Use "${currentMessage}" to help craft the commit message.`,
});
}
data.messages.push({
role: 'user',
content: `Write a meaningful commit message for the following code changes:\n\n${code}`,
const message = await this.container.ai.generateCommitMessage(repository, {
context: currentMessage,
progress: { location: ProgressLocation.Notification, title: 'Generating commit message...' },
});
if (message == null) return;
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('workbench.view.scm');
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.`,
);
}
void executeCoreCommand('workbench.view.scm');
scmRepo.inputBox.value = `${currentMessage ? `${currentMessage}\n\n` : ''}${message}`;
} 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;
}
if (ex instanceof Error && ex.message.startsWith('No staged changes')) {
void window.showInformationMessage('No staged changes to generate a commit message from.');
return;
}
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;
};
void showGenericErrorMessage(ex.message);
}
}
}
@command()

+ 12
- 5
src/container.ts View File

@ -1,6 +1,7 @@
import type { ConfigurationChangeEvent, Disposable, Event, ExtensionContext } from 'vscode';
import { EventEmitter, ExtensionMode } from 'vscode';
import { getSupportedGitProviders } from '@env/providers';
import { AIService } from './aiService';
import { Autolinks } from './annotations/autolinks';
import { FileAnnotationController } from './annotations/fileAnnotationController';
import { LineAnnotationController } from './annotations/lineAnnotationController';
@ -206,6 +207,7 @@ export class Container {
this._disposables.push((this._keyboard = new Keyboard()));
this._disposables.push((this._vsls = new VslsController(this)));
this._disposables.push((this._eventBus = new EventBus()));
this._disposables.push((this._ai = new AIService(this)));
this._disposables.push((this._fileAnnotationController = new FileAnnotationController(this)));
this._disposables.push((this._lineAnnotationController = new LineAnnotationController(this)));
@ -331,6 +333,11 @@ export class Container {
return this._actionRunners;
}
private readonly _ai: AIService;
get ai() {
return this._ai;
}
private _autolinks: Autolinks | undefined;
get autolinks() {
if (this._autolinks == null) {
@ -340,16 +347,16 @@ export class Container {
return this._autolinks;
}
private readonly _codeLensController: GitCodeLensController;
get codeLens() {
return this._codeLensController;
}
private readonly _branchesView: BranchesView;
get branchesView() {
return this._branchesView;
}
private readonly _codeLensController: GitCodeLensController;
get codeLens() {
return this._codeLensController;
}
private readonly _commitsView: CommitsView;
get commitsView() {
return this._commitsView;

+ 1
- 1
src/webviews/apps/commitDetails/commitDetails.html View File

@ -230,7 +230,7 @@
</webview-pane>
<webview-pane collapsable data-region="explain-pane">
<span slot="title">AI assistance</span>
<span slot="title">Explain (AI)</span>
<div class="pane-content">
<p>Let OpenAI assist in understanding the changes made with this commit.</p>

+ 14
- 0
src/webviews/apps/commitDetails/commitDetails.scss View File

@ -571,5 +571,19 @@ ul {
}
}
.ai-content--summary {
font-size: 1.3rem;
border: 1px solid var(--vscode-input-border);
background: var(--vscode-input-background);
margin-top: 1rem;
padding: 1rem 0 0 1rem;
overflow: scroll;
white-space: break-spaces;
}
.ai-content--summary.error {
border-color: var(--color-alert-errorBorder);
}
@import '../shared/codicons';
@import '../shared/glicons';

+ 21
- 9
src/webviews/apps/commitDetails/commitDetails.ts View File

@ -8,6 +8,8 @@ import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
DidExplainCommitCommandType,
ExplainCommitCommandType,
FileActionsCommandType,
messageHeadlineSplitterToken,
NavigateCommitCommandType,
@ -132,19 +134,29 @@ export class CommitDetailsApp extends App> {
}
}
onExplainCommit(e: MouseEvent) {
async onExplainCommit(e: MouseEvent) {
const el = e.target as HTMLButtonElement;
if (el.getAttribute('aria-busy') === 'true') return;
el.setAttribute('aria-busy', 'true');
setTimeout(() => {
el.removeAttribute('aria-busy');
const explanationEL = document.querySelector('[data-region="commit-explanation"]')!;
explanationEL.innerHTML = `
<p class="mb-0">No explanation available</p>
`;
explanationEL.scrollIntoView();
}, 2000);
e.preventDefault();
const result = await this.sendCommandWithCompletion(
ExplainCommitCommandType,
undefined,
DidExplainCommitCommandType,
);
el.removeAttribute('aria-busy');
const explanationEL = document.querySelector('[data-region="commit-explanation"]')!;
if (result.error) {
explanationEL.innerHTML = `<p class="ai-content--summary error scrollable">${result.error.message}</p>`;
} else if (result.summary) {
explanationEL.innerHTML = `<p class="ai-content--summary scrollable">${result.summary}</p>`;
} else {
explanationEL.innerHTML = '';
}
explanationEL.scrollIntoView();
}
onDismissBanner(e: MouseEvent) {

+ 19
- 1
src/webviews/commitDetails/commitDetailsWebview.ts View File

@ -47,11 +47,13 @@ import type { IpcMessage } from '../protocol';
import { onIpc } from '../protocol';
import type { WebviewController, WebviewProvider } from '../webviewController';
import { updatePendingContext } from '../webviewController';
import type { CommitDetails, FileActionParams, Preferences, State } from './protocol';
import type { CommitDetails, DidExplainCommitParams, FileActionParams, Preferences, State } from './protocol';
import {
AutolinkSettingsCommandType,
CommitActionsCommandType,
DidChangeNotificationType,
DidExplainCommitCommandType,
ExplainCommitCommandType,
FileActionsCommandType,
messageHeadlineSplitterToken,
NavigateCommitCommandType,
@ -382,9 +384,25 @@ export class CommitDetailsWebviewProvider implements WebviewProvider
case PreferencesCommandType.method:
onIpc(PreferencesCommandType, e, params => this.updatePreferences(params));
break;
case ExplainCommitCommandType.method:
onIpc(ExplainCommitCommandType, e, () => this.explainCommit(e.completionId));
}
}
private async explainCommit(completionId?: string) {
let params: DidExplainCommitParams;
try {
const summary = await this.container.ai.explainCommit(this._context.commit!, {
progress: { location: { viewId: this.host.id } },
});
params = { summary: summary };
} catch (ex) {
debugger;
params = { error: ex.message };
}
void this.host.notify(DidExplainCommitCommandType, params, completionId);
}
private navigateStack(direction: 'back' | 'forward') {
const commit = this._commitStack.navigate(direction);
if (commit == null) return;

+ 10
- 0
src/webviews/commitDetails/protocol.ts View File

@ -82,6 +82,8 @@ export const PickCommitCommandType = new IpcCommandType('commit/pickC
export const SearchCommitCommandType = new IpcCommandType<undefined>('commit/searchCommit');
export const AutolinkSettingsCommandType = new IpcCommandType<undefined>('commit/autolinkSettings');
export const ExplainCommitCommandType = new IpcCommandType<undefined>('commit/explain');
export interface PinParams {
pin: boolean;
}
@ -115,3 +117,11 @@ export type DidChangeRichStateParams = {
export const DidChangeRichStateNotificationType = new IpcNotificationType<DidChangeRichStateParams>(
'commit/didChange/rich',
);
export type DidExplainCommitParams =
| {
summary: string | undefined;
error?: undefined;
}
| { error: { message: string } };
export const DidExplainCommitCommandType = new IpcNotificationType<DidExplainCommitParams>('commit/didExplain');

Loading…
Cancel
Save