From 7237de6d8fd7b4bacbd1fc01d5e5e931751f49f7 Mon Sep 17 00:00:00 2001 From: Alex Ross Date: Tue, 4 Jul 2023 13:43:31 +0200 Subject: [PATCH] Hook up reviewers, assignees, and milestones in create view (#4998) and reuse quickpicks from the PR description Part of #4403 --- common/views.ts | 23 +- src/github/createPRViewProviderNew.ts | 116 +++++++- src/github/pullRequestOverview.ts | 329 +-------------------- src/github/quickPicks.ts | 335 ++++++++++++++++++++++ src/view/createPullRequestHelper.ts | 12 +- webviews/common/createContextNew.ts | 19 +- webviews/createPullRequestViewNew/app.tsx | 91 +++--- 7 files changed, 543 insertions(+), 382 deletions(-) create mode 100644 src/github/quickPicks.ts diff --git a/common/views.ts b/common/views.ts index 4cedf7cc6..54ce5149e 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 { ILabel, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; +import { IAccount, ILabel, IMilestone, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface'; export interface RemoteInfo { owner: string; @@ -66,6 +66,24 @@ export interface CreatePullRequest { labels: ILabel[]; } +export interface CreatePullRequestNew { + title: string; + body: string; + owner: string; + repo: string; + base: string + compareBranch: string; + compareOwner: string; + compareRepo: string; + draft: boolean; + autoMerge: boolean; + autoMergeMethod?: MergeMethod; + labels: ILabel[]; + assignees: IAccount[]; + reviewers: (IAccount | ITeam)[]; + milestone?: IMilestone; +} + // #region new create view export interface CreateParamsNew { @@ -85,6 +103,9 @@ export interface CreateParamsNew { isDraftDefault: boolean; isDraft?: boolean; labels?: ILabel[]; + assignees?: IAccount[]; + reviewers?: (IAccount | ITeam)[]; + milestone?: IMilestone; isDarkTheme?: boolean; validate?: boolean; diff --git a/src/github/createPRViewProviderNew.ts b/src/github/createPRViewProviderNew.ts index ce6814548..18938d71f 100644 --- a/src/github/createPRViewProviderNew.ts +++ b/src/github/createPRViewProviderNew.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequest, RemoteInfo } from '../../common/views'; +import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo } from '../../common/views'; import type { Branch, Ref } from '../api/api'; import { GitHubServerType } from '../common/authentication'; import { commands, contexts } from '../common/executeCommands'; @@ -19,6 +19,7 @@ import { PUSH_BRANCH, SET_AUTO_MERGE, } from '../common/settingKeys'; +import { asPromise, compareIgnoreCase, formatError } from '../common/utils'; import { getNonce, IRequestMessage, WebviewViewBase } from '../common/webview'; import { byRemoteName, @@ -28,10 +29,11 @@ import { titleAndBodyFrom, } from './folderRepositoryManager'; import { GitHubRepository } from './githubRepository'; -import { ILabel, MergeMethod, RepoAccessAndMergeMethods } from './interface'; +import { IAccount, ILabel, IMilestone, isTeam, ITeam, MergeMethod, RepoAccessAndMergeMethods } from './interface'; import { PullRequestGitHelper } from './pullRequestGitHelper'; import { PullRequestModel } from './pullRequestModel'; import { getDefaultMergeMethod } from './pullRequestOverview'; +import { getAssigneesQuickPickItems, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; import { ISSUE_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 @@ -497,6 +499,102 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements } } + private async setAssignees(pr: PullRequestModel, assignees: IAccount[]): Promise { + if (assignees.length) { + await pr.addAssignees(assignees.map(assignee => assignee.login)); + } else { + await this.autoAssign(pr); + } + } + + private async setReviewers(pr: PullRequestModel, reviewers: (IAccount | ITeam)[]): Promise { + if (reviewers.length) { + const users: string[] = []; + const teams: string[] = []; + for (const reviewer of reviewers) { + if (isTeam(reviewer)) { + teams.push(reviewer.id); + } else { + users.push(reviewer.id); + } + } + await pr.requestReview(users, teams); + } + } + + private setMilestone(pr: PullRequestModel, milestone: IMilestone | undefined): void { + if (milestone) { + pr.updateMilestone(milestone.id); + } + } + + private async getRemote(): Promise { + return (await this._folderRepositoryManager.getGitHubRemotes()).find(remote => compareIgnoreCase(remote.owner, this._baseRemote.owner) === 0 && compareIgnoreCase(remote.repositoryName, this._baseRemote.repositoryName) === 0)!; + } + + private milestone: IMilestone | undefined; + public async addMilestone(): Promise { + const remote = await this.getRemote(); + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + + return getMilestoneFromQuickPick(this._folderRepositoryManager, repo, (milestone) => { + this.milestone = milestone; + return this._postMessage({ + command: 'set-milestone', + params: { milestone: this.milestone } + }); + }); + } + + private reviewers: (IAccount | ITeam)[] = []; + public async addReviewers(): Promise { + let quickPick: vscode.QuickPick | undefined; + const remote = await this.getRemote(); + try { + const repo = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.remoteName === remote.remoteName)!; + const [metadata, author, teamsCount] = await Promise.all([repo?.getMetadata(), this._folderRepositoryManager.getCurrentUser(), this._folderRepositoryManager.getOrgTeamsCount(repo)]); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, remote.remoteName, !!metadata?.organization, teamsCount, author, this.reviewers.map(reviewer => { return { reviewer, state: 'REQUESTED' }; }), []); + quickPick.busy = false; + const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { + return quickPick!.selectedItems.filter(item => item.reviewer) as (vscode.QuickPickItem & { reviewer: IAccount | ITeam })[] | undefined; + }); + const hidePromise = asPromise(quickPick.onDidHide); + const allReviewers = await Promise.race<(vscode.QuickPickItem & { reviewer: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); + quickPick.busy = true; + + if (allReviewers) { + this.reviewers = allReviewers.map(item => item.reviewer); + this._postMessage({ + command: 'set-reviewers', + params: { reviewers: this.reviewers } + }); + } + } catch (e) { + Logger.error(formatError(e)); + vscode.window.showErrorMessage(formatError(e)); + } finally { + quickPick?.hide(); + quickPick?.dispose(); + } + } + + private assignees: IAccount[] = []; + public async addAssignees(): Promise { + const remote = await this.getRemote(); + const assigneesToAdd = await vscode.window.showQuickPick(getAssigneesQuickPickItems(this._folderRepositoryManager, remote.remoteName, this.assignees), + { canPickMany: true, placeHolder: vscode.l10n.t('Add Assignees') }); + if (assigneesToAdd) { + const addedAssignees = assigneesToAdd.map(assignee => assignee.assignee).filter((assignee): assignee is IAccount => !!assignee); + this.assignees = addedAssignees; + this._postMessage({ + command: 'set-assignees', + params: { assignees: this.assignees } + }); + } + } + private labels: ILabel[] = []; public async addLabels(): Promise { let newLabels: ILabel[] = []; @@ -518,7 +616,7 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements const labelsToAdd = await vscode.window.showQuickPick( getLabelOptions(this._folderRepositoryManager, this.labels, this._baseRemote), - { canPickMany: true, placeHolder: 'Apply labels' }, + { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, ); if (labelsToAdd) { @@ -568,15 +666,17 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements } } - private async create(message: IRequestMessage): Promise { + private async create(message: IRequestMessage): Promise { Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProviderNew.ID); - function postCreate(createdPR: PullRequestModel) { + const postCreate = (createdPR: PullRequestModel) => { return Promise.all([ this.setLabels(createdPR, message.args.labels), this.enableAutoMerge(createdPR, message.args.autoMerge, message.args.autoMergeMethod), - this.autoAssign(createdPR)]); - } + this.setAssignees(createdPR, message.args.assignees), + this.setReviewers(createdPR, message.args.reviewers), + this.setMilestone(createdPR, message.args.milestone)]); + }; vscode.window.withProgress({ location: { viewId: 'github:createPullRequest' } }, () => { return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { @@ -689,7 +789,7 @@ export class CreatePullRequestViewProviderNew extends WebviewViewBase implements return this.getTitleAndDescription(compareBranch, this._baseBranch); } - private async cancel(message: IRequestMessage) { + private async cancel(message: IRequestMessage) { vscode.commands.executeCommand('setContext', 'github:createPullRequest', false); this._onDone.fire(undefined); // Re-fetch the automerge info so that it's updated for next time. diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index ec797e925..c4da1c916 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -10,17 +10,14 @@ import { IComment } from '../common/comment'; import Logger from '../common/logger'; import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; -import { DataUri } from '../common/uri'; import { asPromise, dispose, formatError } from '../common/utils'; import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { FolderRepositoryManager } from './folderRepositoryManager'; -import { TeamReviewerRefreshKind } from './githubRepository'; import { GithubItemStateEnum, IAccount, IMilestone, isTeam, - ISuggestedReviewer, ITeam, MergeMethod, MergeMethodsAvailability, @@ -31,14 +28,9 @@ import { import { IssueOverviewPanel } from './issueOverview'; import { PullRequestModel } from './pullRequestModel'; import { PullRequestView } from './pullRequestOverviewCommon'; +import { getAssigneesQuickPickItems, getMilestoneFromQuickPick, reviewersQuickPick } from './quickPicks'; import { isInCodespaces, parseReviewers, vscodeDevPrLink } from './utils'; -type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; - -function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { - return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; -} - export class PullRequestOverviewPanel extends IssueOverviewPanel { public static ID: string = 'PullRequestOverviewPanel'; /** @@ -361,230 +353,16 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel { - if (!suggestedReviewers) { - return []; - } - - const allAssignableUsers = await this._folderRepositoryManager.getAssignableUsers(); - const allTeamReviewers = this._item.base.isInOrganization ? await this._folderRepositoryManager.getTeamReviewers(refreshKind) : []; - const teamReviewers: ITeam[] = allTeamReviewers[this._item.remote.remoteName] ?? []; - const assignableUsers: (IAccount | ITeam)[] = [...teamReviewers]; - assignableUsers.push(...allAssignableUsers[this._item.remote.remoteName]); - let hasTeams = teamReviewers.length > 0; - - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([ - this._item.author.login, - ...this._existingReviewers.map(reviewer => { - if (isTeam(reviewer.reviewer)) { - hasTeams = true; - } - return reviewerId(reviewer.reviewer); - }), - ]); - - const reviewers: Promise<(vscode.QuickPickItem & { reviewer?: IAccount | ITeam })>[] = []; - // Start will all existing reviewers so they show at the top - for (const reviewer of this._existingReviewers) { - const label = isTeam(reviewer.reviewer) ? `$(organization) ${reviewer.reviewer.org}/${reviewer.reviewer.slug}` : `${hasTeams ? `$(account) ` : ''}${reviewer.reviewer.login}`; - reviewers.push(DataUri.avatarCircleAsImageDataUri(reviewer.reviewer, 16, 16).then(avatarUrl => { - return { - label, - description: reviewer.reviewer.name, - reviewer: reviewer.reviewer, - picked: true, - iconPath: avatarUrl - }; - })); - } - - for (const user of suggestedReviewers) { - const { login, name, isAuthor, isCommenter } = user; - if (skipList.has(login)) { - continue; - } - - const suggestionReason: string = - isAuthor && isCommenter - ? vscode.l10n.t('Recently edited and reviewed changes to these files') - : isAuthor - ? vscode.l10n.t('Recently edited these files') - : isCommenter - ? vscode.l10n.t('Recently reviewed changes to these files') - : vscode.l10n.t('Suggested reviewer'); - - const label = `${hasTeams ? `$(account) ` : ''}${login}`; - reviewers.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { - return { - label, - description: name, - detail: suggestionReason, - reviewer: user, - iconPath: avatarUrl - }; - })); - // this user shouldn't be added later from assignable users list - skipList.add(login); - } - - for (const user of assignableUsers) { - if (skipList.has(reviewerId(user))) { - continue; - } - - const label = isTeam(user) ? `$(organization) ${user.org}/${user.slug}` : `${hasTeams ? `$(account) ` : ''}${user.login}`; - reviewers.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { - return { - label, - description: user.name, - reviewer: user, - iconPath: avatarUrl - }; - })); - } - - if (reviewers.length === 0) { - reviewers.push(Promise.resolve({ - label: vscode.l10n.t('No reviewers available for this repository') - })); - } - - return Promise.all(reviewers); - } - - private async getAssigneesQuickPickItems(): - Promise<(vscode.QuickPickItem & { assignee?: IAccount })[]> { - - const [allAssignableUsers, { participants, viewer }] = await Promise.all([ - this._folderRepositoryManager.getAssignableUsers(), - this._folderRepositoryManager.getPullRequestParticipants(this._item.githubRepository, this._item.number) - ]); - - let assignableUsers = allAssignableUsers[this._item.remote.remoteName]; - - assignableUsers = assignableUsers ?? []; - // used to track logins that shouldn't be added to pick list - // e.g. author, existing and already added reviewers - const skipList: Set = new Set([...(this._item.assignees?.map(assignee => assignee.login) ?? [])]); - - const assignees: Promise<(vscode.QuickPickItem & { assignee?: IAccount })>[] = []; - // Start will all currently assigned so they show at the top - for (const current of (this._item.assignees ?? [])) { - assignees.push(DataUri.avatarCircleAsImageDataUri(current, 16, 16).then(avatarUrl => { - return { - label: current.login, - description: current.name, - assignee: current, - picked: true, - iconPath: avatarUrl - }; - })); - } - - // Check if the viewer is allowed to be assigned to the PR - if (!skipList.has(viewer.login) && (assignableUsers.findIndex((assignableUser: IAccount) => assignableUser.login === viewer.login) !== -1)) { - assignees.push(DataUri.avatarCircleAsImageDataUri(viewer, 16, 16).then(avatarUrl => { - return { - label: viewer.login, - description: viewer.name, - assignee: viewer, - iconPath: avatarUrl - }; - })); - skipList.add(viewer.login); - } - - for (const suggestedReviewer of participants) { - if (skipList.has(suggestedReviewer.login)) { - continue; - } - - assignees.push(DataUri.avatarCircleAsImageDataUri(suggestedReviewer, 16, 16).then(avatarUrl => { - return { - label: suggestedReviewer.login, - description: suggestedReviewer.name, - assignee: suggestedReviewer, - iconPath: avatarUrl - }; - })); - // this user shouldn't be added later from assignable users list - skipList.add(suggestedReviewer.login); - } - - if (assignees.length !== 0) { - assignees.unshift(Promise.resolve({ - kind: vscode.QuickPickItemKind.Separator, - label: vscode.l10n.t('Suggestions') - })); - } - - assignees.push(Promise.resolve({ - kind: vscode.QuickPickItemKind.Separator, - label: vscode.l10n.t('Users') - })); - - for (const user of assignableUsers) { - if (skipList.has(user.login)) { - continue; - } - - assignees.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { - return { - label: user.login, - description: user.name, - assignee: user, - iconPath: avatarUrl - }; - })); - } - - if (assignees.length === 0) { - assignees.push(Promise.resolve({ - label: vscode.l10n.t('No assignees available for this repository') - })); - } - - return Promise.all(assignees); - } - private async changeReviewers(message: IRequestMessage): Promise { - const quickPick = vscode.window.createQuickPick(); - // The quick-max is used to show the "update reviewers" button. If the number of teams is less than the quick-max, then they'll be automatically updated when the quick pick is opened. - const quickMaxTeamReviewers = 100; + let quickPick: vscode.QuickPick | undefined; + try { - quickPick.busy = true; - quickPick.canSelectMany = true; - quickPick.matchOnDescription = true; - quickPick.show(); - const updateItems = async (refreshKind: TeamReviewerRefreshKind) => { - const slowWarning = setTimeout(() => { - quickPick.placeholder = vscode.l10n.t('Getting team reviewers can take several minutes. Results will be cached.'); - }, 3000); - quickPick.items = await this.getReviewersQuickPickItems(this._item.suggestedReviewers, refreshKind); - clearTimeout(slowWarning); - quickPick.selectedItems = quickPick.items.filter(item => item.picked); - quickPick.placeholder = undefined; - }; - - await updateItems((this._teamsCount !== 0 && this._teamsCount <= quickMaxTeamReviewers) ? TeamReviewerRefreshKind.Try : TeamReviewerRefreshKind.None); - if (this._item.base.isInOrganization) { - quickPick.buttons = [{ iconPath: new vscode.ThemeIcon('organization'), tooltip: vscode.l10n.t('Show or refresh team reviewers') }]; - } - quickPick.onDidTriggerButton(() => { - quickPick.busy = true; - quickPick.ignoreFocusOut = true; - updateItems(TeamReviewerRefreshKind.Force).then(() => { - quickPick.ignoreFocusOut = false; - quickPick.busy = false; - }); - }); + quickPick = await reviewersQuickPick(this._folderRepositoryManager, this._item.remote.remoteName, this._item.base.isInOrganization, this._teamsCount, this._item.author, this._existingReviewers, this._item.suggestedReviewers); quickPick.busy = false; const acceptPromise = asPromise(quickPick.onDidAccept).then(() => { - return quickPick.selectedItems.filter(item => item.reviewer) as (vscode.QuickPickItem & { reviewer: IAccount | ITeam })[] | undefined; + return quickPick!.selectedItems.filter(item => item.reviewer) as (vscode.QuickPickItem & { reviewer: IAccount | ITeam })[] | undefined; }); const hidePromise = asPromise(quickPick.onDidHide); const allReviewers = await Promise.race<(vscode.QuickPickItem & { reviewer: IAccount | ITeam })[] | void>([acceptPromise, hidePromise]); @@ -626,98 +404,13 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { - try { - const githubRepository = this._item.githubRepository; - async function getMilestoneOptions(): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { - const milestones = await githubRepository.getMilestones(); - if (!milestones.length) { - return [ - { - label: vscode.l10n.t('No milestones created for this repository.'), - }, - ]; - } - - return milestones.map(result => { - return { - label: result.title, - id: result.id, - milestone: result, - }; - }); - } - - const quickPick = vscode.window.createQuickPick(); - quickPick.busy = true; - quickPick.canSelectMany = false; - quickPick.title = vscode.l10n.t('Select a milestone to add'); - quickPick.buttons = [{ - iconPath: new vscode.ThemeIcon('add'), - tooltip: 'Create', - }]; - quickPick.onDidTriggerButton((_) => { - quickPick.hide(); - - const inputBox = vscode.window.createInputBox(); - inputBox.title = vscode.l10n.t('Create new milestone'); - inputBox.placeholder = vscode.l10n.t('New milestone title'); - if (quickPick.value !== '') { - inputBox.value = quickPick.value; - } - inputBox.show(); - inputBox.onDidAccept(async () => { - inputBox.hide(); - if (inputBox.value === '') { - return; - } - if (inputBox.value.length > 255) { - vscode.window.showErrorMessage(vscode.l10n.t(`Failed to create milestone: The title can contain a maximum of 255 characters`)); - return; - } - // Check if milestone already exists (only check open ones) - for (const existingMilestone of quickPick.items) { - if (existingMilestone.label === inputBox.value) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone \'{0}\' already exists', inputBox.value)); - return; - } - } - try { - const milestone = await this._folderRepositoryManager.createMilestone(githubRepository, inputBox.value); - if (milestone !== undefined) { - await this.updateMilestone(milestone, message); - } - } catch (e) { - if (e.errors && Array.isArray(e.errors) && e.errors.find(error => error.code === 'already_exists') !== undefined) { - vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone already exists and might be closed')); - } - else { - vscode.window.showErrorMessage(`Failed to create milestone: ${formatError(e)}`); - } - } - }); - }); - - quickPick.show(); - quickPick.items = await getMilestoneOptions(); - quickPick.busy = false; - - quickPick.onDidAccept(async () => { - quickPick.hide(); - const milestoneToAdd = quickPick.selectedItems[0]; - if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { - await this.updateMilestone(milestoneToAdd.milestone, message); - } - }); - - } catch (e) { - vscode.window.showErrorMessage(`Failed to add milestone: ${formatError(e)}`); - } + return getMilestoneFromQuickPick(this._folderRepositoryManager, this._item.githubRepository, this.updateMilestone, message); } private async updateMilestone(milestone: IMilestone, message: IRequestMessage) { @@ -744,7 +437,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel item.picked); quickPick.busy = false; diff --git a/src/github/quickPicks.ts b/src/github/quickPicks.ts new file mode 100644 index 000000000..79fbf8ef5 --- /dev/null +++ b/src/github/quickPicks.ts @@ -0,0 +1,335 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import * as vscode from 'vscode'; +import { DataUri } from '../common/uri'; +import { formatError } from '../common/utils'; +import { IRequestMessage } from '../common/webview'; +import { FolderRepositoryManager } from './folderRepositoryManager'; +import { GitHubRepository, TeamReviewerRefreshKind } from './githubRepository'; +import { IAccount, IMilestone, isTeam, ISuggestedReviewer, ITeam, reviewerId, ReviewState } from './interface'; +import { PullRequestModel } from './pullRequestModel'; + +export async function getAssigneesQuickPickItems(folderRepositoryManager: FolderRepositoryManager, remoteName: string, alreadyAssigned: IAccount[], item?: PullRequestModel): + Promise<(vscode.QuickPickItem & { assignee?: IAccount })[]> { + + const [allAssignableUsers, participantsAndViewer] = await Promise.all([ + folderRepositoryManager.getAssignableUsers(), + item ? folderRepositoryManager.getPullRequestParticipants(item.githubRepository, item.number) : undefined + ]); + const viewer = participantsAndViewer?.viewer; + const participants = participantsAndViewer?.participants ?? []; + + let assignableUsers = allAssignableUsers[remoteName]; + + assignableUsers = assignableUsers ?? []; + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set([...(alreadyAssigned.map(assignee => assignee.login) ?? [])]); + + const assignees: Promise<(vscode.QuickPickItem & { assignee?: IAccount })>[] = []; + // Start will all currently assigned so they show at the top + for (const current of (alreadyAssigned ?? [])) { + assignees.push(DataUri.avatarCircleAsImageDataUri(current, 16, 16).then(avatarUrl => { + return { + label: current.login, + description: current.name, + assignee: current, + picked: true, + iconPath: avatarUrl + }; + })); + } + + // Check if the viewer is allowed to be assigned to the PR + if (viewer && !skipList.has(viewer.login) && (assignableUsers.findIndex((assignableUser: IAccount) => assignableUser.login === viewer.login) !== -1)) { + assignees.push(DataUri.avatarCircleAsImageDataUri(viewer, 16, 16).then(avatarUrl => { + return { + label: viewer.login, + description: viewer.name, + assignee: viewer, + iconPath: avatarUrl + }; + })); + skipList.add(viewer.login); + } + + for (const suggestedReviewer of participants) { + if (skipList.has(suggestedReviewer.login)) { + continue; + } + + assignees.push(DataUri.avatarCircleAsImageDataUri(suggestedReviewer, 16, 16).then(avatarUrl => { + return { + label: suggestedReviewer.login, + description: suggestedReviewer.name, + assignee: suggestedReviewer, + iconPath: avatarUrl + }; + })); + // this user shouldn't be added later from assignable users list + skipList.add(suggestedReviewer.login); + } + + if (assignees.length !== 0) { + assignees.unshift(Promise.resolve({ + kind: vscode.QuickPickItemKind.Separator, + label: vscode.l10n.t('Suggestions') + })); + } + + assignees.push(Promise.resolve({ + kind: vscode.QuickPickItemKind.Separator, + label: vscode.l10n.t('Users') + })); + + for (const user of assignableUsers) { + if (skipList.has(user.login)) { + continue; + } + + assignees.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { + return { + label: user.login, + description: user.name, + assignee: user, + iconPath: avatarUrl + }; + })); + } + + if (assignees.length === 0) { + assignees.push(Promise.resolve({ + label: vscode.l10n.t('No assignees available for this repository') + })); + } + + return Promise.all(assignees); +} + +async function getReviewersQuickPickItems(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined, refreshKind: TeamReviewerRefreshKind, +): Promise<(vscode.QuickPickItem & { reviewer?: IAccount | ITeam })[]> { + if (!suggestedReviewers) { + return []; + } + + const allAssignableUsers = await folderRepositoryManager.getAssignableUsers(); + const allTeamReviewers = isInOrganization ? await folderRepositoryManager.getTeamReviewers(refreshKind) : []; + const teamReviewers: ITeam[] = allTeamReviewers[remoteName] ?? []; + const assignableUsers: (IAccount | ITeam)[] = [...teamReviewers]; + assignableUsers.push(...allAssignableUsers[remoteName]); + let hasTeams = teamReviewers.length > 0; + + // used to track logins that shouldn't be added to pick list + // e.g. author, existing and already added reviewers + const skipList: Set = new Set([ + author.login, + ...existingReviewers.map(reviewer => { + if (isTeam(reviewer.reviewer)) { + hasTeams = true; + } + return reviewerId(reviewer.reviewer); + }), + ]); + + const reviewers: Promise<(vscode.QuickPickItem & { reviewer?: IAccount | ITeam })>[] = []; + // Start will all existing reviewers so they show at the top + for (const reviewer of existingReviewers) { + const label = isTeam(reviewer.reviewer) ? `$(organization) ${reviewer.reviewer.org}/${reviewer.reviewer.slug}` : `${hasTeams ? `$(account) ` : ''}${reviewer.reviewer.login}`; + reviewers.push(DataUri.avatarCircleAsImageDataUri(reviewer.reviewer, 16, 16).then(avatarUrl => { + return { + label, + description: reviewer.reviewer.name, + reviewer: reviewer.reviewer, + picked: true, + iconPath: avatarUrl + }; + })); + } + + for (const user of suggestedReviewers) { + const { login, name, isAuthor, isCommenter } = user; + if (skipList.has(login)) { + continue; + } + + const suggestionReason: string = + isAuthor && isCommenter + ? vscode.l10n.t('Recently edited and reviewed changes to these files') + : isAuthor + ? vscode.l10n.t('Recently edited these files') + : isCommenter + ? vscode.l10n.t('Recently reviewed changes to these files') + : vscode.l10n.t('Suggested reviewer'); + + const label = `${hasTeams ? `$(account) ` : ''}${login}`; + reviewers.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { + return { + label, + description: name, + detail: suggestionReason, + reviewer: user, + iconPath: avatarUrl + }; + })); + // this user shouldn't be added later from assignable users list + skipList.add(login); + } + + for (const user of assignableUsers) { + if (skipList.has(reviewerId(user))) { + continue; + } + + const label = isTeam(user) ? `$(organization) ${user.org}/${user.slug}` : `${hasTeams ? `$(account) ` : ''}${user.login}`; + reviewers.push(DataUri.avatarCircleAsImageDataUri(user, 16, 16).then(avatarUrl => { + return { + label, + description: user.name, + reviewer: user, + iconPath: avatarUrl + }; + })); + } + + if (reviewers.length === 0) { + reviewers.push(Promise.resolve({ + label: vscode.l10n.t('No reviewers available for this repository') + })); + } + + return Promise.all(reviewers); +} + +export async function reviewersQuickPick(folderRepositoryManager: FolderRepositoryManager, remoteName: string, isInOrganization: boolean, teamsCount: number, author: IAccount, existingReviewers: ReviewState[], + suggestedReviewers: ISuggestedReviewer[] | undefined): Promise> { + const quickPick = vscode.window.createQuickPick(); + // The quick-max is used to show the "update reviewers" button. If the number of teams is less than the quick-max, then they'll be automatically updated when the quick pick is opened. + const quickMaxTeamReviewers = 100; + quickPick.busy = true; + quickPick.canSelectMany = true; + quickPick.matchOnDescription = true; + quickPick.show(); + const updateItems = async (refreshKind: TeamReviewerRefreshKind) => { + const slowWarning = setTimeout(() => { + quickPick.placeholder = vscode.l10n.t('Getting team reviewers can take several minutes. Results will be cached.'); + }, 3000); + quickPick.items = await getReviewersQuickPickItems(folderRepositoryManager, remoteName, isInOrganization, author, existingReviewers, suggestedReviewers, refreshKind); + clearTimeout(slowWarning); + quickPick.selectedItems = quickPick.items.filter(item => item.picked); + quickPick.placeholder = undefined; + }; + + await updateItems((teamsCount !== 0 && teamsCount <= quickMaxTeamReviewers) ? TeamReviewerRefreshKind.Try : TeamReviewerRefreshKind.None); + if (isInOrganization) { + quickPick.buttons = [{ iconPath: new vscode.ThemeIcon('organization'), tooltip: vscode.l10n.t('Show or refresh team reviewers') }]; + } + quickPick.onDidTriggerButton(() => { + quickPick.busy = true; + quickPick.ignoreFocusOut = true; + updateItems(TeamReviewerRefreshKind.Force).then(() => { + quickPick.ignoreFocusOut = false; + quickPick.busy = false; + }); + }); + return quickPick; +} + +type MilestoneQuickPickItem = vscode.QuickPickItem & { id: string; milestone: IMilestone }; + +function isMilestoneQuickPickItem(x: vscode.QuickPickItem | MilestoneQuickPickItem): x is MilestoneQuickPickItem { + return !!(x as MilestoneQuickPickItem).id && !!(x as MilestoneQuickPickItem).milestone; +} + +export async function getMilestoneFromQuickPick(folderRepositoryManager: FolderRepositoryManager, githubRepository: GitHubRepository, callback: (milestone: IMilestone, message?: IRequestMessage) => Promise, message?: IRequestMessage): Promise { + try { + async function getMilestoneOptions(): Promise<(MilestoneQuickPickItem | vscode.QuickPickItem)[]> { + const milestones = await githubRepository.getMilestones(); + if (!milestones.length) { + return [ + { + label: vscode.l10n.t('No milestones created for this repository.'), + }, + ]; + } + + return milestones.map(result => { + return { + label: result.title, + id: result.id, + milestone: result, + }; + }); + } + + const quickPick = vscode.window.createQuickPick(); + quickPick.busy = true; + quickPick.canSelectMany = false; + quickPick.title = vscode.l10n.t('Select a milestone to add'); + quickPick.buttons = [{ + iconPath: new vscode.ThemeIcon('add'), + tooltip: 'Create', + }]; + quickPick.onDidTriggerButton((_) => { + quickPick.hide(); + + const inputBox = vscode.window.createInputBox(); + inputBox.title = vscode.l10n.t('Create new milestone'); + inputBox.placeholder = vscode.l10n.t('New milestone title'); + if (quickPick.value !== '') { + inputBox.value = quickPick.value; + } + inputBox.show(); + inputBox.onDidAccept(async () => { + inputBox.hide(); + if (inputBox.value === '') { + return; + } + if (inputBox.value.length > 255) { + vscode.window.showErrorMessage(vscode.l10n.t(`Failed to create milestone: The title can contain a maximum of 255 characters`)); + return; + } + // Check if milestone already exists (only check open ones) + for (const existingMilestone of quickPick.items) { + if (existingMilestone.label === inputBox.value) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone \'{0}\' already exists', inputBox.value)); + return; + } + } + try { + const milestone = await folderRepositoryManager.createMilestone(githubRepository, inputBox.value); + if (milestone !== undefined) { + await callback(milestone, message); + } + } catch (e) { + if (e.errors && Array.isArray(e.errors) && e.errors.find(error => error.code === 'already_exists') !== undefined) { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to create milestone: The milestone already exists and might be closed')); + } + else { + vscode.window.showErrorMessage(`Failed to create milestone: ${formatError(e)}`); + } + } + }); + }); + + quickPick.show(); + quickPick.items = await getMilestoneOptions(); + quickPick.busy = false; + + quickPick.onDidAccept(async () => { + quickPick.hide(); + const milestoneToAdd = quickPick.selectedItems[0]; + if (milestoneToAdd && isMilestoneQuickPickItem(milestoneToAdd)) { + await callback(milestoneToAdd.milestone, message); + } + }); + } catch (e) { + vscode.window.showErrorMessage(`Failed to add milestone: ${formatError(e)}`); + } +} \ No newline at end of file diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 28a149c9d..6e4885cb7 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -62,13 +62,17 @@ export class CreatePullRequestHelper implements vscode.Disposable { this._disposables.push( vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { - return null; // TODO + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addAssignees(); + } }), ); this._disposables.push( vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { - return null; // TODO + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addReviewers(); + } }), ); @@ -80,7 +84,9 @@ export class CreatePullRequestHelper implements vscode.Disposable { this._disposables.push( vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { - return null; // TODO + if (this._createPRViewProvider instanceof CreatePullRequestViewProviderNew) { + return this._createPRViewProvider.addMilestone(); + } }), ); diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index 1fc08a43e..cb6bd5b92 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { createContext } from 'react'; -import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequest, RemoteInfo, ScrollPosition } from '../../common/views'; +import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, ScrollPosition } from '../../common/views'; import { getMessageHandler, MessageHandler, vscode } from './message'; const defaultCreateParams: CreateParamsNew = { @@ -12,7 +12,10 @@ const defaultCreateParams: CreateParamsNew = { showTitleValidationError: false, labels: [], isDraftDefault: false, - autoMergeDefault: false + autoMergeDefault: false, + assignees: [], + reviewers: [], + milestone: undefined, }; export class CreatePRContextNew { @@ -120,7 +123,7 @@ export class CreatePRContextNew { return isValid; }; - private copyParams(): CreatePullRequest { + private copyParams(): CreatePullRequestNew { return { title: this.createParams.pendingTitle!, body: this.createParams.pendingDescription!, @@ -133,13 +136,16 @@ export class CreatePRContextNew { draft: !!this.createParams.isDraft, autoMerge: !!this.createParams.autoMerge, autoMergeMethod: this.createParams.autoMergeMethod, - labels: this.createParams.labels ?? [] + labels: this.createParams.labels ?? [], + assignees: this.createParams.assignees ?? [], + reviewers: this.createParams.reviewers ?? [], + milestone: this.createParams.milestone }; } public submit = async (): Promise => { try { - const args: CreatePullRequest = this.copyParams(); + const args: CreatePullRequestNew = this.copyParams(); vscode.setState(defaultCreateParams); await this.postMessage({ command: 'pr.create', @@ -229,6 +235,9 @@ export class CreatePRContextNew { return; case 'set-labels': + case 'set-assignees': + case 'set-reviewers': + case 'set-milestone': if (!message.params) { return; } diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx index be9547e53..8f9c43694 100644 --- a/webviews/createPullRequestViewNew/app.tsx +++ b/webviews/createPullRequestViewNew/app.tsx @@ -7,12 +7,12 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'rea import { render } from 'react-dom'; import { CreateParamsNew, RemoteInfo } from '../../common/views'; import { compareIgnoreCase } from '../../src/common/utils'; +import { isTeam } from '../../src/github/interface'; import PullRequestContextNew from '../common/createContextNew'; import { ErrorBoundary } from '../common/errorBoundary'; import { LabelCreate } from '../common/label'; import { AutoMerge } from '../components/automergeSelect'; -import { closeIcon, gearIcon, prBaseIcon, prMergeIcon, chevronDownIcon } from '../components/icon'; -import { assigneeIcon, reviewerIcon, labelIcon, milestoneIcon } from '../components/icon'; +import { assigneeIcon, chevronDownIcon, gearIcon, labelIcon, milestoneIcon, prBaseIcon, prMergeIcon, reviewerIcon } from '../components/icon'; export const ChooseRemoteAndBranch = ({ onClick, defaultRemote, defaultBranch, isBase }: @@ -128,55 +128,52 @@ export function main() {
- { /* -
- {assigneeIcon} -
    -
  • deepak1556
  • -
  • hbons
  • -
  • alexr00
  • -
  • deepak1556
  • -
  • hbons
  • -
  • alexr00
  • -
  • deepak1556
  • -
  • hbons
  • -
  • alexr00
  • -
-
-
- {reviewerIcon} -
    -
  • alexr00
  • -
  • deepak1556
  • -
  • hbons
  • -
  • alexr00
  • -
  • deepak1556
  • -
  • hbons
  • -
  • alexr00
  • -
  • hbons
  • -
-
- */ } + + {params.assignees && (params.assignees.length > 0) ? +
+ {assigneeIcon} +
    + {params.assignees.map(assignee => +
  • + {assignee.login} +
  • )} +
+
+ : null} + + {params.reviewers && (params.reviewers.length > 0) ? +
+ {reviewerIcon} +
    + {params.reviewers.map(reviewer => +
  • + {isTeam(reviewer) ? reviewer.slug : reviewer.login} +
  • )} +
+
+ : null} {params.labels && (params.labels.length > 0) ? -
- {labelIcon} -
    { - ctx.postMessage({ command: 'pr.changeLabels', args: null }); - }}> - {params.labels.map(label => )} -
-
+
+ {labelIcon} +
    { + ctx.postMessage({ command: 'pr.changeLabels', args: null }); + }}> + {params.labels.map(label => )} +
+
: null} - { /* -
- {milestoneIcon} -
    -
  • January 2024
  • -
-
- */ } + {params.milestone ? +
+ {milestoneIcon} +
    +
  • + {params.milestone.title} +
  • +
+
+ : null}