diff --git a/contributions.json b/contributions.json index 6c64aecfc6c7d..bad9d551595c0 100644 --- a/contributions.json +++ b/contributions.json @@ -23,6 +23,19 @@ ] } }, + "gitlens.ai.explainBranch": { + "label": "Explain Branch (Preview)...", + "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled", + "menus": { + "view/item/context": [ + { + "when": "viewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "3_gitlens_ai", + "order": 1 + } + ] + } + }, "gitlens.ai.explainCommit": { "label": "Explain Commit...", "commandPalette": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", @@ -1374,6 +1387,18 @@ ] } }, + "gitlens.graph.ai.explainBranch": { + "label": "Explain Branch (Preview)", + "menus": { + "webview/context": [ + { + "when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_4", + "order": 100 + } + ] + } + }, "gitlens.graph.ai.explainCommit": { "label": "Explain Commit", "menus": { diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 5e7f33b77a4c1..a63d60f2f0ac7 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -114,7 +114,7 @@ ```typescript { - 'changeType': 'wip' | 'stash' | 'commit' | 'draft-stash' | 'draft-patch' | 'draft-suggested_pr_change', + 'changeType': 'wip' | 'stash' | 'commit' | 'branch' | 'draft-stash' | 'draft-patch' | 'draft-suggested_pr_change', 'config.largePromptThreshold': number, 'config.usedCustomInstructions': boolean, 'duration': number, @@ -2063,7 +2063,7 @@ or 'context.webview.type': string, 'period': 'all' | `${number}|D` | `${number}|M` | `${number}|Y`, 'showAllBranches': boolean, - 'sliceBy': 'author' | 'branch' + 'sliceBy': 'branch' | 'author' } ``` diff --git a/package.json b/package.json index 33337f77d6aca..a544bda4bb63a 100644 --- a/package.json +++ b/package.json @@ -6074,6 +6074,11 @@ "category": "GitLens", "icon": "$(person-add)" }, + { + "command": "gitlens.ai.explainBranch", + "title": "Explain Branch (Preview)...", + "category": "GitLens" + }, { "command": "gitlens.ai.explainCommit", "title": "Explain Commit...", @@ -6624,6 +6629,10 @@ "title": "Add as Co-author", "icon": "$(person-add)" }, + { + "command": "gitlens.graph.ai.explainBranch", + "title": "Explain Branch (Preview)" + }, { "command": "gitlens.graph.ai.explainCommit", "title": "Explain Commit" @@ -10409,6 +10418,10 @@ "command": "gitlens.addAuthors", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders" }, + { + "command": "gitlens.ai.explainBranch", + "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled" + }, { "command": "gitlens.ai.explainCommit", "when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled" @@ -10821,6 +10834,10 @@ "command": "gitlens.graph.addAuthor", "when": "false" }, + { + "command": "gitlens.graph.ai.explainBranch", + "when": "false" + }, { "command": "gitlens.graph.ai.explainCommit", "when": "false" @@ -16388,6 +16405,11 @@ "when": "viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && listMultiSelection", "group": "2_gitlens_quickopen@1" }, + { + "command": "gitlens.ai.explainBranch", + "when": "viewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "3_gitlens_ai@1" + }, { "command": "gitlens.views.openChangedFileDiffsWithMergeBase", "when": "viewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/ && !listMultiSelection", @@ -20060,6 +20082,11 @@ "when": "webviewItem =~ /gitlens:(branch|tag)\\b/ && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", "group": "1_gitlens_actions_3@100" }, + { + "command": "gitlens.graph.ai.explainBranch", + "when": "webviewItem =~ /gitlens:branch\\b/ && !listMultiSelection && !gitlens:readonly && !gitlens:untrusted && gitlens:gk:organization:ai:enabled && config.gitlens.ai.enabled", + "group": "1_gitlens_actions_4@100" + }, { "command": "gitlens.graph.openBranchOnRemote", "when": "webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/ && gitlens:repos:withRemotes", diff --git a/src/commands.ts b/src/commands.ts index 3bdb90d9413ce..9682333c5213f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -21,6 +21,7 @@ import './commands/diffWithRevision'; import './commands/diffWithRevisionFrom'; import './commands/diffWithWorking'; import './commands/externalDiff'; +import './commands/explainBranch'; import './commands/explainCommit'; import './commands/explainStash'; import './commands/explainWip'; diff --git a/src/commands/explainBranch.ts b/src/commands/explainBranch.ts new file mode 100644 index 0000000000000..efcfdf9a30bce --- /dev/null +++ b/src/commands/explainBranch.ts @@ -0,0 +1,166 @@ +import type { CancellationToken, TextEditor, Uri } from 'vscode'; +import { ProgressLocation } from 'vscode'; +import type { Container } from '../container'; +import { GitUri } from '../git/gitUri'; +import type { GitBranch } from '../git/models/branch'; +import { showGenericErrorMessage } from '../messages'; +import type { AIExplainSource } from '../plus/ai/aiProviderService'; +import { prepareCompareDataForAIRequest } from '../plus/ai/aiProviderService'; +import { ReferencesQuickPickIncludes, showReferencePicker } from '../quickpicks/referencePicker'; +import { getBestRepositoryOrShowPicker } from '../quickpicks/repositoryPicker'; +import { command } from '../system/-webview/command'; +import { showMarkdownPreview } from '../system/-webview/markdown'; +import { Logger } from '../system/logger'; +import { getNodeRepoPath } from '../views/nodes/abstract/viewNode'; +import { GlCommandBase } from './commandBase'; +import { getCommandUri } from './commandBase.utils'; +import type { CommandContext } from './commandContext'; +import { isCommandContextViewNodeHasBranch } from './commandContext.utils'; + +export interface ExplainBranchCommandArgs { + repoPath?: string | Uri; + ref?: string; + source?: AIExplainSource; +} + +@command() +export class ExplainBranchCommand extends GlCommandBase { + constructor(private readonly container: Container) { + super('gitlens.ai.explainBranch'); + } + + protected override preExecute(context: CommandContext, args?: ExplainBranchCommandArgs): Promise { + if (isCommandContextViewNodeHasBranch(context)) { + args = { ...args }; + args.repoPath = args.repoPath ?? getNodeRepoPath(context.node); + args.ref = args.ref ?? context.node.branch.ref; + args.source = args.source ?? { source: 'view', type: 'branch' }; + } + + return this.execute(context.editor, context.uri, args); + } + + async execute(editor?: TextEditor, uri?: Uri, args?: ExplainBranchCommandArgs): Promise { + args = { ...args }; + + let repository; + if (args?.repoPath != null) { + repository = this.container.git.getRepository(args.repoPath); + } else { + uri = getCommandUri(uri, editor); + const gitUri = uri != null ? await GitUri.fromUri(uri) : undefined; + repository = await getBestRepositoryOrShowPicker( + gitUri, + editor, + 'Explain Branch', + 'Choose which repository to explain a branch from', + ); + } + + if (repository == null) return; + + try { + // Clarifying the head branch + if (args.ref == null) { + // If no ref is provided, show a picker to select a branch + const pick = await showReferencePicker( + repository.path, + 'Explain Branch', + 'Choose a branch to explain', + { + include: ReferencesQuickPickIncludes.Branches, + sort: { branches: { current: true } }, + }, + ); + if (pick?.ref == null) return; + args.ref = pick.ref; + } + + // Get the branch + const branch = await repository.git.branches().getBranch(args.ref); + if (branch == null) { + void showGenericErrorMessage('Unable to find the specified branch'); + return; + } + + // Clarifying the base branch + const baseBranchName = await getMergeTarget(this.container, branch); + const baseBranch = await repository.git.branches().getBranch(baseBranchName); + if (!baseBranch) { + void showGenericErrorMessage(`Unable to find the base branch for ${branch.name}.`); + return; + } + + // Get the diff between the branch and its upstream or base + const compareData = await prepareCompareDataForAIRequest(repository, branch.ref, baseBranch.ref, { + reportNoDiffService: () => void showGenericErrorMessage('Unable to get diff service'), + reportNoCommitsService: () => void showGenericErrorMessage('Unable to get commits service'), + reportNoChanges: () => void showGenericErrorMessage('No changes found to explain'), + }); + + if (compareData == null) { + return; + } + + const { diff, logMessages } = compareData; + + const changes = { + diff: diff, + message: `Changes in branch ${branch.name} + that is ahead of its target by number of commits with the following messages:\n\n + + ${logMessages} + + `, + }; + + // Call the AI service to explain the changes + const result = await this.container.ai.explainChanges( + changes, + args.source ?? { source: 'commandPalette', type: 'commit' }, + { + progress: { location: ProgressLocation.Notification, title: 'Explaining branch changes...' }, + }, + ); + + if (result == null) { + void showGenericErrorMessage(`Unable to explain branch ${branch.name}`); + return; + } + + const content = `# Branch Summary\n\n> Generated by ${result.model.name}\n\n## ${branch.name}\n\n${result?.parsed.summary}\n\n${result?.parsed.body}`; + + void showMarkdownPreview(content); + } catch (ex) { + Logger.error(ex, 'ExplainBranchCommand', 'execute'); + void showGenericErrorMessage('Unable to explain branch'); + } + } +} + +async function getMergeTarget( + container: Container, + branch: GitBranch, + options?: { cancellation?: CancellationToken }, +): Promise { + const localValue = await container.git.branches(branch.repoPath).getMergeTargetBranchName?.(branch); + if (localValue) { + return localValue; + } + return getIntegrationDefaultBranchName(container, branch.repoPath, options); +} + +// This is similar to what we have in changeBranchMergeTarget.ts +// what is a proper utils files to put it to? +async function getIntegrationDefaultBranchName( + container: Container, + repoPath: string, + options?: { cancellation?: CancellationToken }, +): Promise { + const remote = await container.git.remotes(repoPath).getBestRemoteWithIntegration(); + if (remote == null) return undefined; + + const integration = await remote.getIntegration(); + const defaultBranch = await integration?.getDefaultBranch?.(remote.provider.repoDesc, options); + return defaultBranch && `${remote.name}/${defaultBranch?.name}`; +} diff --git a/src/constants.commands.generated.ts b/src/constants.commands.generated.ts index cfb9a3396f57b..9ec376ffa99ae 100644 --- a/src/constants.commands.generated.ts +++ b/src/constants.commands.generated.ts @@ -23,6 +23,7 @@ export type ContributedCommands = | 'gitlens.copyRemoteRepositoryUrl' | 'gitlens.ghpr.views.openOrCreateWorktree' | 'gitlens.graph.addAuthor' + | 'gitlens.graph.ai.explainBranch' | 'gitlens.graph.ai.explainCommit' | 'gitlens.graph.ai.explainStash' | 'gitlens.graph.ai.explainWip' @@ -614,6 +615,7 @@ export type ContributedCommands = export type ContributedPaletteCommands = | 'gitlens.addAuthors' + | 'gitlens.ai.explainBranch' | 'gitlens.ai.explainCommit' | 'gitlens.ai.explainStash' | 'gitlens.ai.explainWip' diff --git a/src/constants.commands.ts b/src/constants.commands.ts index 6fe9520a72d54..202dc9191133f 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -55,6 +55,7 @@ type InternalHomeWebviewCommands = | 'gitlens.home.continuePausedOperation' | 'gitlens.home.abortPausedOperation' | 'gitlens.home.explainWip' + | 'gitlens.home.ai.explainBranch' | 'gitlens.home.openRebaseEditor'; type InternalHomeWebviewViewCommands = diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 6a6c37d32c877..94853632b7b8c 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -336,7 +336,7 @@ interface AIEventDataBase { interface AIExplainEvent extends AIEventDataBase { type: 'change'; - changeType: 'wip' | 'stash' | 'commit' | `draft-${'patch' | 'stash' | 'suggested_pr_change'}`; + changeType: 'wip' | 'stash' | 'commit' | 'branch' | `draft-${'patch' | 'stash' | 'suggested_pr_change'}`; } export interface AIGenerateCommitEventData extends AIEventDataBase { diff --git a/src/env/node/git/sub-providers/branches.ts b/src/env/node/git/sub-providers/branches.ts index 85175944d953e..b6b56e771f50d 100644 --- a/src/env/node/git/sub-providers/branches.ts +++ b/src/env/node/git/sub-providers/branches.ts @@ -707,6 +707,21 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider { await this.provider.config.setConfig(repoPath, mergeTargetConfigKey, target); } + async getMergeTargetBranchName(repoPath: string, branch: GitBranch): Promise { + const [baseResult, defaultResult, targetResult, userTargetResult] = await Promise.allSettled([ + this.getBaseBranchName?.(repoPath, branch.name), + this.getDefaultBranchName(repoPath, branch.getRemoteName()), + this.getTargetBranchName?.(repoPath, branch.name), + this.getUserMergeTargetBranchName?.(repoPath, branch.name), + ]); + + const baseBranchName = getSettledValue(baseResult); + const defaultBranchName = getSettledValue(defaultResult); + const targetMaybeResult = getSettledValue(targetResult); + const userTargetBranchName = getSettledValue(userTargetResult); + return userTargetBranchName || targetMaybeResult || baseBranchName || defaultBranchName; + } + private async getBaseBranchFromReflog( repoPath: string, ref: string, diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts index 2cd7e5af5f207..9bf3d23467231 100644 --- a/src/git/gitProvider.ts +++ b/src/git/gitProvider.ts @@ -279,6 +279,7 @@ export interface GitBranchesSubProvider { setTargetBranchName?(repoPath: string, ref: string, target: string): Promise; getUserMergeTargetBranchName?(repoPath: string, ref: string): Promise; setUserMergeTargetBranchName?(repoPath: string, ref: string, target: string | undefined): Promise; + getMergeTargetBranchName?(repoPath: string, branch: GitBranch): Promise; renameBranch?(repoPath: string, oldName: string, newName: string): Promise; } diff --git a/src/plus/ai/aiProviderService.ts b/src/plus/ai/aiProviderService.ts index 20545efd21459..de2c76eb8c27d 100644 --- a/src/plus/ai/aiProviderService.ts +++ b/src/plus/ai/aiProviderService.ts @@ -641,31 +641,21 @@ export class AIProviderService implements Disposable { const result = await this.sendRequest( 'generate-create-pullRequest', async (model, reporting, cancellation, maxInputTokens, retries) => { - const [diffResult, logResult] = await Promise.allSettled([ - repo.git.diff().getDiff?.(headRef, baseRef, { notation: '...' }), - repo.git.commits().getLog(`${baseRef}..${headRef}`), - ]); - - const diff = getSettledValue(diffResult); - const log = getSettledValue(logResult); + const compareData = await prepareCompareDataForAIRequest(repo, headRef, baseRef, { + cancellation: cancellation, + }); - if (!diff?.contents || !log?.commits?.size) { + if (!compareData?.diff || !compareData?.logMessages) { throw new AINoRequestDataError('No changes to generate a pull request from.'); } - if (cancellation.isCancellationRequested) throw new CancellationError(); - - const commitMessages: string[] = []; - for (const commit of [...log.commits.values()].sort((a, b) => a.date.getTime() - b.date.getTime())) { - commitMessages.push(commit.message ?? commit.summary); - } - + const { diff, logMessages } = compareData; const { prompt } = await this.getPrompt( 'generate-create-pullRequest', model, { - diff: diff.contents, - data: commitMessages.join('\n'), + diff: diff, + data: logMessages, context: options?.context, instructions: configuration.get('ai.generateCreatePullRequest.customInstructions'), }, @@ -1405,3 +1395,60 @@ function isPrimaryAIProvider(provider: AIProviders): provider is AIPrimaryProvid function isPrimaryAIProviderModel(model: AIModel): model is AIModel { return isPrimaryAIProvider(model.provider.id); } + +export async function prepareCompareDataForAIRequest( + repo: Repository, + headRef: string, + baseRef: string, + options?: { + cancellation?: CancellationToken; + reportNoDiffService?: () => void; + reportNoCommitsService?: () => void; + reportNoChanges?: () => void; + }, +): Promise<{ diff: string; logMessages: string } | undefined> { + const { cancellation, reportNoDiffService, reportNoCommitsService, reportNoChanges } = options ?? {}; + const diffService = repo.git.diff(); + if (diffService?.getDiff === undefined) { + if (reportNoDiffService) { + reportNoDiffService(); + return; + } + } + + const commitsService = repo.git.commits(); + if (commitsService?.getLog === undefined) { + if (reportNoCommitsService) { + reportNoCommitsService(); + return; + } + } + + const [diffResult, logResult] = await Promise.allSettled([ + diffService.getDiff?.(headRef, baseRef, { notation: '...' }), + commitsService.getLog(`${baseRef}..${headRef}`), + ]); + const diff = getSettledValue(diffResult); + const log = getSettledValue(logResult); + + if (!diff?.contents || !log?.commits?.size) { + reportNoChanges?.(); + return undefined; + } + + if (cancellation?.isCancellationRequested) throw new CancellationError(); + + const commitMessages: string[] = []; + for (const commit of [...log.commits.values()].sort((a, b) => a.date.getTime() - b.date.getTime())) { + const message = commit.message ?? commit.summary; + if (message) { + commitMessages.push( + `\n${ + commit.message ?? commit.summary + }\n`, + ); + } + } + + return { diff: diff.contents, logMessages: commitMessages.join('\n\n') }; +} diff --git a/src/webviews/apps/plus/home/components/branch-card.ts b/src/webviews/apps/plus/home/components/branch-card.ts index bbb81b749298b..5f88d026f1913 100644 --- a/src/webviews/apps/plus/home/components/branch-card.ts +++ b/src/webviews/apps/plus/home/components/branch-card.ts @@ -1000,15 +1000,30 @@ export class GlBranchCard extends GlBranchCardBase { href=${this.createCommandLink('gitlens.home.openWorktree')} >`, ); - // add explain WIP - if (this.wip?.workingTreeState != null) { + + const hasWip = + this.wip?.workingTreeState != null + ? this.wip.workingTreeState.added + + this.wip.workingTreeState.changed + + this.wip.workingTreeState.deleted > + 0 + : false; + if (hasWip) { actions.push( html``, ); + } else { + actions.push( + html``, + ); } } else { actions.push( @@ -1018,6 +1033,13 @@ export class GlBranchCard extends GlBranchCardBase { href=${this.createCommandLink('gitlens.home.switchToBranch')} >`, ); + actions.push( + html``, + ); } // branch actions diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 0ec7aca2afdb2..96ac21ead3a1a 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -354,6 +354,7 @@ export class HomeWebviewProvider implements WebviewProvider