diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 304146b..e35c9cf 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -9,6 +9,9 @@ export declare global { export type ExcludeSome = Omit & { [P in K]-?: Exclude }; export type ExtractAll = { [K in keyof T]: T[K] extends U ? T[K] : never }; + export type ExtractPrefixes = T extends `${infer Prefix}${SEP}${infer Rest}` + ? Prefix | `${Prefix}${SEP}${ExtractPrefixes}` + : T; export type ExtractSome = Omit & { [P in K]-?: Extract }; export type RequireSome = Omit & { [P in K]-?: T[P] }; @@ -18,4 +21,11 @@ export declare global { export type NarrowRepo = ExcludeSome; export type NarrowRepos = ExcludeSome; + + export type Prefix

= T extends `${P}${S}${infer R}` + ? R + : never; + export type StartsWith

= T extends `${P}${S}${string}` + ? T + : never; } diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts new file mode 100644 index 0000000..78da6b3 --- /dev/null +++ b/src/ai/aiProviderService.ts @@ -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; + explainChanges(message: string, diff: string): Promise; +} + +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; + public async generateCommitMessage( + repository: Repository, + options?: { context?: string; progress?: ProgressOptions }, + ): Promise; + public async generateCommitMessage( + repoOrPath: string | Uri | Repository, + options?: { context?: string; progress?: ProgressOptions }, + ): Promise { + 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; + async explainCommit( + commit: GitRevisionReference | GitCommit, + options?: { progress?: ProgressOptions }, + ): Promise; + async explainCommit( + commitOrRepoPath: string | Uri | GitRevisionReference | GitCommit, + shaOrOptions?: string | { progress?: ProgressOptions }, + options?: { progress?: ProgressOptions }, + ): Promise { + 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 { + 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; +} diff --git a/src/ai/openaiProvider.ts b/src/ai/openaiProvider.ts new file mode 100644 index 0000000..92c3829 --- /dev/null +++ b/src/ai/openaiProvider.ts @@ -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 { + 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 { + 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 { + 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(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; + }; +} diff --git a/src/aiService.ts b/src/aiService.ts deleted file mode 100644 index 9c099f7..0000000 --- a/src/aiService.ts +++ /dev/null @@ -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; - public async generateCommitMessage( - repository: Repository, - options?: { context?: string; progress?: ProgressOptions }, - ): Promise; - public async generateCommitMessage( - repoOrPath: string | Uri | Repository, - options?: { context?: string; progress?: ProgressOptions }, - ): Promise { - 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; - async explainCommit( - commit: GitRevisionReference | GitCommit, - options?: { progress?: ProgressOptions }, - ): Promise; - async explainCommit( - commitOrRepoPath: string | Uri | GitRevisionReference | GitCommit, - shaOrOptions?: string | { progress?: ProgressOptions }, - options?: { progress?: ProgressOptions }, - ): Promise { - 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 { - 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(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 { - 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; - }; -} diff --git a/src/commands/generateCommitMessage.ts b/src/commands/generateCommitMessage.ts index cc7ba63..92fbcc1 100644 --- a/src/commands/generateCommitMessage.ts +++ b/src/commands/generateCommitMessage.ts @@ -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 { - 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; -} diff --git a/src/constants.ts b/src/constants.ts index 31d919e..18e7b04 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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; /** @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; @@ -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 { diff --git a/src/container.ts b/src/container.ts index 3213371..b68e9c0 100644 --- a/src/container.ts +++ b/src/container.ts @@ -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; } diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index 81cf4d9..840281a 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -183,7 +183,7 @@ export class GitCommit implements GitRevisionReference { private _etagFileSystem: number | undefined; - hasFullDetails(): this is GitCommit & SomeNonNullable { + 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 { - if (!this.hasFullDetails()) { - throw new Error(`GitCommit(${this.sha}) is not fully loaded`); - } - } - @gate() async ensureFullDetails(): Promise { if (this.hasFullDetails()) return; @@ -664,3 +658,11 @@ export interface GitStashCommit extends GitCommit { readonly stashName: string; readonly number: string; } + +type GitCommitWithFullDetails = GitCommit & SomeNonNullable; + +export function assertsCommitHasFullDetails(commit: GitCommit): asserts commit is GitCommitWithFullDetails { + if (!commit.hasFullDetails()) { + throw new Error(`GitCommit(${commit.sha}) is not fully loaded`); + } +} diff --git a/src/system/storage.ts b/src/system/storage.ts index 97f439a..0531544 100644 --- a/src/system/storage.ts +++ b/src/system/storage.ts @@ -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(key: T): DeprecatedGlobalStorage[T] | undefined; get(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 { + async delete(key: GlobalStorageKeys): Promise { await this.context.globalState.update(`${extensionPrefix}:${key}`, undefined); this._onDidChange.fire({ key: key, workspace: false }); } + @debug({ logThreshold: 250 }) + async deleteWithPrefix(prefix: ExtractPrefixes): Promise { + 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(key: T, value: GlobalStorage[T] | undefined): Promise { await this.context.globalState.update(`${extensionPrefix}:${key}`, value); @@ -87,19 +103,29 @@ export class Storage implements Disposable { getWorkspace(key: T): DeprecatedWorkspaceStorage[T] | undefined; getWorkspace(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 { + async deleteWorkspace(key: WorkspaceStorageKeys): Promise { await this.context.workspaceState.update(`${extensionPrefix}:${key}`, undefined); this._onDidChange.fire({ key: key, workspace: true }); } + @debug({ logThreshold: 250 }) + async deleteWorkspaceWithPrefix(prefix: ExtractPrefixes): Promise { + 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( key: T,