Skip to content

Commit b47069a

Browse files
authored
Add command to generate PR title + description (#8508)
* Add command to generate PR title + description * Copilot PR feedback
1 parent b73dbca commit b47069a

6 files changed

Lines changed: 138 additions & 52 deletions

File tree

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,6 +1874,11 @@
18741874
"command": "pr.checkoutChatSessionPullRequest",
18751875
"title": "%command.pr.checkoutChatSessionPullRequest.title%",
18761876
"category": "%command.pull.request.category%"
1877+
},
1878+
{
1879+
"command": "pr.generateTitleAndDescription",
1880+
"title": "%command.pr.generateTitleAndDescription.title%",
1881+
"category": "%command.pull.request.category%"
18771882
}
18781883
],
18791884
"viewsWelcome": [
@@ -2673,6 +2678,10 @@
26732678
{
26742679
"command": "review.copyPrLink",
26752680
"when": "github:inReviewMode"
2681+
},
2682+
{
2683+
"command": "pr.generateTitleAndDescription",
2684+
"when": "false"
26762685
}
26772686
],
26782687
"view/title": [

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@
357357
"command.notifications.configureNotificationsViewlet.title": "Configure...",
358358
"command.notification.chatSummarizeNotification.title": "Summarize With Copilot",
359359
"command.pr.checkoutChatSessionPullRequest.title": "Checkout Pull Request",
360+
"command.pr.generateTitleAndDescription.title": "Generate Pull Request Title and Description",
360361
"welcome.github.login.contents": {
361362
"message": "You have not yet signed in with GitHub\n[Sign in](command:pr.signin)",
362363
"comment": [

src/commands.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { IssueChatContextItem } from './lm/issueContextProvider';
3636
import { PRChatContextItem } from './lm/pullRequestContextProvider';
3737
import { isNotificationTreeItem, NotificationTreeItem } from './notifications/notificationItem';
3838
import { NotificationsManager } from './notifications/notificationsManager';
39+
import { CreatePullRequestDataModel } from './view/createPullRequestDataModel';
3940
import { PullRequestsTreeDataProvider } from './view/prsTreeDataProvider';
4041
import { PrsTreeModel } from './view/prsTreeModel';
4142
import { ReviewCommentController } from './view/reviewCommentController';
@@ -2044,4 +2045,66 @@ ${contents}
20442045
}
20452046
})
20462047
);
2048+
2049+
context.subscriptions.push(
2050+
vscode.commands.registerCommand('pr.generateTitleAndDescription', async (args: { rootUri: vscode.Uri; baseBranch: string; compareBranch: string }) => {
2051+
if (!args?.rootUri || !args?.baseBranch || !args?.compareBranch) {
2052+
Logger.error('Missing required arguments for pr.generateTitleAndDescription', logId);
2053+
return undefined;
2054+
}
2055+
2056+
const folderManager = reposManager.getManagerForFile(args.rootUri);
2057+
if (!folderManager) {
2058+
Logger.error('Unable to find a repository for the provided rootUri.', logId);
2059+
return undefined;
2060+
}
2061+
2062+
const origin = await folderManager.getOrigin();
2063+
const defaults = await folderManager.getPullRequestDefaults();
2064+
2065+
const model = new CreatePullRequestDataModel(
2066+
folderManager,
2067+
defaults.owner,
2068+
args.baseBranch,
2069+
origin.remote.owner,
2070+
args.compareBranch,
2071+
origin.remote.repositoryName,
2072+
);
2073+
2074+
try {
2075+
const { commitMessages, patches } = await model.getCommitsAndPatches();
2076+
const issues = await model.findIssueContext(commitMessages);
2077+
const template = await folderManager.getPullRequestTemplateBody(defaults.owner);
2078+
2079+
const provider = folderManager.getTitleAndDescriptionProvider();
2080+
if (!provider) {
2081+
Logger.error('No title and description provider available.', logId);
2082+
return undefined;
2083+
}
2084+
2085+
const tokenSource = new vscode.CancellationTokenSource();
2086+
const result = await provider.provider.provideTitleAndDescription(
2087+
{ commitMessages, patches, issues, template },
2088+
tokenSource.token,
2089+
);
2090+
2091+
/* __GDPR__
2092+
"pr.generatedTitleAndDescription" : {
2093+
"providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
2094+
"source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
2095+
}
2096+
*/
2097+
telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title, source: 'command' });
2098+
2099+
tokenSource.dispose();
2100+
2101+
return result ? { title: result.title, description: result.description } : undefined;
2102+
} catch (e) {
2103+
Logger.error(`Error generating title and description: ${formatError(e)}`, logId);
2104+
return undefined;
2105+
} finally {
2106+
model.dispose();
2107+
}
2108+
}),
2109+
);
20472110
}

src/github/createPRViewProvider.ts

Lines changed: 3 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { BaseBranchMetadata, PullRequestGitHelper } from './pullRequestGitHelper
1616
import { PullRequestModel } from './pullRequestModel';
1717
import { getDefaultMergeMethod } from './pullRequestOverview';
1818
import { branchPicks, getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks';
19-
import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils';
19+
import { ISSUE_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils';
2020
import { ChangeTemplateReply, DisplayLabel, PreReviewState } from './views';
2121
import { RemoteInfo } from '../../common/types';
2222
import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views';
@@ -1142,57 +1142,8 @@ Don't forget to commit your template file to the repository so that it can be us
11421142
return this._replyMessage(message, chooseResult);
11431143
}
11441144

1145-
private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> {
1146-
const issues: Promise<{ content: string, reference: string } | undefined>[] = [];
1147-
for (const commit of commits) {
1148-
const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION));
1149-
if (tryParse) {
1150-
const owner = tryParse.owner ?? this.model.baseOwner;
1151-
const name = tryParse.name ?? this.model.repositoryName;
1152-
issues.push(new Promise(resolve => {
1153-
this._folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => {
1154-
if (issue) {
1155-
resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) });
1156-
} else {
1157-
resolve(undefined);
1158-
}
1159-
}).catch(() => resolve(undefined));
1160-
}));
1161-
}
1162-
}
1163-
if (issues.length) {
1164-
return (await Promise.all(issues)).filter(issue => !!issue) as { content: string, reference: string }[];
1165-
}
1166-
return undefined;
1167-
}
1168-
11691145
private async getCommitsAndPatches(): Promise<{ commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }> {
1170-
let commitMessages: string[];
1171-
let patches: ({ patch: string, fileUri: string, previousFileUri?: string } | undefined)[] | undefined;
1172-
if (await this.model.getCompareHasUpstream()) {
1173-
[commitMessages, patches] = await Promise.all([
1174-
this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)),
1175-
this.model.gitHubFiles().then(rawPatches => rawPatches?.map(file => {
1176-
if (!file.patch) {
1177-
return;
1178-
}
1179-
const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString();
1180-
const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined;
1181-
return { patch: file.patch, fileUri, previousFileUri };
1182-
}))]);
1183-
} else {
1184-
[commitMessages, patches] = await Promise.all([
1185-
this.model.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)),
1186-
Promise.all((await this.model.gitFiles()).map(async (file) => {
1187-
return {
1188-
patch: await this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.compareBranch, file.uri.fsPath),
1189-
fileUri: file.uri.toString(),
1190-
};
1191-
}))]);
1192-
}
1193-
const filteredPatches: { patch: string, fileUri: string, previousFileUri?: string }[] =
1194-
patches?.filter<{ patch: string, fileUri: string, previousFileUri?: string }>((patch): patch is { patch: string, fileUri: string, previousFileUri?: string } => !!patch) ?? [];
1195-
return { commitMessages, patches: filteredPatches };
1146+
return this.model.getCommitsAndPatches();
11961147
}
11971148

11981149
private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined;
@@ -1201,7 +1152,7 @@ Don't forget to commit your template file to the repository so that it can be us
12011152
try {
12021153
const templatePromise = this.getPullRequestTemplate(); // Fetch in parallel
12031154
const { commitMessages, patches } = await this.getCommitsAndPatches();
1204-
const issues = await this.findIssueContext(commitMessages);
1155+
const issues = await this.model.findIssueContext(commitMessages);
12051156
const template = await templatePromise;
12061157

12071158
const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm);

src/github/pullRequestOverview.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,14 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
10221022
this.generatingDescriptionCancellationToken.token
10231023
);
10241024

1025+
/* __GDPR__
1026+
"pr.generatedTitleAndDescription" : {
1027+
"providerTitle" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
1028+
"source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
1029+
}
1030+
*/
1031+
this._telemetry.sendTelemetryEvent('pr.generatedTitleAndDescription', { providerTitle: provider?.title, source: 'regenerate' });
1032+
10251033
this.generatingDescriptionCancellationToken = undefined;
10261034
return this._replyMessage(message, { description: result?.description });
10271035
} catch (e) {

src/view/createPullRequestDataModel.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Logger from '../common/logger';
1111
import { OctokitCommon } from '../github/common';
1212
import { FolderRepositoryManager } from '../github/folderRepositoryManager';
1313
import { GitHubRepository } from '../github/githubRepository';
14+
import { getIssueNumberLabelFromParsed, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils';
1415

1516
export interface CreateModelChangeEvent {
1617
baseOwner?: string;
@@ -252,4 +253,57 @@ export class CreatePullRequestDataModel extends Disposable {
252253
}
253254
return this._gitHubMergeBase!;
254255
}
256+
257+
public async getCommitsAndPatches(): Promise<{ commitMessages: string[]; patches: { patch: string; fileUri: string; previousFileUri?: string }[] }> {
258+
let commitMessages: string[];
259+
let patches: ({ patch: string; fileUri: string; previousFileUri?: string } | undefined)[] | undefined;
260+
if (await this.getCompareHasUpstream()) {
261+
[commitMessages, patches] = await Promise.all([
262+
this.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)),
263+
this.gitHubFiles().then(rawPatches => rawPatches?.map(file => {
264+
if (!file.patch) {
265+
return;
266+
}
267+
const fileUri = vscode.Uri.joinPath(this.folderRepositoryManager.repository.rootUri, file.filename).toString();
268+
const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this.folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined;
269+
return { patch: file.patch, fileUri, previousFileUri };
270+
}))]);
271+
} else {
272+
[commitMessages, patches] = await Promise.all([
273+
this.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)),
274+
Promise.all((await this.gitFiles()).map(async (file) => {
275+
return {
276+
patch: await this.folderRepositoryManager.repository.diffBetween(this._baseBranch, this._compareBranch, file.uri.fsPath),
277+
fileUri: file.uri.toString(),
278+
};
279+
}))]);
280+
}
281+
const filteredPatches: { patch: string; fileUri: string; previousFileUri?: string }[] =
282+
patches?.filter<{ patch: string; fileUri: string; previousFileUri?: string }>((patch): patch is { patch: string; fileUri: string; previousFileUri?: string } => !!patch) ?? [];
283+
return { commitMessages, patches: filteredPatches };
284+
}
285+
286+
public async findIssueContext(commits: string[]): Promise<{ content: string; reference: string }[] | undefined> {
287+
const issues: Promise<{ content: string; reference: string } | undefined>[] = [];
288+
for (const commit of commits) {
289+
const tryParse = parseIssueExpressionOutput(commit.match(ISSUE_OR_URL_EXPRESSION));
290+
if (tryParse) {
291+
const owner = tryParse.owner ?? this._baseOwner;
292+
const name = tryParse.name ?? this.repositoryName;
293+
issues.push(new Promise(resolve => {
294+
this.folderRepositoryManager.resolveIssue(owner, name, tryParse.issueNumber).then(issue => {
295+
if (issue) {
296+
resolve({ content: `${issue.title}\n${issue.body}`, reference: getIssueNumberLabelFromParsed(tryParse) });
297+
} else {
298+
resolve(undefined);
299+
}
300+
}).catch(() => resolve(undefined));
301+
}));
302+
}
303+
}
304+
if (issues.length) {
305+
return (await Promise.all(issues)).filter(issue => !!issue) as { content: string; reference: string }[];
306+
}
307+
return undefined;
308+
}
255309
}

0 commit comments

Comments
 (0)