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) ?
+
+
{projectIcon}
+
activateCommand(e.nativeEvent, 'pr.changeProjects')}
+ onKeyPress={(e) => activateCommand(e.nativeEvent, 'pr.changeProjects')}
+ >
+ -
+ {params.projects.map(project => {project.title})}
+
+
+
+ : null}