Browse Source

Splits AI service into providers

main
Eric Amodio 1 year ago
parent
commit
57053ee1d2
9 changed files with 450 additions and 393 deletions
  1. +10
    -0
      src/@types/global.d.ts
  2. +147
    -0
      src/ai/aiProviderService.ts
  3. +231
    -0
      src/ai/openaiProvider.ts
  4. +0
    -336
      src/aiService.ts
  5. +4
    -34
      src/commands/generateCommitMessage.ts
  6. +11
    -4
      src/constants.ts
  7. +3
    -3
      src/container.ts
  8. +9
    -7
      src/git/models/commit.ts
  9. +35
    -9
      src/system/storage.ts

+ 10
- 0
src/@types/global.d.ts View File

@ -9,6 +9,9 @@ export declare global {
export type ExcludeSome<T, K extends keyof T, R> = Omit<T, K> & { [P in K]-?: Exclude<T[P], R> };
export type ExtractAll<T, U> = { [K in keyof T]: T[K] extends U ? T[K] : never };
export type ExtractPrefixes<T extends string, SEP extends string> = T extends `${infer Prefix}${SEP}${infer Rest}`
? Prefix | `${Prefix}${SEP}${ExtractPrefixes<Rest, SEP>}`
: T;
export type ExtractSome<T, K extends keyof T, R> = Omit<T, K> & { [P in K]-?: Extract<T[P], R> };
export type RequireSome<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: T[P] };
@ -18,4 +21,11 @@ export declare global {
export type NarrowRepo<T extends { repo?: unknown }> = ExcludeSome<T, 'repo', string | undefined>;
export type NarrowRepos<T extends { repos?: unknown }> = ExcludeSome<T, 'repos', string | string[] | undefined>;
export type Prefix<P extends string, T extends string, S extends string = ''> = T extends `${P}${S}${infer R}`
? R
: never;
export type StartsWith<P extends string, T extends string, S extends string = ''> = T extends `${P}${S}${string}`
? T
: never;
}

+ 147
- 0
src/ai/aiProviderService.ts View File

@ -0,0 +1,147 @@
import type { Disposable, MessageItem, ProgressOptions } from 'vscode';
import { Uri, window } from 'vscode';
import type { AIProviders } from '../constants';
import type { Container } from '../container';
import type { GitCommit } from '../git/models/commit';
import { assertsCommitHasFullDetails, 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 type { Storage } from '../system/storage';
import { OpenAIProvider } from './openaiProvider';
export interface AIProvider extends Disposable {
readonly id: AIProviders;
readonly name: string;
generateCommitMessage(diff: string, options?: { context?: string }): Promise<string | undefined>;
explainChanges(message: string, diff: string): Promise<string | undefined>;
}
export class AIProviderService implements Disposable {
private _provider: AIProvider;
constructor(private readonly container: Container) {
this._provider = new OpenAIProvider(container);
}
dispose() {
this._provider.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 confirmed = await confirmAIProviderToS(this._provider, this.container.storage);
if (!confirmed) return undefined;
if (options?.progress != null) {
return window.withProgress(options.progress, async () =>
this._provider.generateCommitMessage(diff.diff!, { context: options?.context }),
);
}
return this._provider.generateCommitMessage(diff.diff, { context: options?.context });
}
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> {
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 confirmed = await confirmAIProviderToS(this._provider, this.container.storage);
if (!confirmed) return undefined;
if (!commit.hasFullDetails()) {
await commit.ensureFullDetails();
assertsCommitHasFullDetails(commit);
}
if (options?.progress != null) {
return window.withProgress(options.progress, async () =>
this._provider.explainChanges(commit!.message!, diff.diff!),
);
}
return this._provider.explainChanges(commit.message, diff.diff);
}
}
async function confirmAIProviderToS(provider: AIProvider, storage: Storage): Promise<boolean> {
const confirmed =
storage.get(`confirm:ai:tos:${provider.id}`, false) ||
storage.getWorkspace(`confirm:ai:tos:${provider.id}`, 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 ${provider.name}. 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:ai:tos:${provider.id}`, true);
return true;
}
if (result === acceptAlways) {
void storage.store(`confirm:ai:tos:${provider.id}`, true);
return true;
}
return false;
}

+ 231
- 0
src/ai/openaiProvider.ts View File

@ -0,0 +1,231 @@
import type { Disposable, QuickInputButton } from 'vscode';
import { env, ThemeIcon, Uri, window } from 'vscode';
import { fetch } from '@env/fetch';
import type { Container } from '../container';
import { configuration } from '../system/configuration';
import type { Storage } from '../system/storage';
import { supportedInVSCodeVersion } from '../system/utils';
import type { AIProvider } from './aiProviderService';
const maxCodeCharacters = 12000;
export class OpenAIProvider implements AIProvider {
readonly id = 'openai';
readonly name = 'OpenAI';
constructor(private readonly container: Container) {}
dispose() {}
async generateCommitMessage(diff: string, options?: { context?: string }): Promise<string | undefined> {
const openaiApiKey = await getApiKey(this.container.storage);
if (openaiApiKey == null) return undefined;
const code = diff.substring(0, maxCodeCharacters);
if (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.`,
);
}
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;
}
async explainChanges(message: string, diff: string): Promise<string | undefined> {
const openaiApiKey = await getApiKey(this.container.storage);
if (openaiApiKey == null) return undefined;
const code = diff.substring(0, maxCodeCharacters);
if (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.`,
);
}
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${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 summary = completion.choices[0].message.content.trim();
return summary;
}
}
async function getApiKey(storage: Storage): Promise<string | 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;
}
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;
};
}

+ 0
- 336
src/aiService.ts View File

@ -1,336 +0,0 @@
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;
};
}

+ 4
- 34
src/commands/generateCommitMessage.ts View File

@ -1,4 +1,4 @@
import type { MessageItem, TextEditor, Uri } from 'vscode';
import type { TextEditor, Uri } from 'vscode';
import { ProgressLocation, window } from 'vscode';
import { Commands } from '../constants';
import type { Container } from '../container';
@ -7,7 +7,6 @@ import { showGenericErrorMessage } from '../messages';
import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker';
import { command, executeCoreCommand } from '../system/command';
import { Logger } from '../system/logger';
import type { Storage } from '../system/storage';
import { ActiveEditorCommand, Command, getCommandUri } from './base';
export interface GenerateCommitMessageCommandArgs {
@ -69,39 +68,10 @@ export class ResetOpenAIKeyCommand extends Command {
execute() {
void this.container.storage.deleteSecret('gitlens.openai.key');
void this.container.storage.deleteWithPrefix('confirm:ai:tos');
void this.container.storage.deleteWorkspaceWithPrefix('confirm:ai:tos');
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(
'This GitLens experimental feature automatically generates commit messages by sending the diff of your staged 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;
}

+ 11
- 4
src/constants.ts View File

@ -497,6 +497,8 @@ export type TelemetryEvents =
| 'subscription/changed'
| 'usage/track';
export type AIProviders = 'openai';
export type SecretKeys =
| `gitlens.integration.auth:${string}`
| 'gitlens.openai.key'
@ -509,6 +511,9 @@ export const enum SyncedStorageKeys {
}
export type DeprecatedGlobalStorage = {
/** @deprecated use `confirm:ai:send:openai` */
'confirm:sendToOpenAI': boolean;
} & {
/** @deprecated */
[key in `disallow:connection:${string}`]: any;
};
@ -516,7 +521,6 @@ export type DeprecatedGlobalStorage = {
export type GlobalStorage = {
avatars: [string, StoredAvatar][];
repoVisibility: [string, StoredRepoVisibilityInfo][];
'confirm:sendToOpenAI': boolean;
'deepLinks:pending': StoredDeepLinkContext;
'home:actions:completed': CompletedActions[];
'home:steps:completed': string[];
@ -540,9 +544,13 @@ export type GlobalStorage = {
'views:layout': StoredViewsLayout;
'views:welcome:visible': boolean;
'views:commitDetails:dismissed': CommitDetailsDismissed[];
} & { [key in `provider:authentication:skip:${string}`]: boolean };
} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & {
[key in `provider:authentication:skip:${string}`]: boolean;
};
export type DeprecatedWorkspaceStorage = {
/** @deprecated use `confirm:ai:send:openai` */
'confirm:sendToOpenAI': boolean;
/** @deprecated use `graph:filtersByRepo.excludeRefs` */
'graph:hiddenRefs': Record<string, StoredGraphExcludedRef>;
/** @deprecated use `views:searchAndCompare:pinned` */
@ -552,7 +560,6 @@ 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>;
@ -565,7 +572,7 @@ export type WorkspaceStorage = {
'views:searchAndCompare:keepResults': boolean;
'views:searchAndCompare:pinned': StoredPinnedItems;
'views:commitDetails:autolinksExpanded': boolean;
} & { [key in `connected:${string}`]: boolean };
} & { [key in `confirm:ai:tos:${AIProviders}`]: boolean } & { [key in `connected:${string}`]: boolean };
export type StoredViewsLayout = 'gitlens' | 'scm';
export interface Stored<T, SchemaVersion extends number = 1> {

+ 3
- 3
src/container.ts View File

@ -1,7 +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 { AIProviderService } from './ai/aiProviderService';
import { Autolinks } from './annotations/autolinks';
import { FileAnnotationController } from './annotations/fileAnnotationController';
import { LineAnnotationController } from './annotations/lineAnnotationController';
@ -207,7 +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._ai = new AIProviderService(this)));
this._disposables.push((this._fileAnnotationController = new FileAnnotationController(this)));
this._disposables.push((this._lineAnnotationController = new LineAnnotationController(this)));
@ -333,7 +333,7 @@ export class Container {
return this._actionRunners;
}
private readonly _ai: AIService;
private readonly _ai: AIProviderService;
get ai() {
return this._ai;
}

+ 9
- 7
src/git/models/commit.ts View File

@ -183,7 +183,7 @@ export class GitCommit implements GitRevisionReference {
private _etagFileSystem: number | undefined;
hasFullDetails(): this is GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'> {
hasFullDetails(): this is GitCommitWithFullDetails {
return (
this.message != null &&
this.files != null &&
@ -195,12 +195,6 @@ export class GitCommit implements GitRevisionReference {
);
}
assertsFullDetails(): asserts this is GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'> {
if (!this.hasFullDetails()) {
throw new Error(`GitCommit(${this.sha}) is not fully loaded`);
}
}
@gate()
async ensureFullDetails(): Promise<void> {
if (this.hasFullDetails()) return;
@ -664,3 +658,11 @@ export interface GitStashCommit extends GitCommit {
readonly stashName: string;
readonly number: string;
}
type GitCommitWithFullDetails = GitCommit & SomeNonNullable<GitCommit, 'message' | 'files'>;
export function assertsCommitHasFullDetails(commit: GitCommit): asserts commit is GitCommitWithFullDetails {
if (!commit.hasFullDetails()) {
throw new Error(`GitCommit(${commit.sha}) is not fully loaded`);
}
}

+ 35
- 9
src/system/storage.ts View File

@ -10,19 +10,22 @@ import type {
import { extensionPrefix } from '../constants';
import { debug } from './decorators/log';
type GlobalStorageKeys = keyof (GlobalStorage & DeprecatedGlobalStorage);
type WorkspaceStorageKeys = keyof (WorkspaceStorage & DeprecatedWorkspaceStorage);
export type StorageChangeEvent =
| {
/**
* The key of the stored value that has changed.
*/
readonly key: keyof (GlobalStorage & DeprecatedGlobalStorage);
readonly key: GlobalStorageKeys;
readonly workspace: false;
}
| {
/**
* The key of the stored value that has changed.
*/
readonly key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage);
readonly key: WorkspaceStorageKeys;
readonly workspace: true;
};
@ -51,16 +54,29 @@ export class Storage implements Disposable {
get<T extends keyof DeprecatedGlobalStorage>(key: T): DeprecatedGlobalStorage[T] | undefined;
get<T extends keyof GlobalStorage>(key: T, defaultValue: GlobalStorage[T]): GlobalStorage[T];
@debug({ logThreshold: 50 })
get(key: keyof (GlobalStorage & DeprecatedGlobalStorage), defaultValue?: unknown): unknown | undefined {
get(key: GlobalStorageKeys, defaultValue?: unknown): unknown | undefined {
return this.context.globalState.get(`${extensionPrefix}:${key}`, defaultValue);
}
@debug({ logThreshold: 250 })
async delete(key: keyof (GlobalStorage & DeprecatedGlobalStorage)): Promise<void> {
async delete(key: GlobalStorageKeys): Promise<void> {
await this.context.globalState.update(`${extensionPrefix}:${key}`, undefined);
this._onDidChange.fire({ key: key, workspace: false });
}
@debug({ logThreshold: 250 })
async deleteWithPrefix(prefix: ExtractPrefixes<GlobalStorageKeys, ':'>): Promise<void> {
const qualifiedKey = `${extensionPrefix}:${prefix}`;
const qualifiedPrefix = `${qualifiedKey}:`;
for (const key of this.context.globalState.keys() as GlobalStorageKeys[]) {
if (key === qualifiedKey || key.startsWith(qualifiedPrefix)) {
await this.context.globalState.update(key, undefined);
this._onDidChange.fire({ key: key, workspace: false });
}
}
}
@debug({ args: { 1: false }, logThreshold: 250 })
async store<T extends keyof GlobalStorage>(key: T, value: GlobalStorage[T] | undefined): Promise<void> {
await this.context.globalState.update(`${extensionPrefix}:${key}`, value);
@ -87,19 +103,29 @@ export class Storage implements Disposable {
getWorkspace<T extends keyof DeprecatedWorkspaceStorage>(key: T): DeprecatedWorkspaceStorage[T] | undefined;
getWorkspace<T extends keyof WorkspaceStorage>(key: T, defaultValue: WorkspaceStorage[T]): WorkspaceStorage[T];
@debug({ logThreshold: 25 })
getWorkspace(
key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage),
defaultValue?: unknown,
): unknown | undefined {
getWorkspace(key: WorkspaceStorageKeys, defaultValue?: unknown): unknown | undefined {
return this.context.workspaceState.get(`${extensionPrefix}:${key}`, defaultValue);
}
@debug({ logThreshold: 250 })
async deleteWorkspace(key: keyof (WorkspaceStorage & DeprecatedWorkspaceStorage)): Promise<void> {
async deleteWorkspace(key: WorkspaceStorageKeys): Promise<void> {
await this.context.workspaceState.update(`${extensionPrefix}:${key}`, undefined);
this._onDidChange.fire({ key: key, workspace: true });
}
@debug({ logThreshold: 250 })
async deleteWorkspaceWithPrefix(prefix: ExtractPrefixes<WorkspaceStorageKeys, ':'>): Promise<void> {
const qualifiedKey = `${extensionPrefix}:${prefix}`;
const qualifiedPrefix = `${qualifiedKey}:`;
for (const key of this.context.workspaceState.keys() as WorkspaceStorageKeys[]) {
if (key === qualifiedKey || key.startsWith(qualifiedPrefix)) {
await this.context.workspaceState.update(key, undefined);
this._onDidChange.fire({ key: key, workspace: true });
}
}
}
@debug({ args: { 1: false }, logThreshold: 250 })
async storeWorkspace<T extends keyof WorkspaceStorage>(
key: T,

Loading…
Cancel
Save