Skip to content

Commit 4605075

Browse files
committed
Add Copilot Title and Description Provider for PR generation
1 parent d668d18 commit 4605075

File tree

2 files changed

+269
-0
lines changed

2 files changed

+269
-0
lines changed

src/extension.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { registerBuiltinGitProvider, registerLiveShareGitProvider } from './gitP
2929
import { GitHubContactServiceProvider } from './gitProviders/GitHubContactServiceProvider';
3030
import { GitLensIntegration } from './integrations/gitlens/gitlensImpl';
3131
import { IssueFeatureRegistrar } from './issues/issueFeatureRegistrar';
32+
import { CopilotTitleAndDescriptionProvider } from './lm/copilotTitleAndDescriptionProvider';
3233
import { ChatParticipant, ChatParticipantState } from './lm/participants';
3334
import { registerTools } from './lm/tools/tools';
3435
import { migrate } from './migrations';
@@ -397,6 +398,11 @@ async function deferredActivate(context: vscode.ExtensionContext, showPRControll
397398
const apiImpl = new GitApiImpl(reposManager);
398399
context.subscriptions.push(apiImpl);
399400

401+
// Register Copilot Title and Description Provider
402+
Logger.debug('Registering Copilot Title and Description Provider.', 'Activation');
403+
const copilotProvider = new CopilotTitleAndDescriptionProvider(credentialStore, telemetry);
404+
apiImpl.registerTitleAndDescriptionProvider('Copilot', copilotProvider);
405+
400406
deferredActivateRegisterBuiltInGitProvider(context, apiImpl, credentialStore);
401407

402408
Logger.debug('Registering live share git provider.', 'Activation');
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { TitleAndDescriptionProvider } from '../api/api';
8+
import Logger from '../common/logger';
9+
import { ITelemetry } from '../common/telemetry';
10+
import { CredentialStore } from '../github/credentials';
11+
12+
/**
13+
* Provides PR title and description generation using VS Code's built-in Copilot language models.
14+
* This provider leverages pull request templates when available to generate contextually appropriate
15+
* titles and descriptions that follow the repository's conventions.
16+
*/
17+
export class CopilotTitleAndDescriptionProvider implements TitleAndDescriptionProvider {
18+
private static readonly ID = 'CopilotTitleAndDescriptionProvider';
19+
20+
constructor(
21+
private readonly credentialStore: CredentialStore,
22+
private readonly telemetry: ITelemetry
23+
) { }
24+
25+
async provideTitleAndDescription(
26+
context: {
27+
commitMessages: string[];
28+
patches: string[] | { patch: string; fileUri: string; previousFileUri?: string }[];
29+
issues?: { reference: string; content: string }[];
30+
pullRequestTemplate?: string;
31+
},
32+
token: vscode.CancellationToken
33+
): Promise<{ title: string; description?: string } | undefined> {
34+
try {
35+
Logger.debug('Starting Copilot PR title and description generation', CopilotTitleAndDescriptionProvider.ID);
36+
37+
// Select the appropriate language model (use user's preference from all available models)
38+
const models = await vscode.lm.selectChatModels();
39+
console.log(`Available models: ${models.map(m => `${m.vendor}:${m.family} (${m.name})`).join(', ')}`);
40+
41+
if (!models || models.length === 0) {
42+
Logger.warn('No language models available', CopilotTitleAndDescriptionProvider.ID);
43+
return undefined;
44+
}
45+
46+
// Prefer higher capability models for better PR generation
47+
// Priority: Claude > GPT-4 > GPT-3.5
48+
const model = this.selectBestModel(models);
49+
console.log(`Using model: ${model.vendor}:${model.family} (${model.name})`);
50+
Logger.debug(`Using model: ${model.vendor}:${model.family} (${model.name})`, CopilotTitleAndDescriptionProvider.ID);
51+
52+
// Build the prompt
53+
const prompt = this.buildPrompt(context);
54+
const messages = [vscode.LanguageModelChatMessage.User(prompt)];
55+
56+
// Send request to language model
57+
const response = await model.sendRequest(messages, {
58+
justification: 'Generating pull request title and description based on commits and repository templates'
59+
}, token);
60+
61+
// Parse response
62+
let responseText = '';
63+
for await (const part of response.stream) {
64+
if (part instanceof vscode.LanguageModelTextPart) {
65+
responseText += part.value;
66+
}
67+
}
68+
69+
const result = this.parseResponse(responseText);
70+
71+
if (result) {
72+
Logger.debug(`Generated title: "${result.title}"`, CopilotTitleAndDescriptionProvider.ID);
73+
Logger.debug(`Generated description length: ${result.description?.length || 0} characters`, CopilotTitleAndDescriptionProvider.ID);
74+
75+
// Track telemetry
76+
this.telemetry.sendTelemetryEvent('copilot.titleAndDescription.generated', {
77+
hasTemplate: context.pullRequestTemplate ? 'true' : 'false',
78+
commitCount: context.commitMessages.length.toString(),
79+
patchCount: Array.isArray(context.patches) ? context.patches.length.toString() : 'unknown',
80+
issueCount: (context.issues?.length || 0).toString(),
81+
modelVendor: model.vendor,
82+
modelFamily: model.family,
83+
modelName: model.name
84+
});
85+
}
86+
87+
return result;
88+
} catch (error) {
89+
Logger.error(`Error generating PR title and description: ${error}`, CopilotTitleAndDescriptionProvider.ID);
90+
91+
this.telemetry.sendTelemetryEvent('copilot.titleAndDescription.error', {
92+
error: error instanceof Error ? error.message : 'unknown'
93+
});
94+
95+
return undefined;
96+
}
97+
}
98+
99+
/**
100+
* Selects the best available model for PR generation.
101+
* Prioritizes models based on their capabilities for text generation.
102+
*/
103+
private selectBestModel(models: readonly vscode.LanguageModelChat[]): vscode.LanguageModelChat {
104+
// Define model preference order (higher priority = better for PR generation)
105+
const modelPreferences = [
106+
// Basic models (lowest priority)
107+
{ vendor: 'copilot', family: 'gpt-3.5-turbo', priority: 1 },
108+
{ vendor: 'copilot', family: 'gpt-3.5', priority: 1 },
109+
110+
// Standard GPT-4 models (medium priority)
111+
{ vendor: 'copilot', family: 'gpt-4', priority: 2 },
112+
{ vendor: 'copilot', family: 'gpt-4o-mini', priority: 3 },
113+
{ vendor: 'copilot', family: 'gpt-4-turbo', priority: 4 },
114+
{ vendor: 'copilot', family: 'gpt-4o', priority: 5 },
115+
{ vendor: 'copilot', family: 'gpt-4.1', priority: 6 },
116+
117+
// Claude models (high priority for text generation)
118+
{ vendor: 'copilot', family: 'claude-3-haiku', priority: 7 },
119+
{ vendor: 'copilot', family: 'claude-3-sonnet', priority: 8 },
120+
{ vendor: 'copilot', family: 'claude-3.5-sonnet', priority: 9 },
121+
{ vendor: 'copilot', family: 'claude-3-opus', priority: 10 },
122+
{ vendor: 'copilot', family: 'claude-3.7-sonnet', priority: 11 },
123+
{ vendor: 'copilot', family: 'claude-3.7-sonnet-thought', priority: 12 },
124+
125+
// Gemini models (high priority)
126+
{ vendor: 'copilot', family: 'gemini-2.0-flash', priority: 13 },
127+
{ vendor: 'copilot', family: 'gemini-2.5-pro', priority: 14 },
128+
129+
// Latest advanced models (highest priority)
130+
{ vendor: 'copilot', family: 'o3-mini', priority: 15 },
131+
{ vendor: 'copilot', family: 'claude-sonnet-4', priority: 16 },
132+
{ vendor: 'copilot', family: 'o4-mini', priority: 17 },
133+
];
134+
135+
// Find the highest priority model available
136+
let bestModel = models[0];
137+
let bestPriority = 0;
138+
139+
for (const model of models) {
140+
const preference = modelPreferences.find(
141+
p => p.vendor === model.vendor && p.family === model.family
142+
);
143+
const priority = preference?.priority || 0;
144+
145+
if (priority > bestPriority) {
146+
bestModel = model;
147+
bestPriority = priority;
148+
}
149+
}
150+
151+
Logger.debug(
152+
`Selected model: ${bestModel.vendor}:${bestModel.family} (priority: ${bestPriority}) from ${models.length} available models`,
153+
CopilotTitleAndDescriptionProvider.ID
154+
);
155+
156+
return bestModel;
157+
}
158+
159+
private buildPrompt(context: {
160+
commitMessages: string[];
161+
patches: string[] | { patch: string; fileUri: string; previousFileUri?: string }[];
162+
issues?: { reference: string; content: string }[];
163+
pullRequestTemplate?: string;
164+
}): string {
165+
let prompt = `You are an expert at writing clear, concise pull request titles and descriptions. Please generate a suitable title and description for this pull request based on the provided information.
166+
167+
**Requirements:**
168+
1. Title should be concise (under 50 characters when possible) and descriptive
169+
2. Title should follow conventional commit format when appropriate (e.g., "feat:", "fix:", "docs:", etc.)
170+
3. Description should be comprehensive but focused
171+
4. If a pull request template is provided, follow its structure and fill in the sections appropriately
172+
5. Reference any related issues mentioned in commits
173+
6. Summarize the key changes without being overly technical
174+
175+
`;
176+
177+
// Add commit information
178+
if (context.commitMessages && context.commitMessages.length > 0) {
179+
prompt += `**Commit Messages:**\n`;
180+
context.commitMessages.forEach((msg, index) => {
181+
prompt += `${index + 1}. ${msg}\n`;
182+
});
183+
prompt += '\n';
184+
}
185+
186+
// Add patch information summary
187+
if (context.patches && context.patches.length > 0) {
188+
prompt += `**Changes Summary:**\n`;
189+
if (Array.isArray(context.patches) && typeof context.patches[0] === 'string') {
190+
prompt += `${context.patches.length} file(s) modified\n`;
191+
} else {
192+
const patchObjects = context.patches as { patch: string; fileUri: string; previousFileUri?: string }[];
193+
prompt += `Files modified: ${patchObjects.length}\n`;
194+
const fileList = patchObjects.map(p => p.fileUri).slice(0, 10); // Limit to first 10 files
195+
prompt += `Key files: ${fileList.join(', ')}${patchObjects.length > 10 ? '...' : ''}\n`;
196+
}
197+
prompt += '\n';
198+
}
199+
200+
// Add related issues
201+
if (context.issues && context.issues.length > 0) {
202+
prompt += `**Related Issues:**\n`;
203+
context.issues.forEach(issue => {
204+
prompt += `- ${issue.reference}: ${issue.content}\n`;
205+
});
206+
prompt += '\n';
207+
}
208+
209+
// Add pull request template - this is the key part for template integration
210+
if (context.pullRequestTemplate) {
211+
prompt += `**Pull Request Template to Follow:**\n`;
212+
prompt += '```\n' + context.pullRequestTemplate + '\n```\n\n';
213+
prompt += `Please structure the description according to this template. Fill in each section with relevant information based on the commits and changes. If a section is not applicable, you may omit it or note "N/A".\n\n`;
214+
}
215+
216+
prompt += `**Output Format:**
217+
Please respond with the title and description in the following format:
218+
219+
TITLE: [Your generated title here]
220+
221+
DESCRIPTION:
222+
[Your generated description here]
223+
224+
Make sure the title is on a single line after "TITLE:" and the description follows after "DESCRIPTION:" on subsequent lines.`;
225+
226+
return prompt;
227+
}
228+
229+
private parseResponse(responseText: string): { title: string; description?: string } | undefined {
230+
try {
231+
// Look for TITLE: and DESCRIPTION: markers
232+
const titleMatch = responseText.match(/TITLE:\s*(.+?)(?=\n|$)/i);
233+
const descriptionMatch = responseText.match(/DESCRIPTION:\s*([\s\S]*?)(?=\n\n|$)/i);
234+
235+
if (!titleMatch) {
236+
Logger.warn('Could not parse title from response', CopilotTitleAndDescriptionProvider.ID);
237+
return undefined;
238+
}
239+
240+
const title = titleMatch[1].trim();
241+
const description = descriptionMatch ? descriptionMatch[1].trim() : undefined;
242+
243+
// Validate title
244+
if (!title || title.length === 0) {
245+
Logger.warn('Generated title is empty', CopilotTitleAndDescriptionProvider.ID);
246+
return undefined;
247+
}
248+
249+
// Clean up description
250+
const cleanDescription = description && description.length > 0
251+
? description.replace(/^[\s\n]+|[\s\n]+$/g, '') // Trim whitespace and newlines
252+
: undefined;
253+
254+
return {
255+
title,
256+
description: cleanDescription
257+
};
258+
} catch (error) {
259+
Logger.error(`Error parsing response: ${error}`, CopilotTitleAndDescriptionProvider.ID);
260+
return undefined;
261+
}
262+
}
263+
}

0 commit comments

Comments
 (0)