diff --git a/common/views.ts b/common/views.ts index 707db9097b..8bc09a0574 100644 --- a/common/views.ts +++ b/common/views.ts @@ -130,6 +130,7 @@ export interface CreateParamsNew { creating: boolean; reviewing: boolean; + usingTemplate: boolean; } export interface ChooseRemoteAndBranchArgs { diff --git a/resources/icons/codicons/notebook-template.svg b/resources/icons/codicons/notebook-template.svg new file mode 100644 index 0000000000..67aaf65d5c --- /dev/null +++ b/resources/icons/codicons/notebook-template.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index 89c89b441c..0260f1e8f5 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -17,7 +17,7 @@ import { PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; -import { DisplayLabel, PreReviewState } from './views'; +import { ChangeTemplateReply, DisplayLabel, PreReviewState } from './views'; import { RemoteInfo } from '../../common/types'; import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, TitleAndDescriptionArgs } from '../../common/views'; import type { Branch, Ref } from '../api/api'; @@ -245,7 +245,9 @@ export abstract class BaseCreatePullRequestViewProvider(PULL_REQUEST_DESCRIPTION) === 'Copilot'); + const descriptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); + const useCopilot: boolean = !!this.getTitleAndDescriptionProvider('Copilot') && (descriptionSource === 'Copilot'); + const usingTemplate: boolean = descriptionSource === 'template'; const defaultTitleAndDescriptionProvider = this.getTitleAndDescriptionProvider()?.title; if (defaultTitleAndDescriptionProvider) { /* __GDPR__ @@ -282,7 +284,8 @@ export abstract class BaseCreatePullRequestViewProvider): Promise { + const templates = await this._folderRepositoryManager.getAllPullRequestTemplates(this.model.baseOwner); + + if (!templates || templates.length === 0) { + vscode.window.showInformationMessage(vscode.l10n.t('No pull request templates found')); + return this._replyMessage(message, undefined); + } + + if (templates.length === 1) { + vscode.window.showInformationMessage(vscode.l10n.t('Only one template is available')); + return this._replyMessage(message, undefined); + } + + // Multiple templates exist - show quick pick + const selectedTemplate = await vscode.window.showQuickPick( + templates.map((template, index) => { + // Try to extract a meaningful name from the template (first line or first few chars) + const firstLine = template.split('\n')[0].trim(); + const label = firstLine || `Template ${index + 1}`; + return { + label: label.substring(0, 50) + (label.length > 50 ? '...' : ''), + description: `${template.length} characters`, + template: template + }; + }), + { + placeHolder: vscode.l10n.t('Select a pull request template'), + ignoreFocusOut: true + } + ); + + if (selectedTemplate) { + const reply: ChangeTemplateReply = { + description: selectedTemplate.template + }; + return this._replyMessage(message, reply); + } + return this._replyMessage(message, undefined); + } + protected async detectBaseMetadata(defaultCompareBranch: Branch): Promise { const owner = this.model.compareOwner; const repositoryName = this.model.repositoryName; @@ -1413,6 +1456,9 @@ export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProv case 'pr.cancelGenerateTitleAndDescription': return this.cancelGenerateTitleAndDescription(); + case 'pr.changeTemplate': + return this.changeTemplate(message); + case 'pr.preReview': return this.preReview(message); diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 453e283c14..ee43678ed3 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -1486,6 +1486,29 @@ export class FolderRepositoryManager extends Disposable { } } + async getAllPullRequestTemplates(owner: string): Promise { + try { + const repository = this.gitHubRepositories.find(repo => repo.remote.owner === owner); + if (!repository) { + return undefined; + } + const templates = await repository.getPullRequestTemplates(); + if (templates && templates.length > 0) { + return templates; + } + + // If there's no local template, look for owner-wide templates + const githubRepository = await this.createGitHubRepositoryFromOwnerName(owner, '.github'); + if (!githubRepository) { + return undefined; + } + return githubRepository.getPullRequestTemplates(); + } catch (e) { + Logger.error(`Error fetching pull request templates for ${owner}: ${e instanceof Error ? e.message : e}`, this.id); + return undefined; + } + } + private async getPullRequestTemplateWithCache(owner: string): Promise { const cacheLocation = `${CACHED_TEMPLATE_BODY}+${this.repository.rootUri.toString()}`; diff --git a/src/github/views.ts b/src/github/views.ts index ede4717618..a497a276a0 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -163,6 +163,10 @@ export enum PreReviewState { ReviewedWithoutComments } +export interface ChangeTemplateReply { + description: string; +} + export interface CancelCodingAgentReply { events: TimelineEvent[]; } diff --git a/webviews/common/common.css b/webviews/common/common.css index d5847177c3..3aeeaec500 100644 --- a/webviews/common/common.css +++ b/webviews/common/common.css @@ -176,6 +176,7 @@ body img.avatar { .section .icon-button:hover, .section .icon-button:focus { background-color: var(--vscode-toolbar-hoverBackground); + cursor: pointer; } .icon-button:focus, diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index bf27c8420f..b96d8daf44 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -34,7 +34,8 @@ const defaultCreateParams: CreateParamsNew = { baseHasMergeQueue: false, preReviewState: PreReviewState.None, preReviewer: undefined, - reviewing: false + reviewing: false, + usingTemplate: false }; export class CreatePRContextNew { diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index 67c7ee53aa..b9cdb6cda1 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -34,6 +34,7 @@ export const gitPullRequestIcon = ; export const loadingIcon = ; export const milestoneIcon = ; +export const notebookTemplate = ; export const passIcon = ; export const projectIcon = ; export const quoteIcon = ; diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx index d762585360..003de5bf07 100644 --- a/webviews/createPullRequestViewNew/app.tsx +++ b/webviews/createPullRequestViewNew/app.tsx @@ -8,11 +8,12 @@ import { render } from 'react-dom'; import { RemoteInfo } from '../../common/types'; import { CreateParamsNew } from '../../common/views'; import { isITeam, MergeMethod } from '../../src/github/interface'; +import { ChangeTemplateReply } from '../../src/github/views'; import PullRequestContextNew from '../common/createContextNew'; import { ErrorBoundary } from '../common/errorBoundary'; import { LabelCreate } from '../common/label'; import { ContextDropdown } from '../components/contextDropdown'; -import { accountIcon, feedbackIcon, gitCompareIcon, milestoneIcon, prMergeIcon, projectIcon, sparkleIcon, stopCircleIcon, tagIcon } from '../components/icon'; +import { accountIcon, feedbackIcon, gitCompareIcon, milestoneIcon, notebookTemplate, prMergeIcon, projectIcon, sparkleIcon, stopCircleIcon, tagIcon } from '../components/icon'; import { Avatar } from '../components/user'; type CreateMethod = 'create-draft' | 'create' | 'create-automerge-squash' | 'create-automerge-rebase' | 'create-automerge-merge'; @@ -178,6 +179,13 @@ export function main() { setGeneratingTitle(false); } + async function changeTemplate() { + const result: ChangeTemplateReply = await ctx.postMessage({ command: 'pr.changeTemplate' }); + if (result && result.description) { + ctx.updateState({ pendingDescription: result.description }); + } + } + if (!ctx.initialized) { ctx.initialize(); @@ -325,7 +333,11 @@ export function main() { : null} - Description + + Description + {ctx.createParams.usingTemplate ? + changeTemplate()} tabIndex={0}>{notebookTemplate} : null} +