Skip to content

Commit a78cf11

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

File tree

2 files changed

+257
-0
lines changed

2 files changed

+257
-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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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 index = higher preference)
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+
// Advanced models (medium priority)
111+
{ vendor: 'copilot', family: 'gpt-4', priority: 2 },
112+
{ vendor: 'copilot', family: 'gpt-4-turbo', priority: 3 },
113+
{ vendor: 'copilot', family: 'gpt-4o', priority: 4 },
114+
{ vendor: 'copilot', family: 'gpt-4o-mini', priority: 3 },
115+
116+
// Claude models (highest priority for text generation)
117+
{ vendor: 'copilot', family: 'claude-3-haiku', priority: 5 },
118+
{ vendor: 'copilot', family: 'claude-3-sonnet', priority: 6 },
119+
{ vendor: 'copilot', family: 'claude-3-opus', priority: 7 },
120+
{ vendor: 'copilot', family: 'claude-3-5-sonnet', priority: 8 },
121+
];
122+
123+
// Find the highest priority model available
124+
let bestModel = models[0];
125+
let bestPriority = 0;
126+
127+
for (const model of models) {
128+
const preference = modelPreferences.find(
129+
p => p.vendor === model.vendor && p.family === model.family
130+
);
131+
const priority = preference?.priority || 0;
132+
133+
if (priority > bestPriority) {
134+
bestModel = model;
135+
bestPriority = priority;
136+
}
137+
}
138+
139+
Logger.debug(
140+
`Selected model: ${bestModel.vendor}:${bestModel.family} (priority: ${bestPriority}) from ${models.length} available models`,
141+
CopilotTitleAndDescriptionProvider.ID
142+
);
143+
144+
return bestModel;
145+
}
146+
147+
private buildPrompt(context: {
148+
commitMessages: string[];
149+
patches: string[] | { patch: string; fileUri: string; previousFileUri?: string }[];
150+
issues?: { reference: string; content: string }[];
151+
pullRequestTemplate?: string;
152+
}): string {
153+
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.
154+
155+
**Requirements:**
156+
1. Title should be concise (under 50 characters when possible) and descriptive
157+
2. Title should follow conventional commit format when appropriate (e.g., "feat:", "fix:", "docs:", etc.)
158+
3. Description should be comprehensive but focused
159+
4. If a pull request template is provided, follow its structure and fill in the sections appropriately
160+
5. Reference any related issues mentioned in commits
161+
6. Summarize the key changes without being overly technical
162+
163+
`;
164+
165+
// Add commit information
166+
if (context.commitMessages && context.commitMessages.length > 0) {
167+
prompt += `**Commit Messages:**\n`;
168+
context.commitMessages.forEach((msg, index) => {
169+
prompt += `${index + 1}. ${msg}\n`;
170+
});
171+
prompt += '\n';
172+
}
173+
174+
// Add patch information summary
175+
if (context.patches && context.patches.length > 0) {
176+
prompt += `**Changes Summary:**\n`;
177+
if (Array.isArray(context.patches) && typeof context.patches[0] === 'string') {
178+
prompt += `${context.patches.length} file(s) modified\n`;
179+
} else {
180+
const patchObjects = context.patches as { patch: string; fileUri: string; previousFileUri?: string }[];
181+
prompt += `Files modified: ${patchObjects.length}\n`;
182+
const fileList = patchObjects.map(p => p.fileUri).slice(0, 10); // Limit to first 10 files
183+
prompt += `Key files: ${fileList.join(', ')}${patchObjects.length > 10 ? '...' : ''}\n`;
184+
}
185+
prompt += '\n';
186+
}
187+
188+
// Add related issues
189+
if (context.issues && context.issues.length > 0) {
190+
prompt += `**Related Issues:**\n`;
191+
context.issues.forEach(issue => {
192+
prompt += `- ${issue.reference}: ${issue.content}\n`;
193+
});
194+
prompt += '\n';
195+
}
196+
197+
// Add pull request template - this is the key part for template integration
198+
if (context.pullRequestTemplate) {
199+
prompt += `**Pull Request Template to Follow:**\n`;
200+
prompt += '```\n' + context.pullRequestTemplate + '\n```\n\n';
201+
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`;
202+
}
203+
204+
prompt += `**Output Format:**
205+
Please respond with the title and description in the following format:
206+
207+
TITLE: [Your generated title here]
208+
209+
DESCRIPTION:
210+
[Your generated description here]
211+
212+
Make sure the title is on a single line after "TITLE:" and the description follows after "DESCRIPTION:" on subsequent lines.`;
213+
214+
return prompt;
215+
}
216+
217+
private parseResponse(responseText: string): { title: string; description?: string } | undefined {
218+
try {
219+
// Look for TITLE: and DESCRIPTION: markers
220+
const titleMatch = responseText.match(/TITLE:\s*(.+?)(?=\n|$)/i);
221+
const descriptionMatch = responseText.match(/DESCRIPTION:\s*([\s\S]*?)(?=\n\n|$)/i);
222+
223+
if (!titleMatch) {
224+
Logger.warn('Could not parse title from response', CopilotTitleAndDescriptionProvider.ID);
225+
return undefined;
226+
}
227+
228+
const title = titleMatch[1].trim();
229+
const description = descriptionMatch ? descriptionMatch[1].trim() : undefined;
230+
231+
// Validate title
232+
if (!title || title.length === 0) {
233+
Logger.warn('Generated title is empty', CopilotTitleAndDescriptionProvider.ID);
234+
return undefined;
235+
}
236+
237+
// Clean up description
238+
const cleanDescription = description && description.length > 0
239+
? description.replace(/^[\s\n]+|[\s\n]+$/g, '') // Trim whitespace and newlines
240+
: undefined;
241+
242+
return {
243+
title,
244+
description: cleanDescription
245+
};
246+
} catch (error) {
247+
Logger.error(`Error parsing response: ${error}`, CopilotTitleAndDescriptionProvider.ID);
248+
return undefined;
249+
}
250+
}
251+
}

0 commit comments

Comments
 (0)