Skip to content

Commit d221575

Browse files
committed
Generates changelog from comparison
1 parent 06b0cee commit d221575

13 files changed

+449
-13
lines changed

contributions.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6881,6 +6881,24 @@
68816881
]
68826882
}
68836883
},
6884+
"gitlens.views.generateChangelog": {
6885+
"label": "Generate Changelog",
6886+
"icon": "$(sparkle)",
6887+
"menus": {
6888+
"view/item/context": [
6889+
{
6890+
"when": "viewItem =~ /gitlens:compare:results:commits\\b/",
6891+
"group": "inline",
6892+
"order": 98
6893+
},
6894+
{
6895+
"when": "viewItem =~ /gitlens:compare:results:commits\\b/ && !listMultiSelection",
6896+
"group": "4_gitlens_actions",
6897+
"order": 2
6898+
}
6899+
]
6900+
}
6901+
},
68846902
"gitlens.views.graph.openInTab": {
68856903
"label": "Open in Editor",
68866904
"icon": "$(link-external)",

docs/telemetry-events.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,23 @@ or
179179
}
180180
```
181181

182+
or
183+
184+
```typescript
185+
{
186+
'duration': number,
187+
'failed.error': string,
188+
'failed.reason': 'user-declined' | 'user-cancelled' | 'error',
189+
'input.length': number,
190+
'model.id': string,
191+
'model.provider.id': 'anthropic' | 'deepseek' | 'gemini' | 'github' | 'huggingface' | 'openai' | 'vscode' | 'xai',
192+
'model.provider.name': string,
193+
'output.length': number,
194+
'retry.count': number,
195+
'type': 'changelog'
196+
}
197+
```
198+
182199
### associateIssueWithBranch/action
183200

184201
> Sent when the user chooses to manage integrations

package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3919,6 +3919,16 @@
39193919
"preview"
39203920
]
39213921
},
3922+
"gitlens.ai.generateChangelog.customInstructions": {
3923+
"type": "string",
3924+
"default": null,
3925+
"markdownDescription": "Specifies custom instructions to provide to the AI provider when generating a changelog from a set of changes",
3926+
"scope": "window",
3927+
"order": 110,
3928+
"tags": [
3929+
"preview"
3930+
]
3931+
},
39223932
"gitlens.ai.generateCommitMessage.customInstructions": {
39233933
"type": "string",
39243934
"default": null,
@@ -8169,6 +8179,11 @@
81698179
"command": "gitlens.views.fileHistory.setShowMergeCommitsOn",
81708180
"title": "Show Merge Commits"
81718181
},
8182+
{
8183+
"command": "gitlens.views.generateChangelog",
8184+
"title": "Generate Changelog",
8185+
"icon": "$(sparkle)"
8186+
},
81728187
{
81738188
"command": "gitlens.views.graph.openInTab",
81748189
"title": "Open in Editor",
@@ -11791,6 +11806,10 @@
1179111806
"command": "gitlens.views.fileHistory.setShowMergeCommitsOn",
1179211807
"when": "false"
1179311808
},
11809+
{
11810+
"command": "gitlens.views.generateChangelog",
11811+
"when": "false"
11812+
},
1179411813
{
1179511814
"command": "gitlens.views.graph.openInTab",
1179611815
"when": "false"
@@ -15921,6 +15940,11 @@
1592115940
"when": "viewItem =~ /gitlens:compare:results(?!:)\\b/ && !listMultiSelection",
1592215941
"group": "2_gitlens_quickopen@2"
1592315942
},
15943+
{
15944+
"command": "gitlens.views.generateChangelog",
15945+
"when": "viewItem =~ /gitlens:compare:results:commits\\b/",
15946+
"group": "inline@98"
15947+
},
1592415948
{
1592515949
"command": "gitlens.openComparisonOnRemote",
1592615950
"when": "viewItem =~ /gitlens:compare:results:commits\\b/ && gitlens:repos:withRemotes",
@@ -15932,6 +15956,11 @@
1593215956
"when": "viewItem =~ /gitlens:compare:results:commits\\b/ && !listMultiSelection && gitlens:repos:withRemotes",
1593315957
"group": "3_gitlens_explore@0"
1593415958
},
15959+
{
15960+
"command": "gitlens.views.generateChangelog",
15961+
"when": "viewItem =~ /gitlens:compare:results:commits\\b/ && !listMultiSelection",
15962+
"group": "4_gitlens_actions@2"
15963+
},
1593515964
{
1593615965
"command": "gitlens.inviteToLiveShare",
1593715966
"when": "viewItem =~ /gitlens:contributor\\b(?!.*?\\b\\+current\\b)/ && gitlens:vsls && gitlens:vsls != guest",

src/ai/aiProviderService.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { configuration } from '../system/-webview/configuration';
1515
import type { Storage } from '../system/-webview/storage';
1616
import { supportedInVSCodeVersion } from '../system/-webview/vscode';
1717
import { formatNumeric } from '../system/date';
18+
import type { Lazy } from '../system/lazy';
1819
import type { Deferred } from '../system/promise';
1920
import { getSettledValue } from '../system/promise';
2021
import { getPossessiveForm } from '../system/string';
@@ -33,6 +34,11 @@ export interface AIResult {
3334
body: string;
3435
}
3536

37+
export interface AIGenerateChangelogChange {
38+
message: string;
39+
issues: { id: string; url: string; title: string | undefined }[];
40+
}
41+
3642
export interface AIModel<Provider extends AIProviders = AIProviders, Model extends string = string> {
3743
readonly id: Model;
3844
readonly name: string;
@@ -95,6 +101,12 @@ export interface AIProvider<Provider extends AIProviders = AIProviders> extends
95101
reporting: TelemetryEvents['ai/generate'],
96102
options?: { cancellation?: CancellationToken; context?: string; codeSuggestion?: boolean },
97103
): Promise<string | undefined>;
104+
generateChangelog(
105+
model: AIModel<Provider>,
106+
changes: AIGenerateChangelogChange[],
107+
reporting: TelemetryEvents['ai/generate'],
108+
options?: { cancellation?: CancellationToken },
109+
): Promise<string | undefined>;
98110
}
99111

100112
export class AIProviderService implements Disposable {
@@ -231,7 +243,7 @@ export class AIProviderService implements Disposable {
231243
const changes: string | undefined = await this.getChanges(changesOrRepo);
232244
if (changes == null) return undefined;
233245

234-
const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage);
246+
const { confirmed, model } = await getModelAndConfirmAIProviderToS('diff', this, this.container.storage);
235247
if (model == null) {
236248
options?.generating?.cancel();
237249
return undefined;
@@ -315,7 +327,7 @@ export class AIProviderService implements Disposable {
315327
const changes: string | undefined = await this.getChanges(changesOrRepo);
316328
if (changes == null) return undefined;
317329

318-
const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage);
330+
const { confirmed, model } = await getModelAndConfirmAIProviderToS('diff', this, this.container.storage);
319331
if (model == null) {
320332
options?.generating?.cancel();
321333
return undefined;
@@ -400,7 +412,7 @@ export class AIProviderService implements Disposable {
400412
return undefined;
401413
}
402414

403-
const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage);
415+
const { confirmed, model } = await getModelAndConfirmAIProviderToS('diff', this, this.container.storage);
404416
if (model == null) {
405417
options?.generating?.cancel();
406418
return undefined;
@@ -470,6 +482,73 @@ export class AIProviderService implements Disposable {
470482
}
471483
}
472484

485+
async generateChangelog(
486+
changes: Lazy<Promise<AIGenerateChangelogChange[]>>,
487+
sourceContext: { source: Sources },
488+
options?: { cancellation?: CancellationToken; progress?: ProgressOptions },
489+
): Promise<string | undefined> {
490+
const { confirmed, model } = await getModelAndConfirmAIProviderToS('data', this, this.container.storage);
491+
if (model == null) return undefined;
492+
493+
const payload: TelemetryEvents['ai/generate'] = {
494+
type: 'changelog',
495+
'model.id': model.id,
496+
'model.provider.id': model.provider.id,
497+
'model.provider.name': model.provider.name,
498+
'retry.count': 0,
499+
};
500+
const source: Parameters<TelemetryService['sendEvent']>[2] = { source: sourceContext.source };
501+
502+
if (!confirmed) {
503+
this.container.telemetry.sendEvent('ai/generate', { ...payload, 'failed.reason': 'user-declined' }, source);
504+
return undefined;
505+
}
506+
507+
if (options?.cancellation?.isCancellationRequested) {
508+
this.container.telemetry.sendEvent(
509+
'ai/generate',
510+
{ ...payload, 'failed.reason': 'user-cancelled' },
511+
source,
512+
);
513+
return undefined;
514+
}
515+
516+
const promise = changes.value.then(changes =>
517+
this._provider!.generateChangelog(model, changes, payload, {
518+
cancellation: options?.cancellation,
519+
}),
520+
);
521+
522+
const start = Date.now();
523+
try {
524+
const result = await (options?.progress != null
525+
? window.withProgress(
526+
{ ...options.progress, title: `Generating changelog with ${model.name}...` },
527+
() => promise,
528+
)
529+
: promise);
530+
531+
payload['output.length'] = result?.length;
532+
this.container.telemetry.sendEvent('ai/generate', { ...payload, duration: Date.now() - start }, source);
533+
534+
return result;
535+
} catch (ex) {
536+
this.container.telemetry.sendEvent(
537+
'ai/generate',
538+
{
539+
...payload,
540+
duration: Date.now() - start,
541+
...(ex instanceof CancellationError
542+
? { 'failed.reason': 'user-cancelled' }
543+
: { 'failed.reason': 'error', 'failed.error': String(ex) }),
544+
},
545+
source,
546+
);
547+
548+
throw ex;
549+
}
550+
}
551+
473552
private async getChanges(
474553
changesOrRepo: string | string[] | Repository,
475554
options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions },
@@ -501,7 +580,7 @@ export class AIProviderService implements Disposable {
501580
const diff = await this.container.git.getDiff(commitOrRevision.repoPath, commitOrRevision.ref);
502581
if (!diff?.contents) throw new Error('No changes found to explain.');
503582

504-
const { confirmed, model } = await getModelAndConfirmAIProviderToS(this, this.container.storage);
583+
const { confirmed, model } = await getModelAndConfirmAIProviderToS('diff', this, this.container.storage);
505584
if (model == null) return undefined;
506585

507586
const payload: TelemetryEvents['ai/explain'] = {
@@ -632,6 +711,7 @@ export class AIProviderService implements Disposable {
632711
}
633712

634713
async function getModelAndConfirmAIProviderToS(
714+
confirmationType: 'data' | 'diff',
635715
service: AIProviderService,
636716
storage: Storage,
637717
): Promise<{ confirmed: boolean; model: AIModel | undefined }> {
@@ -651,7 +731,11 @@ async function getModelAndConfirmAIProviderToS(
651731
const decline: MessageItem = { title: 'Cancel', isCloseAffordance: true };
652732

653733
const result = await window.showInformationMessage(
654-
`GitLens AI features require sending a diff of the code changes to ${model.provider.name} for analysis. This may contain sensitive information.\n\nDo you want to continue?`,
734+
`GitLens AI features require sending ${
735+
confirmationType === 'data' ? 'data' : 'a diff of the code changes'
736+
} to ${
737+
model.provider.name
738+
} for analysis. This may contain sensitive information.\n\nDo you want to continue?`,
655739
{ modal: true },
656740
accept,
657741
switchModel,
@@ -796,6 +880,14 @@ export function showDiffTruncationWarning(maxCodeCharacters: number, model: AIMo
796880
);
797881
}
798882

883+
export function showPromptTruncationWarning(maxCodeCharacters: number, model: AIModel): void {
884+
void window.showWarningMessage(
885+
`The prompt had to be truncated to ${formatNumeric(
886+
maxCodeCharacters,
887+
)} characters to fit within the ${getPossessiveForm(model.provider.name)} limits.`,
888+
);
889+
}
890+
799891
export function getValidatedTemperature(modelTemperature?: number | null): number | undefined {
800892
if (modelTemperature === null) return undefined;
801893
if (modelTemperature != null) return modelTemperature;

src/ai/openAICompatibleProvider.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ import { CancellationError } from '../errors';
88
import { configuration } from '../system/-webview/configuration';
99
import { sum } from '../system/iterable';
1010
import { interpolate } from '../system/string';
11-
import type { AIModel, AIProvider } from './aiProviderService';
11+
import type { AIGenerateChangelogChange, AIModel, AIProvider } from './aiProviderService';
1212
import {
1313
getMaxCharacters,
1414
getOrPromptApiKey,
1515
getValidatedTemperature,
1616
showDiffTruncationWarning,
17+
showPromptTruncationWarning,
1718
} from './aiProviderService';
1819
import {
1920
explainChangesUserPrompt,
21+
generateChangelogUserPrompt,
2022
generateCloudPatchMessageUserPrompt,
2123
generateCodeSuggestMessageUserPrompt,
2224
generateCommitMessageUserPrompt,
@@ -181,6 +183,51 @@ export abstract class OpenAICompatibleProvider<T extends AIProviders> implements
181183
);
182184
}
183185

186+
async generateChangelog(
187+
model: AIModel<T>,
188+
changes: AIGenerateChangelogChange[],
189+
reporting: TelemetryEvents['ai/generate'],
190+
options?: { cancellation?: CancellationToken },
191+
): Promise<string | undefined> {
192+
const apiKey = await this.getApiKey();
193+
if (apiKey == null) return undefined;
194+
195+
try {
196+
const data = JSON.stringify(changes);
197+
198+
const [result, maxCodeCharacters] = await this.fetch(
199+
model,
200+
apiKey,
201+
(max, retries): ChatMessage[] => {
202+
const messages: ChatMessage[] = [
203+
{
204+
role: 'user',
205+
content: interpolate(generateChangelogUserPrompt, {
206+
data: data.substring(0, max),
207+
instructions: configuration.get('ai.generateChangelog.customInstructions') ?? '',
208+
}),
209+
},
210+
];
211+
212+
reporting['retry.count'] = retries;
213+
reporting['input.length'] = (reporting['input.length'] ?? 0) + sum(messages, m => m.content.length);
214+
215+
return messages;
216+
},
217+
4096,
218+
options?.cancellation,
219+
);
220+
221+
if (data.length > maxCodeCharacters) {
222+
showPromptTruncationWarning(maxCodeCharacters, model);
223+
}
224+
225+
return result;
226+
} catch (ex) {
227+
throw new Error(`Unable to generate changelog: ${ex.message}`);
228+
}
229+
}
230+
184231
async explainChanges(
185232
model: AIModel<T>,
186233
message: string,

0 commit comments

Comments
 (0)