diff --git a/common/views.ts b/common/views.ts index a776749604..35e64eb306 100644 --- a/common/views.ts +++ b/common/views.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAccount, ILabel, IMilestone, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; +import { IAccount, ILabel, IMilestone, IProject, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; export interface RemoteInfo { owner: string; @@ -79,6 +79,7 @@ export interface CreatePullRequestNew { autoMerge: boolean; autoMergeMethod?: MergeMethod; labels: ILabel[]; + projects: IProject[]; assignees: IAccount[]; reviewers: (IAccount | ITeam)[]; milestone?: IMilestone; @@ -103,6 +104,7 @@ export interface CreateParamsNew { isDraftDefault: boolean; isDraft?: boolean; labels?: ILabel[]; + projects?: IProject[]; assignees?: IAccount[]; reviewers?: (IAccount | ITeam)[]; milestone?: IMilestone; diff --git a/package.json b/package.json index 1de240559d..afdd37efc8 100644 --- a/package.json +++ b/package.json @@ -1084,6 +1084,12 @@ "category": "%command.pull.request.category%", "icon": "$(milestone)" }, + { + "command": "pr.addProjectsToNewPr", + "title": "%command.pr.addProjectsToNewPr.title%", + "category": "%command.pull.request.category%", + "icon": "$(project)" + }, { "command": "pr.addFileComment", "title": "%command.pr.addFileComment.title%", @@ -1781,6 +1787,10 @@ "command": "pr.addMilestoneToNewPr", "when": "false" }, + { + "command": "pr.addProjectsToNewPr", + "when": "false" + }, { "command": "pr.addFileComment", "when": "false" @@ -2050,6 +2060,11 @@ "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", "group": "navigation@4" }, + { + "command": "pr.addProjectsToNewPr", + "when": "view == github:createPullRequestWebview && github:createPrPermissions != READ && github:createPrPermissions", + "group": "navigation@5" + }, { "command": "pr.refreshComments", "when": "view == workbench.panel.comments", diff --git a/package.nls.json b/package.nls.json index 4d843b3b94..4889a51b8b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -220,6 +220,7 @@ "command.pr.addReviewersToNewPr.title": "Add Reviewers", "command.pr.addLabelsToNewPr.title": "Apply Labels", "command.pr.addMilestoneToNewPr.title": "Set Milestone", + "command.pr.addProjectsToNewPr.title": "Add Projects", "command.pr.addFileComment.title": "Add File Comment", "command.review.diffWithPrHead.title": "Compare Base With Pull Request Head (readonly)", "command.review.diffLocalWithPrHead.title": "Compare Pull Request Head with Local", diff --git a/resources/icons/project.svg b/resources/icons/project.svg new file mode 100644 index 0000000000..5aca8548e5 --- /dev/null +++ b/resources/icons/project.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/github/createPRViewProviderNew.ts b/src/github/createPRViewProviderNew.ts index ca12dc249c..1bbd1027b7 100644 --- a/src/github/createPRViewProviderNew.ts +++ b/src/github/createPRViewProviderNew.ts @@ -33,11 +33,11 @@ import { titleAndBodyFrom, } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; -import { IAccount, ILabel, IMilestone, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { IAccount, ILabel, IMilestone, IProject, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; import { BaseBranchMetadata } from './pullRequestGitHelper'; import { PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; -import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; +import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks'; import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils'; const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword @@ -663,9 +663,15 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements } } - private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined): void { + private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined) { if (milestone) { - pr.updateMilestone(milestone.id); + return pr.updateMilestone(milestone.id); + } + } + + private setProjects(pr: PullRequestModel, projects: IProject[]) { + if (projects.length) { + return pr.updateProjects(projects); } } @@ -673,6 +679,10 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(remote.repositoryName, this._baseRemote.repositoryName) === 0)!; } + private getGitHubRepo(): GitHubRepository | undefined { + return this._folderRepositoryManager.gitHubRepositories.find(repo => compareIgnoreCase(repo.remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(repo.remote.repositoryName, this._baseRemote.repositoryName) === 0); + } + private milestone: IMilestone | undefined; public async addMilestone(): Promise { const remote = await this.getRemote(); @@ -743,6 +753,23 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements }); } } + private projects: IProject[] = []; + public async addProjects(): Promise { + const githubRepo = this.getGitHubRepo(); + if (!githubRepo) { + return; + } + await new Promise((resolve) => { + getProjectFromQuickPick(this._folderRepositoryManager, githubRepo, this.projects, async (projects) => { + this.projects = projects; + this._postMessage({ + command: 'set-projects', + params: { projects: this.projects } + }); + resolve(); + }); + }); + } private labels: ILabel[] = []; public async addLabels(): Promise { @@ -938,7 +965,8 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), this.setAssignees(createdPR, message.args.assignees), this.setReviewers(createdPR, message.args.reviewers), - this.setMilestone(createdPR, message.args.milestone)]); + this.setMilestone(createdPR, message.args.milestone), + this.setProjects(createdPR, message.args.projects)]); }; CreatePullRequestViewProviderNew.withProgress(() => { @@ -1103,6 +1131,9 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements case 'pr.changeMilestone': return this.addMilestone(); + case 'pr.changeProjects': + return this.addProjects(); + case 'pr.removeLabel': return this.removeLabel(message); diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 90f16b0927..eb51f6be08 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -746,6 +746,12 @@ export class FolderRepositoryManager implements vscode.Disposable { return globalStateProjects ?? this.createFetchOrgProjectsPromise(); } + async getAllProjects(githubRepository: GitHubRepository, clearOrgCache?: boolean): Promise { + const isInOrganization = !!(await githubRepository.getMetadata()).organization; + const [repoProjects, orgProjects] = (await Promise.all([githubRepository.getProjects(), (isInOrganization ? this.getOrgProjects(clearOrgCache) : undefined)])); + return [...(repoProjects ?? []), ...(orgProjects ? orgProjects[githubRepository.remote.remoteName] : [])]; + } + async getOrgTeamsCount(repository: GitHubRepository): Promise { if ((await repository.getMetadata()).organization) { return repository.getOrgTeamsCount(); diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index b070620975..68a5a4c8a3 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -475,7 +475,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { - return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.remote.remoteName, this._item.base.isInOrganization, this._item.item.projectItems, (project) => this.updateProjects(project, message)); + return getProjectFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this._item.item.projectItems?.map(item => item.project), (project) => this.updateProjects(project, message)); } private async updateProjects(projects: IProject[] | undefined, message: IRequestMessage) { diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts index cac6cd32a8..260914abb9 100644 --- a/src/github/quickPicks.ts +++ b/src/github/quickPicks.ts @@ -12,7 +12,7 @@ import { DataUri } from '../common/uri'; import { formatError } from '../common/utils'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; -import { IAccount, ILabel, IMilestone, IProject, IProjectItem, isSuggestedReviewer, isTeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { IAccount, ILabel, IMilestone, IProject, isSuggestedReviewer, isTeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; import { PullRequestModel } from './pullRequestModel'; async function getItems(context: vscode.ExtensionContext, skipList: Set, users: T[], picked: boolean, tooManyAssignable: boolean = false): Promise<(vscode.QuickPickItem & { user?: T })[]> { @@ -214,12 +214,11 @@ function isProjectQuickPickItem(x: vscode.QuickPickItem | ProjectQuickPickItem): return !!(x as ProjectQuickPickItem).id && !!(x as ProjectQuickPickItem).project; } -export async function getProjectFromQuickPick(folderRepoManager: FolderRepositoryManager, githubRepository: GitHubRepository, remoteName: string, isInOrganization: boolean, currentProjects: IProjectItem[] | undefined, callback: (projects: IProject[]) => Promise): Promise { +export async function getProjectFromQuickPick(folderRepoManager: FolderRepositoryManager, githubRepository: GitHubRepository, currentProjects: IProject[] | undefined, callback: (projects: IProject[]) => Promise): Promise { try { let selectedItems: vscode.QuickPickItem[] = []; async function getProjectOptions(): Promise<(ProjectQuickPickItem | vscode.QuickPickItem)[]> { - const [repoProjects, orgProjects] = (await Promise.all([githubRepository.getProjects(), (isInOrganization ? folderRepoManager.getOrgProjects() : undefined)])); - const projects = [...(repoProjects ?? []), ...(orgProjects ? orgProjects[remoteName] : [])]; + const projects = await folderRepoManager.getAllProjects(githubRepository); if (!projects || !projects.length) { return [ { @@ -235,7 +234,7 @@ export async function getProjectFromQuickPick(folderRepoManager: FolderRepositor id: result.id, project: result }; - if (currentProjects && currentProjects.find(project => project.project.id === result.id)) { + if (currentProjects && currentProjects.find(project => project.id === result.id)) { selectedItems.push(item); } return item; diff --git a/src/issues/issueFeatureRegistrar.ts b/src/issues/issueFeatureRegistrar.ts index 08dfcfe336..0349060e2c 100644 --- a/src/issues/issueFeatureRegistrar.ts +++ b/src/issues/issueFeatureRegistrar.ts @@ -17,6 +17,7 @@ import { import { ITelemetry } from '../common/telemetry'; import { OctokitCommon } from '../github/common'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IProject } from '../github/interface'; import { IssueModel } from '../github/issueModel'; import { RepositoriesManager } from '../github/repositoriesManager'; import { getRepositoryForFile, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput } from '../github/utils'; @@ -34,6 +35,7 @@ import { NEW_ISSUE_SCHEME, NewIssueCache, NewIssueFileCompletionProvider, + PROJECTS, } from './issueFile'; import { IssueHoverProvider } from './issueHoverProvider'; import { openCodeLink } from './issueLinkLookup'; @@ -647,6 +649,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { metadata.assignees, metadata.labels, metadata.milestone, + metadata.projects, this.createIssueInfo?.lineNumber, this.createIssueInfo?.insertIndex, metadata.originUri @@ -926,7 +929,7 @@ export class IssueFeatureRegistrar implements vscode.Disposable { title = quickInput.value; if (title) { quickInput.busy = true; - await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, undefined, lineNumber, insertIndex); + await this.doCreateIssue(document, newIssue, title, body, assignee, undefined, undefined, undefined, lineNumber, insertIndex); quickInput.busy = false; } quickInput.hide(); @@ -965,11 +968,13 @@ export class IssueFeatureRegistrar implements vscode.Disposable { }`; const labelLine = `${LABELS} `; const milestoneLine = `${MILESTONE} `; + const projectsLine = `${PROJECTS} `; const cached = this._newIssueCache.get(); const text = (cached && cached !== '') ? cached : `${title ?? vscode.l10n.t('Issue Title')}\n ${assigneeLine} ${labelLine} -${milestoneLine}\n +${milestoneLine} +${projectsLine}\n ${body ?? ''}\n `; await vscode.workspace.fs.writeFile(bodyPath, this.stringToUint8Array(text)); @@ -987,6 +992,13 @@ ${body ?? ''}\n color: new vscode.ThemeColor('issues.newIssueDecoration'), }, }); + const projectsDecoration = vscode.window.createTextEditorDecorationType({ + after: { + contentText: vscode.l10n.t(' Comma-separated projects.'), + fontStyle: 'italic', + color: new vscode.ThemeColor('issues.newIssueDecoration'), + }, + }); const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(textEditor => { if (textEditor?.document.uri.scheme === NEW_ISSUE_SCHEME) { const assigneeFullLine = textEditor.document.lineAt(2); @@ -1004,6 +1016,12 @@ ${body ?? ''}\n new vscode.Range(new vscode.Position(3, 0), new vscode.Position(3, labelFullLine.text.length)), ]); } + const projectsFullLine = textEditor.document.lineAt(5); + if (projectsFullLine.text.startsWith(PROJECTS)) { + textEditor.setDecorations(projectsDecoration, [ + new vscode.Range(new vscode.Position(5, 0), new vscode.Position(5, projectsFullLine.text.length)), + ]); + } } }); @@ -1149,6 +1167,7 @@ ${body ?? ''}\n assignees: string[] | undefined, labels: string[] | undefined, milestone: number | undefined, + projects: IProject[] | undefined, lineNumber: number | undefined, insertIndex: number | undefined, originUri?: vscode.Uri, @@ -1189,12 +1208,16 @@ ${body ?? ''}\n labels, milestone }; + if (!(await this.verifyLabels(folderManager, createParams))) { return false; } progress.report({ message: vscode.l10n.t('Creating issue in {0}...', `${createParams.owner}/${createParams.repo}`) }); const issue = await folderManager.createIssue(createParams); if (issue) { + if (projects) { + await issue.updateProjects(projects); + } if (document !== undefined && insertIndex !== undefined && lineNumber !== undefined) { const edit: vscode.WorkspaceEdit = new vscode.WorkspaceEdit(); const insertText: string = diff --git a/src/issues/issueFile.ts b/src/issues/issueFile.ts index 4003aed743..6083abdfeb 100644 --- a/src/issues/issueFile.ts +++ b/src/issues/issueFile.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; +import { IProject } from '../github/interface'; import { RepositoriesManager } from '../github/repositoriesManager'; export const NEW_ISSUE_SCHEME = 'newIssue'; @@ -12,6 +13,7 @@ export const NEW_ISSUE_FILE = 'NewIssue.md'; export const ASSIGNEES = vscode.l10n.t('Assignees:'); export const LABELS = vscode.l10n.t('Labels:'); export const MILESTONE = vscode.l10n.t('Milestone:'); +export const PROJECTS = vscode.l10n.t('Projects:'); const NEW_ISSUE_CACHE = 'newIssue.cache'; @@ -91,7 +93,7 @@ export class NewIssueFileCompletionProvider implements vscode.CompletionItemProv _context: vscode.CompletionContext, ): Promise { const line = document.lineAt(position.line).text; - if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE)) { + if (!line.startsWith(LABELS) && !line.startsWith(MILESTONE) && !line.startsWith(PROJECTS)) { return []; } const originFile = extractIssueOriginFromQuery(document.uri); @@ -108,6 +110,8 @@ export class NewIssueFileCompletionProvider implements vscode.CompletionItemProv return this.provideLabelCompletionItems(folderManager, defaults); } else if (line.startsWith(MILESTONE)) { return this.provideMilestoneCompletionItems(folderManager); + } else if (line.startsWith(PROJECTS)) { + return this.provideProjectCompletionItems(folderManager); } else { return []; } @@ -131,6 +135,15 @@ export class NewIssueFileCompletionProvider implements vscode.CompletionItemProv return item; }); } + + private async provideProjectCompletionItems(folderManager: FolderRepositoryManager): Promise { + const projects = await (await folderManager.getPullRequestDefaultRepo())?.getProjects() ?? []; + return projects.map(project => { + const item = new vscode.CompletionItem(project.title, vscode.CompletionItemKind.Event); + item.commitCharacters = [' ', ',']; + return item; + }); + } } export class NewIssueCache { @@ -154,7 +167,7 @@ export class NewIssueCache { } } -export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri } | undefined> { +export async function extractMetadataFromFile(repositoriesManager: RepositoriesManager): Promise<{ labels: string[] | undefined, milestone: number | undefined, projects: IProject[] | undefined, assignees: string[] | undefined, title: string, body: string | undefined, originUri: vscode.Uri } | undefined> { let text: string; if ( !vscode.window.activeTextEditor || @@ -232,6 +245,22 @@ export async function extractMetadataFromFile(repositoriesManager: RepositoriesM text = text.substring(lines[0].length).trim(); } } + let projects: IProject[] | undefined; + if (text.startsWith(PROJECTS)) { + const lines = text.split(/\r\n|\n/, 1); + if (lines.length === 1) { + const repoProjects = await folderManager.getAllProjects(repo); + projects = lines[0].substring(PROJECTS.length) + .split(',') + .map(value => { + value = value.trim(); + return repoProjects.find(project => project.title === value); + }) + .filter((project): project is IProject => !!project); + + text = text.substring(lines[0].length).trim(); + } + } const body = text ?? ''; - return { labels, milestone, assignees, title, body, originUri }; + return { labels, milestone, projects, assignees, title, body, originUri }; } diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 7317e8d88a..08fe5eca24 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -89,6 +89,14 @@ export class CreatePullRequestHelper implements vscode.Disposable { }), ); + this._disposables.push( + vscode.commands.registerCommand('pr.addProjectsToNewPr', _ => { + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addProjects(); + } + }), + ); + this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuCreate', () => { if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index 7355aab29e..de24d699fb 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -204,6 +204,7 @@ export class CreatePRContextNew { autoMerge: !!this.createParams.autoMerge, autoMergeMethod: this.createParams.autoMergeMethod, labels: this.createParams.labels ?? [], + projects: this.createParams.projects ?? [], assignees: this.createParams.assignees ?? [], reviewers: this.createParams.reviewers ?? [], milestone: this.createParams.milestone @@ -308,6 +309,7 @@ export class CreatePRContextNew { case 'set-labels': case 'set-assignees': case 'set-reviewers': + case 'set-projects': if (!message.params) { return; } diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index 3952ef223d..ef79a3cced 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -40,5 +40,6 @@ export const assigneeIcon = ; export const labelIcon = ; export const milestoneIcon = ; +export const projectIcon = ; export const sparkleIcon = ; export const stopIcon = ; diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx index 5fd4520b85..c41761e03e 100644 --- a/webviews/createPullRequestViewNew/app.tsx +++ b/webviews/createPullRequestViewNew/app.tsx @@ -12,7 +12,7 @@ import PullRequestContextNew from '../common/createContextNew'; import { ErrorBoundary } from '../common/errorBoundary'; import { LabelCreate } from '../common/label'; import { ContextDropdown } from '../components/contextDropdown'; -import { assigneeIcon, labelIcon, milestoneIcon, prBaseIcon, prMergeIcon, reviewerIcon, sparkleIcon, stopIcon } from '../components/icon'; +import { assigneeIcon, labelIcon, milestoneIcon, prBaseIcon, prMergeIcon, projectIcon, reviewerIcon, sparkleIcon, stopIcon } from '../components/icon'; import { Avatar } from '../components/user'; type CreateMethod = 'create-draft' | 'create' | 'create-automerge-squash' | 'create-automerge-rebase' | 'create-automerge-merge'; @@ -311,6 +311,20 @@ export function main() { : null} + + {params.projects && (params.projects.length > 0) ? +
+ +
    activateCommand(e.nativeEvent, 'pr.changeProjects')} + onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeProjects')} + > +
  • + {params.projects.map(project => {project.title})} +
  • +
+
+ : null}