diff --git a/common/views.ts b/common/views.ts index 1118325f6e..e5b0674167 100644 --- a/common/views.ts +++ b/common/views.ts @@ -89,6 +89,7 @@ export interface CreatePullRequestNew { // #region new create view export interface CreateParamsNew { + canModifyBranches: boolean; defaultBaseRemote?: RemoteInfo; defaultBaseBranch?: string; defaultCompareRemote?: RemoteInfo; diff --git a/package.json b/package.json index e818a4648f..7b7326a5fc 100644 --- a/package.json +++ b/package.json @@ -685,7 +685,7 @@ "id": "github:createPullRequestWebview", "type": "webview", "name": "%view.github.create.pull.request.name%", - "when": "github:createPullRequest", + "when": "github:createPullRequest || github:revertPullRequest", "visibility": "visible", "initialSize": 2 }, @@ -706,7 +706,7 @@ { "id": "prStatus:github", "name": "%view.pr.status.github.name%", - "when": "github:inReviewMode && !github:createPullRequest", + "when": "github:inReviewMode && !github:createPullRequest && !github:revertPullRequest", "icon": "$(git-pull-request)", "visibility": "visible", "initialSize": 3 @@ -715,7 +715,7 @@ "id": "github:activePullRequest", "type": "webview", "name": "%view.github.active.pull.request.name%", - "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && github:activePRCount <= 1", + "when": "github:inReviewMode && github:focusedReview && !github:createPullRequest && !github:revertPullRequest && github:activePRCount <= 1", "initialSize": 2 }, { diff --git a/src/@types/vscode.proposed.commentThreadApplicability.d.ts b/src/@types/vscode.proposed.commentThreadApplicability.d.ts index e09f5a34d6..fb99abb48b 100644 --- a/src/@types/vscode.proposed.commentThreadApplicability.d.ts +++ b/src/@types/vscode.proposed.commentThreadApplicability.d.ts @@ -28,5 +28,13 @@ declare module 'vscode' { * Worth noting that we already have this problem for the `comments` property. */ state?: CommentThreadState | { resolved?: CommentThreadState; applicability?: CommentThreadApplicability }; + readonly uri: Uri; + range: Range | undefined; + comments: readonly Comment[]; + collapsibleState: CommentThreadCollapsibleState; + canReply: boolean; + contextValue?: string; + label?: string; + dispose(): void; } } diff --git a/src/@types/vscode.proposed.fileComments.d.ts b/src/@types/vscode.proposed.fileComments.d.ts index 65ebaefc76..96e9b181bc 100644 --- a/src/@types/vscode.proposed.fileComments.d.ts +++ b/src/@types/vscode.proposed.fileComments.d.ts @@ -6,68 +6,27 @@ declare module 'vscode' { export interface CommentThread2 { - /** - * The uri of the document the thread has been created on. - */ - readonly uri: Uri; - /** * The range the comment thread is located within the document. The thread icon will be shown - * at the last line of the range. + * at the last line of the range. When set to undefined, the comment will be associated with the + * file, and not a specific range. */ range: Range | undefined; + } + /** + * The ranges a CommentingRangeProvider enables commenting on. + */ + export interface CommentingRanges { /** - * The ordered comments of the thread. - */ - comments: readonly Comment[]; - - /** - * Whether the thread should be collapsed or expanded when opening the document. - * Defaults to Collapsed. - */ - collapsibleState: CommentThreadCollapsibleState; - - /** - * Whether the thread supports reply. - * Defaults to true. - */ - canReply: boolean; - - /** - * Context value of the comment thread. This can be used to contribute thread specific actions. - * For example, a comment thread is given a context value as `editable`. When contributing actions to `comments/commentThread/title` - * using `menus` extension point, you can specify context value for key `commentThread` in `when` expression like `commentThread == editable`. - * ```json - * "contributes": { - * "menus": { - * "comments/commentThread/title": [ - * { - * "command": "extension.deleteCommentThread", - * "when": "commentThread == editable" - * } - * ] - * } - * } - * ``` - * This will show action `extension.deleteCommentThread` only for comment threads with `contextValue` is `editable`. - */ - contextValue?: string; - - /** - * The optional human-readable label describing the {@link CommentThread Comment Thread} + * Enables comments to be added to a file without a specific range. */ - label?: string; - - // from the commentThreadRelevance proposal - state?: CommentThreadState | { resolved?: CommentThreadState; applicability?: CommentThreadApplicability }; + enableFileComments: boolean; /** - * Dispose this comment thread. - * - * Once disposed, this comment thread will be removed from visible editors and Comment Panel when appropriate. + * The ranges which allow new comment threads creation. */ - dispose(): void; + ranges?: Range[]; } export interface CommentController { @@ -78,6 +37,6 @@ declare module 'vscode' { /** * Provide a list of ranges which allow new comment threads creation or null for a given document */ - provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; + provideCommentingRanges(document: TextDocument, token: CancellationToken): ProviderResult; } } diff --git a/src/commands.ts b/src/commands.ts index 497930bb20..8625413b35 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -60,7 +60,6 @@ function ensurePR(folderRepoManager: FolderRepositoryManager, pr?: PRNode | Pull } export async function openDescription( - context: vscode.ExtensionContext, telemetry: ITelemetry, pullRequestModel: PullRequestModel, descriptionNode: DescriptionNode | undefined, @@ -74,7 +73,7 @@ export async function openDescription( descriptionNode?.reveal(descriptionNode, { select: true, focus: true }); } // Create and show a new webview - await PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); + await PullRequestOverviewPanel.createOrShow(telemetry, folderManager.context.extensionUri, folderManager, pullRequest, undefined, preserveFocus); if (notificationProvider?.hasNotification(pullRequest)) { notificationProvider.markPrNotificationsAsRead(pullRequest); @@ -124,7 +123,7 @@ export function registerCommands( reposManager: RepositoriesManager, reviewsManager: ReviewsManager, telemetry: ITelemetry, - tree: PullRequestsTreeDataProvider + tree: PullRequestsTreeDataProvider, ) { context.subscriptions.push( vscode.commands.registerCommand( @@ -812,7 +811,7 @@ export function registerCommands( descriptionNode = reviewManager.changesInPrDataProvider.getDescriptionNode(folderManager); } - await openDescription(context, telemetry, pullRequestModel, descriptionNode, folderManager, !(argument instanceof DescriptionNode), !(argument instanceof RepositoryChangesNode), tree.notificationProvider); + await openDescription(telemetry, pullRequestModel, descriptionNode, folderManager, !(argument instanceof DescriptionNode), !(argument instanceof RepositoryChangesNode), tree.notificationProvider); }, ), ); @@ -843,7 +842,7 @@ export function registerCommands( const pullRequest = ensurePR(folderManager, pr); descriptionNode.reveal(descriptionNode, { select: true, focus: true }); // Create and show a new webview - PullRequestOverviewPanel.createOrShow(context.extensionUri, folderManager, pullRequest, true); + PullRequestOverviewPanel.createOrShow(telemetry, context.extensionUri, folderManager, pullRequest, true); /* __GDPR__ "pr.openDescriptionToTheSide" : {} diff --git a/src/extension.ts b/src/extension.ts index f7591eaeb9..a96baac8bc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -54,6 +54,7 @@ async function init( liveshareApiPromise: Promise, showPRController: ShowPullRequest, reposManager: RepositoriesManager, + createPrHelper: CreatePullRequestHelper ): Promise { context.subscriptions.push(Logger); Logger.appendLine('Git repository found, initializing review manager and pr tree view.'); @@ -146,8 +147,7 @@ async function init( const activePrViewCoordinator = new WebviewViewCoordinator(context); context.subscriptions.push(activePrViewCoordinator); - const createPrHelper = new CreatePullRequestHelper(); - context.subscriptions.push(createPrHelper); + let reviewManagerIndex = 0; const reviewManagers = reposManager.folderManagers.map( folderManager => new ReviewManager(reviewManagerIndex++, context, folderManager.repository, folderManager, telemetry, changesTree, tree, showPRController, activePrViewCoordinator, createPrHelper, git), @@ -170,7 +170,7 @@ async function init( Logger.appendLine(`Repo ${repo.rootUri} has already been setup.`); return; } - const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore); + const newFolderManager = new FolderRepositoryManager(reposManager.folderManagers.length, context, repo, telemetry, git, credentialStore, createPrHelper); reposManager.insertFolderManager(newFolderManager); const newReviewManager = new ReviewManager( reviewManagerIndex++, @@ -364,10 +364,12 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp Logger.appendLine('Looking for git repository'); const repositories = apiImpl.repositories; Logger.appendLine(`Found ${repositories.length} repositories during activation`); + const createPrHelper = new CreatePullRequestHelper(); + context.subscriptions.push(createPrHelper); let folderManagerIndex = 0; const folderManagers = repositories.map( - repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore), + repository => new FolderRepositoryManager(folderManagerIndex++, context, repository, telemetry, apiImpl, credentialStore, createPrHelper), ); context.subscriptions.push(...folderManagers); for (const folderManager of folderManagers) { @@ -379,7 +381,7 @@ async function deferredActivate(context: vscode.ExtensionContext, apiImpl: GitAp readOnlyMessage.isTrusted = { enabledCommands: ['pr.checkoutFromReadonlyFile'] }; context.subscriptions.push(vscode.workspace.registerFileSystemProvider(Schemes.Pr, inMemPRFileSystemProvider, { isReadonly: readOnlyMessage })); - await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager); + await init(context, apiImpl, credentialStore, repositories, prTree, liveshareApiPromise, showPRController, reposManager, createPrHelper); } export async function deactivate() { diff --git a/src/github/createPRViewProvider.ts b/src/github/createPRViewProvider.ts index 66bd98b0c6..a49496449d 100644 --- a/src/github/createPRViewProvider.ts +++ b/src/github/createPRViewProvider.ts @@ -42,61 +42,30 @@ import { PreReviewState } from './views'; 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 -export class CreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider, vscode.Disposable { - private static readonly ID = 'CreatePullRequestViewProvider'; +export interface BasePullRequestDataModel { + baseOwner: string; + repositoryName: string; +} + +export abstract class BaseCreatePullRequestViewProvider extends WebviewViewBase implements vscode.WebviewViewProvider, vscode.Disposable { + protected static readonly ID = 'CreatePullRequestViewProvider'; public readonly viewType = 'github:createPullRequestWebview'; - private _onDone = new vscode.EventEmitter(); + protected _onDone = new vscode.EventEmitter(); readonly onDone: vscode.Event = this._onDone.event; - private _defaultCompareBranch: string; - - private _firstLoad: boolean = true; + protected _firstLoad: boolean = true; + protected _canModifyBranches: boolean = true; constructor( - private readonly telemetry: ITelemetry, - private readonly model: CreatePullRequestDataModel, + protected readonly telemetry: ITelemetry, + protected readonly model: T, extensionUri: vscode.Uri, - private readonly _folderRepositoryManager: FolderRepositoryManager, - private readonly _pullRequestDefaults: PullRequestDefaults, + protected readonly _folderRepositoryManager: FolderRepositoryManager, + protected readonly _pullRequestDefaults: PullRequestDefaults, + protected _defaultCompareBranch: string ) { super(extensionUri); - this._defaultCompareBranch = this.model.compareBranch; - - this._disposables.push(this.model.onDidChange(async (e) => { - let baseRemote: RemoteInfo | undefined; - let baseBranch: string | undefined; - if (e.baseOwner) { - const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.baseOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; - baseRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; - baseBranch = this.model.baseBranch; - } - if (e.baseBranch) { - baseBranch = e.baseBranch; - } - let compareRemote: RemoteInfo | undefined; - let compareBranch: string | undefined; - if (e.compareOwner) { - const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.compareOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; - compareRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; - compareBranch = this.model.compareBranch; - } - if (e.compareBranch) { - compareBranch = e.compareBranch; - } - const params: Partial = { - baseRemote, - baseBranch, - compareRemote, - compareBranch - }; - // TODO: consider updating title and description - return this._postMessage({ - command: 'pr.initialize', - params, - }); - - })); } public resolveWebviewView( @@ -116,36 +85,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } } - - public async setDefaultCompareBranch(compareBranch: Branch | undefined) { - const branchChanged = compareBranch && (compareBranch.name !== this.model.compareBranch); - const currentCompareRemote = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.owner === this.model.compareOwner)?.remote.remoteName; - const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== currentCompareRemote); - if (branchChanged || branchRemoteChanged) { - this._defaultCompareBranch = compareBranch!.name!; - this.model.setCompareBranch(compareBranch!.name); - this.changeBranch(compareBranch!.name!, false).then(titleAndDescription => { - const params: Partial = { - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description, - compareBranch: compareBranch?.name, - defaultCompareBranch: compareBranch?.name - }; - if (!branchRemoteChanged) { - return this._postMessage({ - command: 'pr.initialize', - params, - }); - } - }); - } - } - - public show(compareBranch?: Branch): void { - if (compareBranch) { - this.setDefaultCompareBranch(compareBranch); // don't await, view will be updated when the branch is changed - } - + public show() { super.show(); } @@ -153,25 +93,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return vscode.window.withProgress({ location: { viewId: 'github:createPullRequestWebview' } }, task); } - private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - if (compareBranch.upstream) { - const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); - - if (headRepo) { - const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; - const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; - const compareResult = await origin.compareCommits(baseBranch, headBranch); - - return compareResult?.commits; - } - } - - return undefined; - } - - private async getPullRequestDefaultLabels(defaultBaseRemote: RemoteInfo): Promise { + protected async getPullRequestDefaultLabels(defaultBaseRemote: RemoteInfo): Promise { const pullRequestLabelSettings = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).inspect(PULL_REQUEST_LABELS); @@ -202,88 +124,9 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return defaultLabels; } - private async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { - let title: string = ''; - let description: string = ''; - const descrptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); - if (descrptionSource === 'none') { - return { title, description }; - } - - // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. - // By default, the base branch we use for comparison is the base branch of origin. Compare this to the - // compare branch if it has a GitHub remote. - const origin = await this._folderRepositoryManager.getOrigin(compareBranch); - - let useBranchName = this._pullRequestDefaults.base === compareBranch.name; - Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProvider.ID); - try { - const name = compareBranch.name; - const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ - this.getTotalGitHubCommits(compareBranch, baseBranch), - name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, - descrptionSource === 'template' ? await this.getPullRequestTemplate() : undefined - ]); - const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); - - Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProvider.ID); - if (totalNonMergeCommits === undefined) { - // There is no upstream branch. Use the last commit as the title and description. - useBranchName = false; - } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { - const defaultBranch = await origin.getDefaultBranch(); - useBranchName = defaultBranch !== compareBranch.name; - } - - if (name && !lastCommit) { - Logger.appendLine('Timeout getting last commit message', CreatePullRequestViewProvider.ID); - /* __GDPR__ - "pr.create.getCommitTimeout" : {} - */ - this.telemetry.sendTelemetryEvent('pr.create.getCommitTimeout'); - } - // Set title - if (useBranchName && name) { - title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; - } else if (name && lastCommit) { - title = lastCommit.title; - } - - // Set description - if (pullRequestTemplate && lastCommit?.body) { - description = `${lastCommit.body}\n\n${pullRequestTemplate}`; - } else if (pullRequestTemplate) { - description = pullRequestTemplate; - } else if (lastCommit?.body && (this._pullRequestDefaults.base !== compareBranch.name)) { - description = lastCommit.body; - } - - // If the description is empty, check to see if the title of the PR contains something that looks like an issue - if (!description) { - const issueExpMatch = title.match(ISSUE_EXPRESSION); - const match = parseIssueExpressionOutput(issueExpMatch); - if (match?.issueNumber && !match.name && !match.owner) { - description = `#${match.issueNumber}`; - const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); - - const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); - if (keyWordMatch) { - description = `${keyWordMatch[0]} ${description}`; - } - } - } - } catch (e) { - // Ignore and fall back to commit message - Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProvider.ID); - } - return { title, description }; - } - - private async getPullRequestTemplate(): Promise { - return this._folderRepositoryManager.getPullRequestTemplateBody(this.model.baseOwner); - } + protected abstract getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }>; - private async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { + protected async getMergeConfiguration(owner: string, name: string, refetch: boolean = false): Promise { const repo = await this._folderRepositoryManager.createGitHubRepositoryFromOwnerName(owner, name); return repo.getRepoAccessAndMergeMethods(refetch); } @@ -317,38 +160,16 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return this._alreadyInitializing; } - private async detectBaseMetadata(defaultCompareBranch: Branch, owner: string, repositoryName: string): Promise { - const settingValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch' | 'auto'>(CREATE_BASE_BRANCH); - if (!defaultCompareBranch.name || settingValue === 'repositoryDefault') { - return undefined; - } - const githubRepo = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, owner) === 0 && compareIgnoreCase(repo.remote.repositoryName, repositoryName) === 0); - if (settingValue === 'auto' && (await githubRepo?.getMetadata())?.fork) { - return undefined; - } + protected abstract detectBaseMetadata(defaultCompareBranch: Branch): Promise; - try { - const baseFromProvider = await this._folderRepositoryManager.repository.getBranchBase(defaultCompareBranch.name); - if (baseFromProvider?.name) { - const repo = this._folderRepositoryManager.findRepo(repo => repo.remote.remoteName === baseFromProvider.remote); - if (repo) { - return { - branch: baseFromProvider.name, - owner: repo.remote.owner, - repositoryName: repo.remote.repositoryName - }; - } - } - } catch (e) { - // Not all providers will support `getBranchBase` - return undefined; - } + protected getTitleAndDescriptionProvider(name?: string) { + return this._folderRepositoryManager.getTitleAndDescriptionProvider(name); } - private async doInitializeParams(): Promise { + protected async doInitializeParams(): Promise { const defaultCompareBranch = await this._folderRepositoryManager.repository.getBranch(this._defaultCompareBranch); const [detectedBaseMetadata, remotes, defaultOrigin] = await Promise.all([ - this.detectBaseMetadata(defaultCompareBranch, this.model.compareOwner, this.model.repositoryName), + this.detectBaseMetadata(defaultCompareBranch), this._folderRepositoryManager.getGitHubRemotes(), this._folderRepositoryManager.getOrigin(defaultCompareBranch)]); @@ -393,8 +214,8 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); - const useCopilot: boolean = !!this._folderRepositoryManager.getTitleAndDescriptionProvider('Copilot') && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION) === 'Copilot'); - const defaultTitleAndDescriptionProvider = this._folderRepositoryManager.getTitleAndDescriptionProvider()?.title; + const useCopilot: boolean = !!this.getTitleAndDescriptionProvider('Copilot') && (vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION) === 'Copilot'); + const defaultTitleAndDescriptionProvider = this.getTitleAndDescriptionProvider()?.title; if (defaultTitleAndDescriptionProvider) { /* __GDPR__ "pr.defaultTitleAndDescriptionProvider" : { @@ -408,6 +229,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs this.labels = labels; const params: CreateParamsNew = { + canModifyBranches: this._canModifyBranches, defaultBaseRemote, defaultBaseBranch, defaultCompareRemote, @@ -432,10 +254,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs reviewing: false }; - Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, CreatePullRequestViewProvider.ID); - - this.model.baseOwner = defaultBaseRemote.owner; - this.model.baseBranch = defaultBaseBranch; + Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, BaseCreatePullRequestViewProvider.ID); this._postMessage({ command: 'pr.initialize', @@ -444,200 +263,31 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return params; } + private async autoAssign(pr: PullRequestModel): Promise { + const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); + if (!configuration) { + return; + } + const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); + if (!resolved) { + return; + } + try { + await pr.addAssignees([resolved]); + } catch (e) { + Logger.error(`Unable to assign pull request to user ${resolved}.`); + } + } - private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { - const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); - return remotes.map(remote => { - return { - iconPath: new vscode.ThemeIcon('repo'), - label: `${remote.owner}/${remote.repositoryName}`, - remote: { - owner: remote.owner, - repositoryName: remote.repositoryName, - } - }; - }); + private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { + if (autoMerge && automergeMethod) { + return pr.enableAutoMerge(automergeMethod); + } } - private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> { - let branches: (string | Ref)[]; - if (isBase) { - // For the base, we only want to show branches from GitHub. - branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName); - } else { - // For the compare, we only want to show local branches. - branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name); - } - // TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list. - const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => { - const branchName = typeof branch === 'string' ? branch : branch.name!; - const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = { - iconPath: new vscode.ThemeIcon('git-branch'), - label: branchName, - remote: { - owner: githubRepository.remote.owner, - repositoryName: githubRepository.remote.repositoryName - }, - branch: branchName - }; - return pick; - }); - branchPicks.unshift({ - kind: vscode.QuickPickItemKind.Separator, - label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` - }); - branchPicks.unshift({ - iconPath: new vscode.ThemeIcon('repo'), - label: changeRepoMessage - }); - return branchPicks; - } - - private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { - const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); - - commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); - let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; - if (isBase) { - const baseRemoteChanged = this.model.baseOwner !== result.remote.owner; - const baseBranchChanged = baseRemoteChanged || this.model.baseBranch !== result.branch; - this.model.baseOwner = result.remote.owner; - this.model.baseBranch = result.branch; - const compareBranch = await this._folderRepositoryManager.repository.getBranch(this.model.compareBranch); - const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ - this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), - this.getTitleAndDescription(compareBranch, this.model.baseBranch), - this._folderRepositoryManager.mergeQueueMethodForBranch(this.model.baseBranch, this.model.baseOwner, this.model.repositoryName)]); - let autoMergeDefault = false; - if (mergeConfiguration.viewerCanAutoMerge) { - const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); - const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); - autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); - } - - chooseResult = { - baseRemote: result.remote, - baseBranch: result.branch, - defaultBaseBranch: defaultBranch, - defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), - allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, - baseHasMergeQueue: !!mergeQueueMethodForBranch, - mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, - autoMergeDefault, - defaultTitle: titleAndDescription.title, - defaultDescription: titleAndDescription.description - }; - if (baseRemoteChanged) { - /* __GDPR__ - "pr.create.changedBaseRemote" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); - } - if (baseBranchChanged) { - /* __GDPR__ - "pr.create.changedBaseBranch" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); - } - } else { - await this.changeBranch(result.branch, false); - chooseResult = { - compareRemote: result.remote, - compareBranch: result.branch, - defaultCompareBranch: defaultBranch - }; - /* __GDPR__ - "pr.create.changedCompare" : {} - */ - this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); - } - return chooseResult; - } - - private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { - this.cancelGenerateTitleAndDescription(); - const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); - let githubRepository = this._folderRepositoryManager.findRepo( - repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, - ); - - const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); - const remotePlaceholder = vscode.l10n.t('Choose a remote'); - const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); - const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); - - quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder; - quickPick.show(); - quickPick.busy = true; - quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : await this.remotePicks(isBase); - const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; - quickPick.activeItems = activeItem ? [activeItem] : []; - quickPick.busy = false; - const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { - quickPick.onDidAccept(async () => { - if (quickPick.selectedItems.length === 0) { - return; - } - const selectedPick = quickPick.selectedItems[0]; - if (selectedPick.label === chooseDifferentRemote) { - quickPick.busy = true; - quickPick.items = await this.remotePicks(isBase); - quickPick.busy = false; - quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; - } else if ((selectedPick.branch === undefined) && selectedPick.remote) { - const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; - quickPick.busy = true; - githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; - quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); - quickPick.placeholder = branchPlaceholder; - quickPick.busy = false; - } else if (selectedPick.branch && selectedPick.remote) { - const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; - resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); - } - }); - }); - const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); - const result = await Promise.race([remoteAndBranch, hidePromise]); - if (!result || !githubRepository) { - quickPick.hide(); - quickPick.dispose(); - return; - } - - quickPick.busy = true; - const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); - - quickPick.hide(); - quickPick.dispose(); - return this._replyMessage(message, chooseResult); - } - - private async autoAssign(pr: PullRequestModel): Promise { - const configuration = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get(ASSIGN_TO); - if (!configuration) { - return; - } - const resolved = await variableSubstitution(configuration, pr, undefined, (await this._folderRepositoryManager.getCurrentUser(pr.githubRepository))?.login); - if (!resolved) { - return; - } - try { - await pr.addAssignees([resolved]); - } catch (e) { - Logger.error(`Unable to assign pull request to user ${resolved}.`); - } - } - - private async enableAutoMerge(pr: PullRequestModel, autoMerge: boolean, automergeMethod: MergeMethod | undefined): Promise { - if (autoMerge && automergeMethod) { - return pr.enableAutoMerge(automergeMethod); - } - } - - private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { - if (labels.length > 0) { - await pr.setLabels(labels.map(label => label.name)); + private async setLabels(pr: PullRequestModel, labels: ILabel[]): Promise { + if (labels.length > 0) { + await pr.setLabels(labels.map(label => label.name)); } } @@ -760,54 +410,541 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs 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(); - }); + 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 { + let newLabels: ILabel[] = []; + + const labelsToAdd = await vscode.window.showQuickPick( + getLabelOptions(this._folderRepositoryManager, this.labels, this.model.baseOwner, this.model.repositoryName).then(options => { + newLabels = options.newLabels; + return options.labelPicks; + }) as Promise, + { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, + ); + + if (labelsToAdd) { + const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); + this.labels = addedLabels; + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + } + + private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { + const { label } = message.args; + if (!label) + return; + + const previousLabelsLength = this.labels.length; + this.labels = this.labels.filter(l => l.name !== label.name); + if (previousLabelsLength === this.labels.length) + return; + + this._postMessage({ + command: 'set-labels', + params: { labels: this.labels } + }); + } + + public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { + const params: Partial = { + isDraft, + autoMerge, + autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, + creating: true + }; + return this._postMessage({ + command: 'create', + params + }); + } + + protected abstract create(message: IRequestMessage): Promise; + + protected async postCreate(message: IRequestMessage, createdPR: PullRequestModel) { + return Promise.all([ + this.setLabels(createdPR, message.args.labels), + 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.setProjects(createdPR, message.args.projects)]); + } + + private async cancel(message: IRequestMessage) { + this._onDone.fire(undefined); + // Re-fetch the automerge info so that it's updated for next time. + await this.getMergeConfiguration(message.args.owner, message.args.repo, true); + return this._replyMessage(message, undefined); + } + + protected async _onDidReceiveMessage(message: IRequestMessage) { + const result = await super._onDidReceiveMessage(message); + if (result !== this.MESSAGE_UNHANDLED) { + return; + } + + switch (message.command) { + case 'pr.requestInitialize': + return this.initializeParamsPromise(); + + case 'pr.cancelCreate': + return this.cancel(message); + + case 'pr.create': + return this.create(message); + + case 'pr.changeLabels': + return this.addLabels(); + + case 'pr.changeReviewers': + return this.addReviewers(); + + case 'pr.changeAssignees': + return this.addAssignees(); + + case 'pr.changeMilestone': + return this.addMilestone(); + + case 'pr.changeProjects': + return this.addProjects(); + + case 'pr.removeLabel': + return this.removeLabel(message); + + default: + return this.MESSAGE_UNHANDLED; + } + } + + dispose() { + super.dispose(); + this._postMessage({ command: 'reset' }); + } + + private _getHtmlForWebview() { + const nonce = getNonce(); + + const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); + + return ` + + + + + + + Create Pull Request + + +
+ + +`; + } +} + +function serializeRemoteInfo(remote: { owner: string, repositoryName: string }) { + return { owner: remote.owner, repositoryName: remote.repositoryName }; +} + +export class CreatePullRequestViewProvider extends BaseCreatePullRequestViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + public readonly viewType = 'github:createPullRequestWebview'; + + constructor( + telemetry: ITelemetry, + model: CreatePullRequestDataModel, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + pullRequestDefaults: PullRequestDefaults, + ) { + super(telemetry, model, extensionUri, folderRepositoryManager, pullRequestDefaults, model.compareBranch); + + this._disposables.push(this.model.onDidChange(async (e) => { + let baseRemote: RemoteInfo | undefined; + let baseBranch: string | undefined; + if (e.baseOwner) { + const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.baseOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; + baseRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; + baseBranch = this.model.baseBranch; + } + if (e.baseBranch) { + baseBranch = e.baseBranch; + } + let compareRemote: RemoteInfo | undefined; + let compareBranch: string | undefined; + if (e.compareOwner) { + const gitHubRemote = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, e.compareOwner!) === 0 && compareIgnoreCase(repo.remote.repositoryName, this.model.repositoryName) === 0)?.remote; + compareRemote = gitHubRemote ? serializeRemoteInfo(gitHubRemote) : undefined; + compareBranch = this.model.compareBranch; + } + if (e.compareBranch) { + compareBranch = e.compareBranch; + } + const params: Partial = { + baseRemote, + baseBranch, + compareRemote, + compareBranch + }; + // TODO: consider updating title and description + return this._postMessage({ + command: 'pr.initialize', + params, + }); + + })); + } + + public async setDefaultCompareBranch(compareBranch: Branch | undefined) { + const branchChanged = compareBranch && (compareBranch.name !== this.model.compareBranch); + const currentCompareRemote = this._folderRepositoryManager.gitHubRepositories.find(repo => repo.remote.owner === this.model.compareOwner)?.remote.remoteName; + const branchRemoteChanged = compareBranch && (compareBranch.upstream?.remote !== currentCompareRemote); + if (branchChanged || branchRemoteChanged) { + this._defaultCompareBranch = compareBranch!.name!; + this.model.setCompareBranch(compareBranch!.name); + this.changeBranch(compareBranch!.name!, false).then(titleAndDescription => { + const params: Partial = { + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description, + compareBranch: compareBranch?.name, + defaultCompareBranch: compareBranch?.name + }; + if (!branchRemoteChanged) { + return this._postMessage({ + command: 'pr.initialize', + params, + }); + } + }); + } + } + + public show(compareBranch?: Branch): void { + if (compareBranch) { + this.setDefaultCompareBranch(compareBranch); // don't await, view will be updated when the branch is changed + } + + super.show(); + } + + private async getTotalGitHubCommits(compareBranch: Branch, baseBranchName: string): Promise<{ commit: { message: string }; parents: { sha: string }[] }[] | undefined> { + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + if (compareBranch.upstream) { + const headRepo = this._folderRepositoryManager.findRepo(byRemoteName(compareBranch.upstream.remote)); + + if (headRepo) { + const headBranch = `${headRepo.remote.owner}:${compareBranch.name ?? ''}`; + const baseBranch = `${this._pullRequestDefaults.owner}:${baseBranchName}`; + const compareResult = await origin.compareCommits(baseBranch, headBranch); + + return compareResult?.commits; + } + } + + return undefined; + } + + protected async getTitleAndDescription(compareBranch: Branch, baseBranch: string): Promise<{ title: string, description: string }> { + let title: string = ''; + let description: string = ''; + const descrptionSource = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'commit' | 'template' | 'none' | 'Copilot'>(PULL_REQUEST_DESCRIPTION); + if (descrptionSource === 'none') { + return { title, description }; + } + + // Use same default as GitHub, if there is only one commit, use the commit, otherwise use the branch name, as long as it is not the default branch. + // By default, the base branch we use for comparison is the base branch of origin. Compare this to the + // compare branch if it has a GitHub remote. + const origin = await this._folderRepositoryManager.getOrigin(compareBranch); + + let useBranchName = this._pullRequestDefaults.base === compareBranch.name; + Logger.debug(`Compare branch name: ${compareBranch.name}, Base branch name: ${this._pullRequestDefaults.base}`, CreatePullRequestViewProvider.ID); + try { + const name = compareBranch.name; + const [totalCommits, lastCommit, pullRequestTemplate] = await Promise.all([ + this.getTotalGitHubCommits(compareBranch, baseBranch), + name ? titleAndBodyFrom(promiseWithTimeout(this._folderRepositoryManager.getTipCommitMessage(name), 5000)) : undefined, + descrptionSource === 'template' ? await this.getPullRequestTemplate() : undefined + ]); + const totalNonMergeCommits = totalCommits?.filter(commit => commit.parents.length < 2); + + Logger.debug(`Total commits: ${totalNonMergeCommits?.length}`, CreatePullRequestViewProvider.ID); + if (totalNonMergeCommits === undefined) { + // There is no upstream branch. Use the last commit as the title and description. + useBranchName = false; + } else if (totalNonMergeCommits && totalNonMergeCommits.length > 1) { + const defaultBranch = await origin.getDefaultBranch(); + useBranchName = defaultBranch !== compareBranch.name; + } + + if (name && !lastCommit) { + Logger.appendLine('Timeout getting last commit message', CreatePullRequestViewProvider.ID); + /* __GDPR__ + "pr.create.getCommitTimeout" : {} + */ + this.telemetry.sendTelemetryEvent('pr.create.getCommitTimeout'); + } + // Set title + if (useBranchName && name) { + title = `${name.charAt(0).toUpperCase()}${name.slice(1)}`; + } else if (name && lastCommit) { + title = lastCommit.title; + } + + // Set description + if (pullRequestTemplate && lastCommit?.body) { + description = `${lastCommit.body}\n\n${pullRequestTemplate}`; + } else if (pullRequestTemplate) { + description = pullRequestTemplate; + } else if (lastCommit?.body && (this._pullRequestDefaults.base !== compareBranch.name)) { + description = lastCommit.body; + } + + // If the description is empty, check to see if the title of the PR contains something that looks like an issue + if (!description) { + const issueExpMatch = title.match(ISSUE_EXPRESSION); + const match = parseIssueExpressionOutput(issueExpMatch); + if (match?.issueNumber && !match.name && !match.owner) { + description = `#${match.issueNumber}`; + const prefix = title.substr(0, title.indexOf(issueExpMatch![0])); + + const keyWordMatch = prefix.match(ISSUE_CLOSING_KEYWORDS); + if (keyWordMatch) { + description = `${keyWordMatch[0]} ${description}`; + } + } + } + } catch (e) { + // Ignore and fall back to commit message + Logger.debug(`Error while getting total commits: ${e}`, CreatePullRequestViewProvider.ID); + } + return { title, description }; + } + + private async getPullRequestTemplate(): Promise { + return this._folderRepositoryManager.getPullRequestTemplateBody(this.model.baseOwner); + } + + protected async detectBaseMetadata(defaultCompareBranch: Branch): Promise { + const owner = this.model.compareOwner; + const repositoryName = this.model.repositoryName; + const settingValue = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'repositoryDefault' | 'createdFromBranch' | 'auto'>(CREATE_BASE_BRANCH); + if (!defaultCompareBranch.name || settingValue === 'repositoryDefault') { + return undefined; + } + const githubRepo = this._folderRepositoryManager.findRepo(repo => compareIgnoreCase(repo.remote.owner, owner) === 0 && compareIgnoreCase(repo.remote.repositoryName, repositoryName) === 0); + if (settingValue === 'auto' && (await githubRepo?.getMetadata())?.fork) { + return undefined; + } + + try { + const baseFromProvider = await this._folderRepositoryManager.repository.getBranchBase(defaultCompareBranch.name); + if (baseFromProvider?.name) { + const repo = this._folderRepositoryManager.findRepo(repo => repo.remote.remoteName === baseFromProvider.remote); + if (repo) { + return { + branch: baseFromProvider.name, + owner: repo.remote.owner, + repositoryName: repo.remote.repositoryName + }; + } + } + } catch (e) { + // Not all providers will support `getBranchBase` + return undefined; + } + } + + protected async doInitializeParams(): Promise { + const params = await super.doInitializeParams(); + this.model.baseOwner = params.defaultBaseRemote!.owner; + this.model.baseBranch = params.defaultBaseBranch!; + return params; + } + + + private async remotePicks(isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo })[]> { + const remotes = isBase ? await this._folderRepositoryManager.getActiveGitHubRemotes(await this._folderRepositoryManager.getGitHubRemotes()) : this._folderRepositoryManager.gitHubRepositories.map(repo => repo.remote); + return remotes.map(remote => { + return { + iconPath: new vscode.ThemeIcon('repo'), + label: `${remote.owner}/${remote.repositoryName}`, + remote: { + owner: remote.owner, + repositoryName: remote.repositoryName, + } + }; + }); + } + + private async branchPicks(githubRepository: GitHubRepository, changeRepoMessage: string, isBase: boolean): Promise<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[]> { + let branches: (string | Ref)[]; + if (isBase) { + // For the base, we only want to show branches from GitHub. + branches = await githubRepository.listBranches(githubRepository.remote.owner, githubRepository.remote.repositoryName); + } else { + // For the compare, we only want to show local branches. + branches = (await this._folderRepositoryManager.repository.getBranches({ remote: false })).filter(branch => branch.name); + } + // TODO: @alexr00 - Add sorting so that the most likely to be used branch (ex main or release if base) is at the top of the list. + const branchPicks: (vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })[] = branches.map(branch => { + const branchName = typeof branch === 'string' ? branch : branch.name!; + const pick: (vscode.QuickPickItem & { remote: RemoteInfo, branch: string }) = { + iconPath: new vscode.ThemeIcon('git-branch'), + label: branchName, + remote: { + owner: githubRepository.remote.owner, + repositoryName: githubRepository.remote.repositoryName + }, + branch: branchName + }; + return pick; + }); + branchPicks.unshift({ + kind: vscode.QuickPickItemKind.Separator, + label: `${githubRepository.remote.owner}/${githubRepository.remote.repositoryName}` + }); + branchPicks.unshift({ + iconPath: new vscode.ThemeIcon('repo'), + label: changeRepoMessage }); + return branchPicks; } - private labels: ILabel[] = []; - public async addLabels(): Promise { - let newLabels: ILabel[] = []; + private async processRemoteAndBranchResult(githubRepository: GitHubRepository, result: { remote: RemoteInfo, branch: string }, isBase: boolean) { + const [defaultBranch, viewerPermission] = await Promise.all([githubRepository.getDefaultBranch(), githubRepository.getViewerPermission()]); - const labelsToAdd = await vscode.window.showQuickPick( - getLabelOptions(this._folderRepositoryManager, this.labels, this.model.baseOwner, this.model.repositoryName).then(options => { - newLabels = options.newLabels; - return options.labelPicks; - }) as Promise, - { canPickMany: true, placeHolder: vscode.l10n.t('Apply labels') }, - ); + commands.setContext(contexts.CREATE_PR_PERMISSIONS, viewerPermission); + let chooseResult: ChooseBaseRemoteAndBranchResult | ChooseCompareRemoteAndBranchResult; + if (isBase) { + const baseRemoteChanged = this.model.baseOwner !== result.remote.owner; + const baseBranchChanged = baseRemoteChanged || this.model.baseBranch !== result.branch; + this.model.baseOwner = result.remote.owner; + this.model.baseBranch = result.branch; + const compareBranch = await this._folderRepositoryManager.repository.getBranch(this.model.compareBranch); + const [mergeConfiguration, titleAndDescription, mergeQueueMethodForBranch] = await Promise.all([ + this.getMergeConfiguration(result.remote.owner, result.remote.repositoryName), + this.getTitleAndDescription(compareBranch, this.model.baseBranch), + this._folderRepositoryManager.mergeQueueMethodForBranch(this.model.baseBranch, this.model.baseOwner, this.model.repositoryName)]); + let autoMergeDefault = false; + if (mergeConfiguration.viewerCanAutoMerge) { + const defaultCreateOption = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'lastUsed' | 'create' | 'createDraft' | 'createAutoMerge'>(DEFAULT_CREATE_OPTION, 'lastUsed'); + const lastCreateMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } | undefined = this._folderRepositoryManager.context.workspaceState.get<{ autoMerge: boolean, mergeMethod: MergeMethod, isDraft } | undefined>(PREVIOUS_CREATE_METHOD, undefined); + autoMergeDefault = (defaultCreateOption === 'lastUsed' && lastCreateMethod?.autoMerge) || (defaultCreateOption === 'createAutoMerge'); + } - if (labelsToAdd) { - const addedLabels: ILabel[] = labelsToAdd.map(label => newLabels.find(l => l.name === label.label)!); - this.labels = addedLabels; - this._postMessage({ - command: 'set-labels', - params: { labels: this.labels } - }); + chooseResult = { + baseRemote: result.remote, + baseBranch: result.branch, + defaultBaseBranch: defaultBranch, + defaultMergeMethod: getDefaultMergeMethod(mergeConfiguration.mergeMethodsAvailability), + allowAutoMerge: mergeConfiguration.viewerCanAutoMerge, + baseHasMergeQueue: !!mergeQueueMethodForBranch, + mergeMethodsAvailability: mergeConfiguration.mergeMethodsAvailability, + autoMergeDefault, + defaultTitle: titleAndDescription.title, + defaultDescription: titleAndDescription.description + }; + if (baseRemoteChanged) { + /* __GDPR__ + "pr.create.changedBaseRemote" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseRemote'); + } + if (baseBranchChanged) { + /* __GDPR__ + "pr.create.changedBaseBranch" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedBaseBranch'); + } + } else { + await this.changeBranch(result.branch, false); + chooseResult = { + compareRemote: result.remote, + compareBranch: result.branch, + defaultCompareBranch: defaultBranch + }; + /* __GDPR__ + "pr.create.changedCompare" : {} + */ + this._folderRepositoryManager.telemetry.sendTelemetryEvent('pr.create.changedCompare'); } + return chooseResult; } - private async removeLabel(message: IRequestMessage<{ label: ILabel }>,): Promise { - const { label } = message.args; - if (!label) - return; + private async changeRemoteAndBranch(message: IRequestMessage, isBase: boolean): Promise { + this.cancelGenerateTitleAndDescription(); + const quickPick = vscode.window.createQuickPick<(vscode.QuickPickItem & { remote?: RemoteInfo, branch?: string })>(); + let githubRepository = this._folderRepositoryManager.findRepo( + repo => message.args.currentRemote?.owner === repo.remote.owner && message.args.currentRemote.repositoryName === repo.remote.repositoryName, + ); - const previousLabelsLength = this.labels.length; - this.labels = this.labels.filter(l => l.name !== label.name); - if (previousLabelsLength === this.labels.length) - return; + const chooseDifferentRemote = vscode.l10n.t('Change Repository...'); + const remotePlaceholder = vscode.l10n.t('Choose a remote'); + const branchPlaceholder = isBase ? vscode.l10n.t('Choose a base branch') : vscode.l10n.t('Choose a branch to merge'); + const repositoryPlaceholder = isBase ? vscode.l10n.t('Choose a base repository') : vscode.l10n.t('Choose a repository to merge from'); - this._postMessage({ - command: 'set-labels', - params: { labels: this.labels } + quickPick.placeholder = githubRepository ? branchPlaceholder : remotePlaceholder; + quickPick.show(); + quickPick.busy = true; + quickPick.items = githubRepository ? await this.branchPicks(githubRepository, chooseDifferentRemote, isBase) : await this.remotePicks(isBase); + const activeItem = message.args.currentBranch ? quickPick.items.find(item => item.branch === message.args.currentBranch) : undefined; + quickPick.activeItems = activeItem ? [activeItem] : []; + quickPick.busy = false; + const remoteAndBranch: Promise<{ remote: RemoteInfo, branch: string } | undefined> = new Promise((resolve) => { + quickPick.onDidAccept(async () => { + if (quickPick.selectedItems.length === 0) { + return; + } + const selectedPick = quickPick.selectedItems[0]; + if (selectedPick.label === chooseDifferentRemote) { + quickPick.busy = true; + quickPick.items = await this.remotePicks(isBase); + quickPick.busy = false; + quickPick.placeholder = githubRepository ? repositoryPlaceholder : remotePlaceholder; + } else if ((selectedPick.branch === undefined) && selectedPick.remote) { + const selectedRemote = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo }; + quickPick.busy = true; + githubRepository = this._folderRepositoryManager.findRepo(repo => repo.remote.owner === selectedRemote.remote.owner && repo.remote.repositoryName === selectedRemote.remote.repositoryName)!; + quickPick.items = await this.branchPicks(githubRepository, chooseDifferentRemote, isBase); + quickPick.placeholder = branchPlaceholder; + quickPick.busy = false; + } else if (selectedPick.branch && selectedPick.remote) { + const selectedBranch = selectedPick as vscode.QuickPickItem & { remote: RemoteInfo, branch: string }; + resolve({ remote: selectedBranch.remote, branch: selectedBranch.branch }); + } + }); }); + const hidePromise = new Promise((resolve) => quickPick.onDidHide(() => resolve())); + const result = await Promise.race([remoteAndBranch, hidePromise]); + if (!result || !githubRepository) { + quickPick.hide(); + quickPick.dispose(); + return; + } + + quickPick.busy = true; + const chooseResult = await this.processRemoteAndBranchResult(githubRepository, result, isBase); + + quickPick.hide(); + quickPick.dispose(); + return this._replyMessage(message, chooseResult); } private async findIssueContext(commits: string[]): Promise<{ content: string, reference: string }[] | undefined> { @@ -980,19 +1117,6 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } } - public async createFromCommand(isDraft: boolean, autoMerge: boolean, autoMergeMethod: MergeMethod | undefined, mergeWhenReady?: boolean) { - const params: Partial = { - isDraft, - autoMerge, - autoMergeMethod: mergeWhenReady ? 'merge' : autoMergeMethod, - creating: true - }; - return this._postMessage({ - command: 'create', - params - }); - } - private checkGeneratedTitleAndDescription(title: string, description: string) { if (!this.lastGeneratedTitleAndDescription) { return; @@ -1030,7 +1154,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return true; } - private async create(message: IRequestMessage): Promise { + protected async create(message: IRequestMessage): Promise { Logger.debug(`Creating pull request with args ${JSON.stringify(message.args)}`, CreatePullRequestViewProvider.ID); if (!(await this.checkForChanges())) { @@ -1043,16 +1167,6 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs const createMethod: { autoMerge: boolean, mergeMethod: MergeMethod | undefined, isDraft: boolean } = { autoMerge: message.args.autoMerge, mergeMethod: message.args.autoMergeMethod, isDraft: message.args.draft }; this._folderRepositoryManager.context.workspaceState.update(PREVIOUS_CREATE_METHOD, createMethod); - const postCreate = (createdPR: PullRequestModel) => { - return Promise.all([ - this.setLabels(createdPR, message.args.labels), - 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.setProjects(createdPR, message.args.projects)]); - }; - CreatePullRequestViewProvider.withProgress(() => { return vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async progress => { commands.setContext('pr:creating', true); @@ -1123,7 +1237,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs if (!createdPR) { this._throwError(message, vscode.l10n.t('There must be a difference in commits to create a pull request.')); } else { - await postCreate(createdPR); + await this.postCreate(message, createdPR); } } catch (e) { if (!createdPR) { @@ -1135,7 +1249,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } else { if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. - await postCreate(createdPR); + await this.postCreate(message, createdPR); } // All of these errors occur after the PR is created, so the error is not critical. vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); @@ -1174,14 +1288,6 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs return this.getTitleAndDescription(compareBranch, this.model.baseBranch); } - 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. - await this.getMergeConfiguration(message.args.owner, message.args.repo, true); - return this._replyMessage(message, undefined); - } - protected async _onDidReceiveMessage(message: IRequestMessage) { const result = await super._onDidReceiveMessage(message); if (result !== this.MESSAGE_UNHANDLED) { @@ -1189,39 +1295,12 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs } switch (message.command) { - case 'pr.requestInitialize': - return this.initializeParamsPromise(); - - case 'pr.cancelCreate': - return this.cancel(message); - - case 'pr.create': - return this.create(message); - case 'pr.changeBaseRemoteAndBranch': return this.changeRemoteAndBranch(message, true); case 'pr.changeCompareRemoteAndBranch': return this.changeRemoteAndBranch(message, false); - case 'pr.changeLabels': - return this.addLabels(); - - case 'pr.changeReviewers': - return this.addReviewers(); - - case 'pr.changeAssignees': - return this.addAssignees(); - - case 'pr.changeMilestone': - return this.addMilestone(); - - case 'pr.changeProjects': - return this.addProjects(); - - case 'pr.removeLabel': - return this.removeLabel(message); - case 'pr.generateTitleAndDescription': return this.generateTitleAndDescription(message); @@ -1239,34 +1318,4 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs vscode.window.showErrorMessage('Unsupported webview message'); } } - - dispose() { - super.dispose(); - this._postMessage({ command: 'reset' }); - } - - private _getHtmlForWebview() { - const nonce = getNonce(); - - const uri = vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview-create-pr-view-new.js'); - - return ` - - - - - - - Create Pull Request - - -
- - -`; - } -} - -function serializeRemoteInfo(remote: { owner: string, repositoryName: string }) { - return { owner: remote.owner, repositoryName: remote.repositoryName }; } \ No newline at end of file diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 581528ccfe..f5a5b01f8a 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -33,6 +33,7 @@ import { batchPromiseAll, compareIgnoreCase, formatError, Predicate } from '../c import { PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; import { NEVER_SHOW_PULL_NOTIFICATION, REPO_KEYS, ReposState } from '../extensionState'; import { git } from '../gitProviders/gitCommands'; +import { CreatePullRequestHelper } from '../view/createPullRequestHelper'; import { OctokitCommon } from './common'; import { ConflictModel } from './conflictGuide'; import { ConflictResolutionCoordinator } from './conflictResolutionCoordinator'; @@ -217,6 +218,7 @@ export class FolderRepositoryManager implements vscode.Disposable { public readonly telemetry: ITelemetry, private _git: GitApiImpl, private _credentialStore: CredentialStore, + public readonly createPullRequestHelper: CreatePullRequestHelper ) { this._subs = []; this._githubRepositories = []; @@ -1954,6 +1956,18 @@ export class FolderRepositoryManager implements vscode.Disposable { }); } + async revert(pullRequest: PullRequestModel, title: string, body: string, draft: boolean): Promise { + const repo = this._githubRepositories.find( + r => r.remote.owner === pullRequest.remote.owner && r.remote.repositoryName === pullRequest.remote.repositoryName, + ); + if (!repo) { + throw new Error(`No matching repository ${pullRequest.remote.repositoryName} found for ${pullRequest.remote.owner}`); + } + + const pullRequestModel: PullRequestModel | undefined = await repo.revertPullRequest(pullRequest.graphNodeId, title, body, draft); + return pullRequestModel; + } + async getPullRequestRepositoryDefaultBranch(issue: IssueModel): Promise { const branch = await issue.githubRepository.getDefaultBranch(); return branch; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index fb7de78cc5..2d65b9bd43 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -36,6 +36,7 @@ import { PullRequestsResponse, PullRequestTemplatesResponse, RepoProjectsResponse, + RevertPullRequestResponse, ViewerPermissionResponse, } from './graphql'; import { @@ -180,7 +181,7 @@ export class GitHubRepository implements vscode.Disposable { `github-browse-${this.remote.normalizedHost}-${this.remote.owner}-${this.remote.repositoryName}`, `Pull Request (${this.remote.owner}/${this.remote.repositoryName})`, ); - this.commentsHandler = new PRCommentControllerRegistry(this.commentsController); + this.commentsHandler = new PRCommentControllerRegistry(this.commentsController, this._telemetry); this._toDispose.push(this.commentsHandler); this._toDispose.push(this.commentsController); } catch (e) { @@ -986,6 +987,33 @@ export class GitHubRepository implements vscode.Disposable { } } + async revertPullRequest(pullRequestId: string, title: string, body: string, draft: boolean): Promise { + try { + Logger.debug(`Revert pull request - enter`, this.id); + const { mutate, schema } = await this.ensure(); + + const { data } = await mutate({ + mutation: schema.RevertPullRequest, + variables: { + input: { + pullRequestId, + title, + body, + draft + } + } + }); + Logger.debug(`Revert pull request - done`, this.id); + if (!data) { + throw new Error('Failed to create revert pull request.'); + } + return this.createOrUpdatePullRequestModel(parseGraphQLPullRequest(data.revertPullRequest.revertPullRequest, this)); + } catch (e) { + Logger.error(`Unable to create revert PR: ${e}`, this.id); + throw e; + } + } + async getPullRequest(id: number): Promise { try { Logger.debug(`Fetch pull request ${id} - enter`, this.id); diff --git a/src/github/graphql.ts b/src/github/graphql.ts index 0ec0fcea29..afe93eac4b 100644 --- a/src/github/graphql.ts +++ b/src/github/graphql.ts @@ -368,6 +368,12 @@ export interface CreatePullRequestResponse { } } +export interface RevertPullRequestResponse { + revertPullRequest: { + revertPullRequest: PullRequest + } +} + export interface AddReviewThreadResponse { addPullRequestReviewThread: { thread: ReviewThread; diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index d995f536f7..46d3796fdb 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { IComment } from '../common/comment'; import Logger from '../common/logger'; +import { ITelemetry } from '../common/telemetry'; import { asPromise, formatError } from '../common/utils'; import { getNonce, IRequestMessage, WebviewBase } from '../common/webview'; import { DescriptionNode } from '../view/treeNodes/descriptionNode'; @@ -32,6 +33,7 @@ export class IssueOverviewPanel extends W protected _scrollPosition = { x: 0, y: 0 }; public static async createOrShow( + telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepositoryManager: FolderRepositoryManager, issue: IssueModel, @@ -50,6 +52,7 @@ export class IssueOverviewPanel extends W } else { const title = `Issue #${issue.number.toString()}`; IssueOverviewPanel.currentPanel = new IssueOverviewPanel( + telemetry, extensionUri, activeColumn || vscode.ViewColumn.Active, title, @@ -76,7 +79,8 @@ export class IssueOverviewPanel extends W } protected constructor( - private readonly _extensionUri: vscode.Uri, + protected readonly _telemetry: ITelemetry, + protected readonly _extensionUri: vscode.Uri, column: vscode.ViewColumn, title: string, folderRepositoryManager: FolderRepositoryManager, diff --git a/src/github/pullRequestOverview.ts b/src/github/pullRequestOverview.ts index c4e99e1a35..d589acb91e 100644 --- a/src/github/pullRequestOverview.ts +++ b/src/github/pullRequestOverview.ts @@ -10,6 +10,7 @@ import { IComment } from '../common/comment'; import { commands, contexts } from '../common/executeCommands'; import Logger from '../common/logger'; import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent'; import { asPromise, dispose, formatError } from '../common/utils'; import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview'; @@ -51,6 +52,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel): Promise { + await this._folderRepositoryManager.createPullRequestHelper.revert(this._telemetry, this._extensionUri, this._folderRepositoryManager, this._item, async (pullRequest) => { + const result: Partial = { revertable: !pullRequest }; + return this._replyMessage(message, result); + }); + } + private async copyPrLink(): Promise { return vscode.env.clipboard.writeText(this._item.html_url); } diff --git a/src/github/queries.gql b/src/github/queries.gql index 2dd536a982..3bf340a417 100644 --- a/src/github/queries.gql +++ b/src/github/queries.gql @@ -175,6 +175,14 @@ mutation CreatePullRequest($input: CreatePullRequestInput!) { } } +mutation RevertPullRequest($input: RevertPullRequestInput!) { + revertPullRequest(input: $input) { + revertPullRequest { + ...PullRequestFragment + } + } +} + # Queries that only exist in this file and in extra mutation DequeuePullRequest($input: DequeuePullRequestInput!) { diff --git a/src/github/queriesLimited.gql b/src/github/queriesLimited.gql index 767942e21d..4a833e7de1 100644 --- a/src/github/queriesLimited.gql +++ b/src/github/queriesLimited.gql @@ -142,4 +142,12 @@ mutation CreatePullRequest($input: CreatePullRequestInput!) { ...PullRequestFragment } } -} \ No newline at end of file +} + +mutation RevertPullRequest($input: RevertPullRequestInput!) { + revertPullRequest(input: $input) { + revertPullRequest { + ...PullRequestFragment + } + } +} diff --git a/src/github/revertPRViewProvider.ts b/src/github/revertPRViewProvider.ts new file mode 100644 index 0000000000..18c2534646 --- /dev/null +++ b/src/github/revertPRViewProvider.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { CreatePullRequestNew } from '../../common/views'; +import { openDescription } from '../commands'; +import { commands } from '../common/executeCommands'; +import { ITelemetry } from '../common/telemetry'; +import { IRequestMessage } from '../common/webview'; +import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel } from './createPRViewProvider'; +import { + FolderRepositoryManager, + PullRequestDefaults, +} from './folderRepositoryManager'; +import { BaseBranchMetadata } from './pullRequestGitHelper'; +import { PullRequestModel } from './pullRequestModel'; + +export class RevertPullRequestViewProvider extends BaseCreatePullRequestViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + protected _canModifyBranches: boolean = false; + + constructor( + telemetry: ITelemetry, + model: BasePullRequestDataModel, + extensionUri: vscode.Uri, + folderRepositoryManager: FolderRepositoryManager, + pullRequestDefaults: PullRequestDefaults, + private readonly pullRequest: PullRequestModel + ) { + super(telemetry, model, extensionUri, folderRepositoryManager, pullRequestDefaults, pullRequest.base.name); + } + + protected async getTitleAndDescription(): Promise<{ title: string; description: string; }> { + return { + title: vscode.l10n.t('Revert "{0}"', this.pullRequest.title), + description: vscode.l10n.t('Reverts {0}', `${this.pullRequest.remote.owner}/${this.pullRequest.remote.repositoryName}#${this.pullRequest.number}`) + }; + } + + protected async detectBaseMetadata(): Promise { + return { + owner: this.pullRequest.remote.owner, + repositoryName: this.pullRequest.remote.repositoryName, + branch: this.pullRequest.base.name + }; + } + + protected getTitleAndDescriptionProvider(_name?: string) { + return undefined; + } + + protected async create(message: IRequestMessage): Promise { + let revertPr: PullRequestModel | undefined; + RevertPullRequestViewProvider.withProgress(async () => { + commands.setContext('pr:creating', true); + try { + revertPr = await this._folderRepositoryManager.revert(this.pullRequest, message.args.title, message.args.body, message.args.draft); + if (revertPr) { + await this.postCreate(message, revertPr); + await openDescription(this.telemetry, revertPr, undefined, this._folderRepositoryManager, true); + } + + } catch (e) { + if (!revertPr) { + let errorMessage: string = e.message; + if (errorMessage.startsWith('GraphQL error: ')) { + errorMessage = errorMessage.substring('GraphQL error: '.length); + } + this._throwError(message, errorMessage); + } else { + if ((e as Error).message === 'GraphQL error: ["Pull request Pull request is in unstable status"]') { + // This error can happen if the PR isn't fully created by the time we try to set properties on it. Try again. + await this.postCreate(message, revertPr); + } + // All of these errors occur after the PR is created, so the error is not critical. + vscode.window.showErrorMessage(vscode.l10n.t('There was an error creating the pull request: {0}', (e as Error).message)); + } + } finally { + commands.setContext('pr:creating', false); + if (revertPr) { + this._onDone.fire(revertPr); + } else { + await this._replyMessage(message, {}); + } + } + }); + } +} \ No newline at end of file diff --git a/src/github/views.ts b/src/github/views.ts index 4b9503324a..8a4f12eb2d 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -89,6 +89,7 @@ export interface PullRequest { hasReviewDraft: boolean; lastReviewType?: ReviewType; + revertable?: boolean; busy?: boolean; } @@ -108,4 +109,4 @@ export enum PreReviewState { Available, ReviewedWithComments, ReviewedWithoutComments -} \ No newline at end of file +} diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 4585a5d80b..604c5243b8 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -21,6 +21,7 @@ import { CredentialStore } from '../../github/credentials'; import { MockExtensionContext } from '../mocks/mockExtensionContext'; import { Uri } from 'vscode'; import { GitHubServerType } from '../../common/authentication'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; describe('PullRequestManager', function () { let sinon: SinonSandbox; @@ -35,7 +36,7 @@ describe('PullRequestManager', function () { const repository = new MockRepository(); const context = new MockExtensionContext(); const credentialStore = new CredentialStore(telemetry, context); - manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore, new CreatePullRequestHelper()); }); afterEach(function () { diff --git a/src/test/github/pullRequestOverview.test.ts b/src/test/github/pullRequestOverview.test.ts index bf80c1edae..788d58b845 100644 --- a/src/test/github/pullRequestOverview.test.ts +++ b/src/test/github/pullRequestOverview.test.ts @@ -23,6 +23,7 @@ import { CredentialStore } from '../../github/credentials'; import { GitHubServerType } from '../../common/authentication'; import { GitHubRemote } from '../../common/remote'; import { CheckState } from '../../github/interface'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; const EXTENSION_URI = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '../../..'); @@ -43,7 +44,8 @@ describe('PullRequestOverview', function () { const repository = new MockRepository(); telemetry = new MockTelemetry(); credentialStore = new CredentialStore(telemetry, context); - pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + const createPrHelper = new CreatePullRequestHelper(); + pullRequestManager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore, createPrHelper); const url = 'https://github.com/aaa/bbb'; remote = new GitHubRemote('origin', url, new Protocol(url), GitHubServerType.GitHubDotCom); @@ -76,7 +78,7 @@ describe('PullRequestOverview', function () { const prItem = convertRESTPullRequestToRawPullRequest(new PullRequestBuilder().number(1000).build(), repo); const prModel = new PullRequestModel(credentialStore, telemetry, repo, remote, prItem); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel); assert( createWebviewPanel.calledWith(sinonMatch.string, 'Pull Request #1000', vscode.ViewColumn.One, { @@ -112,7 +114,7 @@ describe('PullRequestOverview', function () { sinon.stub(prModel0, 'getReviewRequests').resolves([]); sinon.stub(prModel0, 'getTimelineEvents').resolves([]); sinon.stub(prModel0, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel0); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel0); const panel0 = PullRequestOverviewPanel.currentPanel; assert.notStrictEqual(panel0, undefined); @@ -125,7 +127,7 @@ describe('PullRequestOverview', function () { sinon.stub(prModel1, 'getReviewRequests').resolves([]); sinon.stub(prModel1, 'getTimelineEvents').resolves([]); sinon.stub(prModel1, 'getStatusChecks').resolves([{ state: CheckState.Success, statuses: [] }, null]); - await PullRequestOverviewPanel.createOrShow(EXTENSION_URI, pullRequestManager, prModel1); + await PullRequestOverviewPanel.createOrShow(telemetry, EXTENSION_URI, pullRequestManager, prModel1); assert.strictEqual(panel0, PullRequestOverviewPanel.currentPanel); assert.strictEqual(createWebviewPanel.callCount, 1); diff --git a/src/test/view/prsTree.test.ts b/src/test/view/prsTree.test.ts index 16aa66ae88..761e7f19a3 100644 --- a/src/test/view/prsTree.test.ts +++ b/src/test/view/prsTree.test.ts @@ -30,6 +30,7 @@ import { GitHubServerType } from '../../common/authentication'; import { DataUri } from '../../common/uri'; import { IAccount, ITeam } from '../../github/interface'; import { asPromise } from '../../common/utils'; +import { CreatePullRequestHelper } from '../../view/createPullRequestHelper'; describe('GitHub Pull Requests view', function () { let sinon: SinonSandbox; @@ -38,6 +39,8 @@ describe('GitHub Pull Requests view', function () { let provider: PullRequestsTreeDataProvider; let credentialStore: CredentialStore; let reposManager: RepositoriesManager; + let createPrHelper: CreatePullRequestHelper; + beforeEach(function () { sinon = createSandbox(); @@ -52,6 +55,7 @@ describe('GitHub Pull Requests view', function () { ); provider = new PullRequestsTreeDataProvider(telemetry, context, reposManager); credentialStore = new CredentialStore(telemetry, context); + createPrHelper = new CreatePullRequestHelper(); // For tree view unit tests, we don't test the authentication flow, so `showSignInNotification` returns // a dummy GitHub/Octokit object. @@ -97,7 +101,7 @@ describe('GitHub Pull Requests view', function () { it('has no children when repositories have not yet been initialized', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore, createPrHelper)); provider.initialize([], credentialStore); const rootNodes = await provider.getChildren(); @@ -107,7 +111,7 @@ describe('GitHub Pull Requests view', function () { it('opens the viewlet and displays the default categories', async function () { const repository = new MockRepository(); repository.addRemote('origin', 'git@github.com:aaa/bbb'); - reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore)); + reposManager.insertFolderManager(new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore, createPrHelper)); sinon.stub(credentialStore, 'isAuthenticated').returns(true); await reposManager.folderManagers[0].updateRepositories(); provider.initialize([], credentialStore); @@ -175,7 +179,7 @@ describe('GitHub Pull Requests view', function () { await repository.createBranch('non-pr-branch', false); - const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore); + const manager = new FolderRepositoryManager(0, context, repository, telemetry, new GitApiImpl(), credentialStore, createPrHelper); reposManager.insertFolderManager(manager); sinon.stub(manager, 'createGitHubRepository').callsFake((r, cs) => { assert.deepStrictEqual(r, remote); diff --git a/src/test/view/reviewCommentController.test.ts b/src/test/view/reviewCommentController.test.ts index 91ebc1bc6c..32803274de 100644 --- a/src/test/view/reviewCommentController.test.ts +++ b/src/test/view/reviewCommentController.test.ts @@ -77,7 +77,7 @@ describe('ReviewCommentController', function () { const createPrHelper = new CreatePullRequestHelper(); Resource.initialize(context); gitApiImpl = new GitApiImpl(); - manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore); + manager = new FolderRepositoryManager(0, context, repository, telemetry, gitApiImpl, credentialStore, createPrHelper); reposManager.insertFolderManager(manager); const tree = new PullRequestChangesTreeDataProvider(context, gitApiImpl, reposManager); reviewManager = new ReviewManager(0, context, repository, manager, telemetry, tree, provider, new ShowPullRequest(), activePrViewCoordinator, createPrHelper, gitApiImpl); @@ -172,7 +172,7 @@ describe('ReviewCommentController', function () { const localFileChanges = [createLocalFileChange(uri, fileName, repository.rootUri)]; const reviewModel = new ReviewModel(); reviewModel.localFileChanges = localFileChanges; - const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel, gitApiImpl); + const reviewCommentController = new TestReviewCommentController(reviewManager, manager, repository, reviewModel, gitApiImpl, telemetry); sinon.stub(activePullRequest, 'validateDraftMode').returns(Promise.resolve(false)); sinon.stub(activePullRequest, 'getReviewThreads').returns( @@ -237,7 +237,8 @@ describe('ReviewCommentController', function () { manager, repository, reviewModel, - gitApiImpl + gitApiImpl, + telemetry ); const thread = createGHPRCommentThread('review-1.1', uri); diff --git a/src/view/commentControllBase.ts b/src/view/commentControllBase.ts index ad29f1dce5..f097ef0f72 100644 --- a/src/view/commentControllBase.ts +++ b/src/view/commentControllBase.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ITelemetry } from '../common/telemetry'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GitHubRepository } from '../github/githubRepository'; import { PullRequestModel } from '../github/pullRequestModel'; export abstract class CommentControllerBase { constructor( - protected _folderRepoManager: FolderRepositoryManager + protected _folderRepoManager: FolderRepositoryManager, + protected _telemetry: ITelemetry ) { } protected _commentController: vscode.CommentController; diff --git a/src/view/createPullRequestHelper.ts b/src/view/createPullRequestHelper.ts index 74c5de60e5..e77c603dc4 100644 --- a/src/view/createPullRequestHelper.ts +++ b/src/view/createPullRequestHelper.ts @@ -7,45 +7,42 @@ import * as vscode from 'vscode'; import { Repository } from '../api/api'; import { ITelemetry } from '../common/telemetry'; import { dispose } from '../common/utils'; -import { CreatePullRequestViewProvider } from '../github/createPRViewProvider'; +import { BaseCreatePullRequestViewProvider, BasePullRequestDataModel, CreatePullRequestViewProvider } from '../github/createPRViewProvider'; import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager'; import { PullRequestModel } from '../github/pullRequestModel'; +import { RevertPullRequestViewProvider } from '../github/revertPRViewProvider'; import { CompareChanges } from './compareChangesTreeDataProvider'; import { CreatePullRequestDataModel } from './createPullRequestDataModel'; export class CreatePullRequestHelper implements vscode.Disposable { private _disposables: vscode.Disposable[] = []; - private _createPRViewProvider: CreatePullRequestViewProvider | undefined; + private _createPRViewProvider: BaseCreatePullRequestViewProvider | undefined; private _treeView: CompareChanges | undefined; - private _postCreateCallback: ((pullRequestModel: PullRequestModel) => Promise) | undefined; + private _postCreateCallback: ((pullRequestModel: PullRequestModel | undefined) => Promise) | undefined; constructor() { } - private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean) { + private registerListeners(repository: Repository, usingCurrentBranchAsCompare: boolean, activeContext: string) { this._disposables.push( this._createPRViewProvider!.onDone(async createdPR => { - if (createdPR) { - await CreatePullRequestViewProvider.withProgress(async () => { - return this._postCreateCallback?.(createdPR); - }); - } + vscode.commands.executeCommand('setContext', activeContext, false); + await CreatePullRequestViewProvider.withProgress(async () => { + return this._postCreateCallback?.(createdPR); + }); this.dispose(); }), ); this._disposables.push( vscode.commands.registerCommand('pr.addAssigneesToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - return this._createPRViewProvider.addAssignees(); - } + return this._createPRViewProvider?.addAssignees(); + }), ); this._disposables.push( vscode.commands.registerCommand('pr.addReviewersToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - return this._createPRViewProvider.addReviewers(); - } + return this._createPRViewProvider?.addReviewers(); }), ); @@ -57,60 +54,50 @@ export class CreatePullRequestHelper implements vscode.Disposable { this._disposables.push( vscode.commands.registerCommand('pr.addMilestoneToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - return this._createPRViewProvider.addMilestone(); - } + return this._createPRViewProvider?.addMilestone(); + }), ); this._disposables.push( vscode.commands.registerCommand('pr.addProjectsToNewPr', _ => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - return this._createPRViewProvider.addProjects(); - } + return this._createPRViewProvider?.addProjects(); + }), ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuCreate', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(false, false, undefined); - } + this._createPRViewProvider?.createFromCommand(false, false, undefined); + }) ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuDraft', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(true, false, undefined); - } + this._createPRViewProvider?.createFromCommand(true, false, undefined); + }) ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuMergeWhenReady', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(false, true, undefined, true); - } + this._createPRViewProvider?.createFromCommand(false, true, undefined, true); + }) ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuMerge', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(false, true, 'merge'); - } + this._createPRViewProvider?.createFromCommand(false, true, 'merge'); + }) ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuSquash', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(false, true, 'squash'); - } + this._createPRViewProvider?.createFromCommand(false, true, 'squash'); }) ); this._disposables.push( vscode.commands.registerCommand('pr.createPrMenuRebase', () => { - if (this._createPRViewProvider instanceof CreatePullRequestViewProvider) { - this._createPRViewProvider.createFromCommand(false, true, 'rebase'); - } + this._createPRViewProvider?.createFromCommand(false, true, 'rebase'); }) ); this._disposables.push( @@ -124,7 +111,7 @@ export class CreatePullRequestHelper implements vscode.Disposable { if (usingCurrentBranchAsCompare) { this._disposables.push( repository.state.onDidChange(_ => { - if (this._createPRViewProvider && repository.state.HEAD) { + if (this._createPRViewProvider && repository.state.HEAD && this._createPRViewProvider instanceof CreatePullRequestViewProvider) { this._createPRViewProvider.setDefaultCompareBranch(repository.state.HEAD); } }), @@ -158,24 +145,69 @@ export class CreatePullRequestHelper implements vscode.Disposable { } } + async revert( + telemetry: ITelemetry, + extensionUri: vscode.Uri, + folderRepoManager: FolderRepositoryManager, + pullRequestModel: PullRequestModel, + callback: (pullRequest: PullRequestModel | undefined) => Promise, + ) { + this.reset(); + + this._postCreateCallback = callback; + await folderRepoManager.loginAndUpdate(); + const activeContext = 'github:revertPullRequest'; + vscode.commands.executeCommand('setContext', activeContext, true); + + if (!this._createPRViewProvider || !(this._createPRViewProvider instanceof RevertPullRequestViewProvider)) { + this._createPRViewProvider?.dispose(); + const model: BasePullRequestDataModel = { + baseOwner: pullRequestModel.remote.owner, + repositoryName: pullRequestModel.remote.repositoryName + }; + this._createPRViewProvider = new RevertPullRequestViewProvider( + telemetry, + model, + extensionUri, + folderRepoManager, + { base: pullRequestModel.base.name, owner: pullRequestModel.remote.owner, repo: pullRequestModel.remote.repositoryName }, + pullRequestModel + ); + + this.registerListeners(folderRepoManager.repository, false, activeContext); + + this._disposables.push( + vscode.window.registerWebviewViewProvider( + this._createPRViewProvider.viewType, + this._createPRViewProvider, + ), + ); + } + + this._createPRViewProvider.show(); + } + async create( telemetry: ITelemetry, extensionUri: vscode.Uri, folderRepoManager: FolderRepositoryManager, compareBranch: string | undefined, - callback: (pullRequestModel: PullRequestModel) => Promise, + callback: (pullRequestModel: PullRequestModel | undefined) => Promise, ) { this.reset(); this._postCreateCallback = callback; await folderRepoManager.loginAndUpdate(); - vscode.commands.executeCommand('setContext', 'github:createPullRequest', true); + const activeContext = 'github:createPullRequest'; + vscode.commands.executeCommand('setContext', activeContext, true); const branch = ((compareBranch ? await folderRepoManager.repository.getBranch(compareBranch) : undefined) ?? folderRepoManager.repository.state.HEAD?.name ? folderRepoManager.repository.state.HEAD : undefined); - if (!this._createPRViewProvider) { + let createViewProvider: CreatePullRequestViewProvider; + if (!this._createPRViewProvider || !(this._createPRViewProvider instanceof CreatePullRequestViewProvider)) { + this._createPRViewProvider?.dispose(); const pullRequestDefaults = await this.ensureDefaultsAreLocal( folderRepoManager, await folderRepoManager.getPullRequestDefaults(branch), @@ -183,7 +215,7 @@ export class CreatePullRequestHelper implements vscode.Disposable { const compareOrigin = await folderRepoManager.getOrigin(branch); const model = new CreatePullRequestDataModel(folderRepoManager, pullRequestDefaults.owner, pullRequestDefaults.base, compareOrigin.remote.owner, branch?.name ?? pullRequestDefaults.base, compareOrigin.remote.repositoryName); - this._createPRViewProvider = new CreatePullRequestViewProvider( + createViewProvider = this._createPRViewProvider = new CreatePullRequestViewProvider( telemetry, model, extensionUri, @@ -196,7 +228,7 @@ export class CreatePullRequestHelper implements vscode.Disposable { model ); - this.registerListeners(folderRepoManager.repository, !compareBranch); + this.registerListeners(folderRepoManager.repository, !compareBranch, activeContext); this._disposables.push( vscode.window.registerWebviewViewProvider( @@ -204,9 +236,11 @@ export class CreatePullRequestHelper implements vscode.Disposable { this._createPRViewProvider, ), ); + } else { + createViewProvider = this._createPRViewProvider; } - this._createPRViewProvider.show(branch); + createViewProvider.show(branch); } private reset() { diff --git a/src/view/pullRequestCommentController.ts b/src/view/pullRequestCommentController.ts index f89be20744..74e0088f70 100644 --- a/src/view/pullRequestCommentController.ts +++ b/src/view/pullRequestCommentController.ts @@ -8,6 +8,7 @@ import { v4 as uuid } from 'uuid'; import * as vscode from 'vscode'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; import { DiffSide, IComment, SubjectType } from '../common/comment'; +import { ITelemetry } from '../common/telemetry'; import { fromPRUri, Schemes } from '../common/uri'; import { dispose, groupBy } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; @@ -38,8 +39,9 @@ export class PullRequestCommentController extends CommentControllerBase implemen private readonly pullRequestModel: PullRequestModel, folderRepoManager: FolderRepositoryManager, commentController: vscode.CommentController, + telemetry: ITelemetry ) { - super(folderRepoManager); + super(folderRepoManager, telemetry); this._commentController = commentController; this._context = folderRepoManager.context; this._commentHandlerId = uuid(); @@ -454,7 +456,7 @@ export class PullRequestCommentController extends CommentControllerBase implemen public async openReview(): Promise { - await PullRequestOverviewPanel.createOrShow(this._folderRepoManager.context.extensionUri, this._folderRepoManager, this.pullRequestModel); + await PullRequestOverviewPanel.createOrShow(this._telemetry, this._folderRepoManager.context.extensionUri, this._folderRepoManager, this.pullRequestModel); PullRequestOverviewPanel.scrollToReview(); /* __GDPR__ diff --git a/src/view/pullRequestCommentControllerRegistry.ts b/src/view/pullRequestCommentControllerRegistry.ts index d4cedffa04..e7ef126264 100644 --- a/src/view/pullRequestCommentControllerRegistry.ts +++ b/src/view/pullRequestCommentControllerRegistry.ts @@ -5,6 +5,7 @@ 'use strict'; import * as vscode from 'vscode'; +import { ITelemetry } from '../common/telemetry'; import { fromPRUri, Schemes } from '../common/uri'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; import { GHPRComment } from '../github/prComment'; @@ -24,7 +25,7 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid private _activeChangeListeners: Map = new Map(); public readonly resourceHints = { schemes: [Schemes.Pr] }; - constructor(public commentsController: vscode.CommentController) { + constructor(public commentsController: vscode.CommentController, private _telemetry: ITelemetry) { this.commentsController.commentingRangeProvider = this; this.commentsController.reactionHandler = this.toggleReaction.bind(this); } @@ -84,7 +85,7 @@ export class PRCommentControllerRegistry implements vscode.CommentingRangeProvid })); } - const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController); + const handler = new PullRequestCommentController(pullRequestModel, folderRepositoryManager, this.commentsController, this._telemetry); this._prCommentHandlers[prNumber] = { handler, refCount: 1, diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index adf01fd01a..b5e7ce04ba 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -15,6 +15,7 @@ import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPosition import { GitChangeType } from '../common/file'; import Logger from '../common/logger'; import { PR_SETTINGS_NAMESPACE, PULL_BRANCH, PULL_PR_BRANCH_BEFORE_CHECKOUT, PullPRBranchVariants } from '../common/settingKeys'; +import { ITelemetry } from '../common/telemetry'; import { fromReviewUri, ReviewUriParams, Schemes, toReviewUri } from '../common/uri'; import { dispose, formatError, groupBy, uniqBy } from '../common/utils'; import { FolderRepositoryManager } from '../github/folderRepositoryManager'; @@ -59,9 +60,10 @@ export class ReviewCommentController extends CommentControllerBase folderRepoManager: FolderRepositoryManager, private _repository: Repository, private _reviewModel: ReviewModel, - private _gitApi: GitApiImpl + private _gitApi: GitApiImpl, + telemetry: ITelemetry ) { - super(folderRepoManager); + super(folderRepoManager, telemetry); this._context = this._folderRepoManager.context; this._commentController = vscode.comments.createCommentController( `github-review-${folderRepoManager.activePullRequest?.remote.owner}-${folderRepoManager.activePullRequest?.remote.owner}-${folderRepoManager.activePullRequest!.number}`, diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index eb7b91dc71..ac0b357c13 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -905,7 +905,8 @@ export class ReviewManager { this._folderRepoManager, this._repository, this._reviewModel, - this._gitApi + this._gitApi, + this._telemetry ); await this._reviewCommentController.initialize(); @@ -1149,12 +1150,15 @@ export class ReviewManager { } public async createPullRequest(compareBranch?: string): Promise { - const postCreate = async (createdPR: PullRequestModel) => { + const postCreate = async (createdPR: PullRequestModel | undefined) => { + if (!createdPR) { + return; + } + const postCreate = vscode.workspace.getConfiguration(PR_SETTINGS_NAMESPACE).get<'none' | 'openOverview' | 'checkoutDefaultBranch' | 'checkoutDefaultBranchAndShow' | 'checkoutDefaultBranchAndCopy'>(POST_CREATE, 'openOverview'); if (postCreate === 'openOverview') { const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); await openDescription( - this._context, this._telemetry, createdPR, descriptionNode, @@ -1192,7 +1196,6 @@ export class ReviewManager { const descriptionNode = this.changesInPrDataProvider.getDescriptionNode(this._folderRepoManager); await openDescription( - this._context, this._telemetry, pullRequest, descriptionNode, diff --git a/src/view/treeNodes/descriptionNode.ts b/src/view/treeNodes/descriptionNode.ts index 736a6349fe..0cd28e98b5 100644 --- a/src/view/treeNodes/descriptionNode.ts +++ b/src/view/treeNodes/descriptionNode.ts @@ -18,7 +18,7 @@ export class DescriptionNode extends TreeNode implements vscode.TreeItem { constructor( public parent: TreeNodeParent, public label: string, - public pullRequestModel: PullRequestModel, + public readonly pullRequestModel: PullRequestModel, public readonly repository: Repository, private readonly folderRepositoryManager: FolderRepositoryManager ) { diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 8fc2b226ac..e732832a03 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -64,6 +64,12 @@ export class PRContext { public deleteBranch = () => this.postMessage({ command: 'pr.deleteBranch' }); + public revert = async () => { + this.updatePR({ busy: true }); + const revertResult = await this.postMessage({ command: 'pr.revert' }); + this.updatePR({ busy: false, ...revertResult }); + }; + public readyForReview = (): Promise => this.postMessage({ command: 'pr.readyForReview' }); public addReviewers = () => this.postMessage({ command: 'pr.change-reviewers' }); diff --git a/webviews/common/createContextNew.ts b/webviews/common/createContextNew.ts index 31efd91e56..1f8e8d4a3f 100644 --- a/webviews/common/createContextNew.ts +++ b/webviews/common/createContextNew.ts @@ -5,10 +5,12 @@ import { createContext } from 'react'; import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views'; +import { compareIgnoreCase } from '../../src/common/utils'; import { PreReviewState } from '../../src/github/views'; import { getMessageHandler, MessageHandler, vscode } from './message'; const defaultCreateParams: CreateParamsNew = { + canModifyBranches: true, defaultBaseRemote: undefined, defaultBaseBranch: undefined, defaultCompareRemote: undefined, @@ -49,7 +51,25 @@ export class CreatePRContextNew { } } + get isCreatable(): boolean { + if (!this.createParams.canModifyBranches) { + return true; + } + if (this.createParams.baseRemote && this.createParams.compareRemote && this.createParams.baseBranch && this.createParams.compareBranch + && compareIgnoreCase(this.createParams.baseRemote?.owner, this.createParams.compareRemote?.owner) === 0 + && compareIgnoreCase(this.createParams.baseRemote?.repositoryName, this.createParams.compareRemote?.repositoryName) === 0 + && compareIgnoreCase(this.createParams.baseBranch, this.createParams.compareBranch) === 0) { + + return false; + } + return true; + } + get initialized(): boolean { + if (!this.createParams.canModifyBranches) { + return true; + } + if (this.createParams.defaultBaseRemote !== undefined || this.createParams.defaultBaseBranch !== undefined || this.createParams.defaultCompareRemote !== undefined diff --git a/webviews/components/timeline.tsx b/webviews/components/timeline.tsx index f4b94ffc30..37b14bbf9d 100644 --- a/webviews/components/timeline.tsx +++ b/webviews/components/timeline.tsx @@ -17,7 +17,7 @@ import { } from '../../src/common/timelineEvent'; import { groupBy, UnreachableCaseError } from '../../src/common/utils'; import PullRequestContext from '../common/context'; -import { CommentView } from './comment'; +import { CommentView } from './comment'; import Diff from './diff'; import { commitIcon, mergeIcon, plusIcon } from './icon'; import { nbsp } from './space'; @@ -26,26 +26,26 @@ import { AuthorLink, Avatar } from './user'; export const Timeline = ({ events }: { events: TimelineEvent[] }) => ( <> - {events.map(event => { - switch (event.event) { - case EventType.Committed: - return ; - case EventType.Reviewed: - return ; - case EventType.Commented: - return ; - case EventType.Merged: - return ; - case EventType.Assigned: - return ; - case EventType.HeadRefDeleted: - return ; - case EventType.NewCommitsSinceReview: - return ; - default: - throw new UnreachableCaseError(event); - } - })} + {events.map(event => { + switch (event.event) { + case EventType.Committed: + return ; + case EventType.Reviewed: + return ; + case EventType.Commented: + return ; + case EventType.Merged: + return ; + case EventType.Assigned: + return ; + case EventType.HeadRefDeleted: + return ; + case EventType.NewCommitsSinceReview: + return ; + default: + throw new UnreachableCaseError(event); + } + })} ); @@ -66,7 +66,7 @@ const CommitEventView = (event: CommitEvent) => ( -
+
{event.sha.slice(0, 7)} @@ -106,16 +106,16 @@ const ReviewEventView = (event: ReviewEvent) => { const reviewIsPending = event.state === 'PENDING'; return ( - {/* Don't show the empty comment body unless a comment has been written. Shows diffs and suggested changes. */} - {event.comments.length ? ( -
- {Object.entries(comments).map(([key, thread]) => { - return ; - })} -
- ) : null} + {/* Don't show the empty comment body unless a comment has been written. Shows diffs and suggested changes. */} + {event.comments.length ? ( +
+ {Object.entries(comments).map(([key, thread]) => { + return ; + })} +
+ ) : null} - {reviewIsPending ? : null} + {reviewIsPending ? : null}
); }; @@ -222,28 +222,36 @@ function AddReviewSummaryComment() { const CommentEventView = (event: CommentEvent) => ; -const MergedEventView = (event: MergedEvent) => ( -
-
- {mergeIcon} - {nbsp} -
- -
- -
- merged commit{nbsp} - - {event.sha.substr(0, 7)} - - {nbsp} - into {event.mergeRef} +const MergedEventView = (event: MergedEvent) => { + const { revert, pr } = useContext(PullRequestContext); + + return ( +
+
+ {mergeIcon} {nbsp} +
+ +
+ +
+ merged commit{nbsp} + + {event.sha.substr(0, 7)} + + {nbsp} + into {event.mergeRef} + {nbsp} +
+
- + {pr.revertable ? +
+ +
: null}
-
-); + ); +}; const HeadDeleteEventView = (event: HeadRefDeleteEvent) => (
diff --git a/webviews/createPullRequestViewNew/app.tsx b/webviews/createPullRequestViewNew/app.tsx index 25c5b519cf..4756342755 100644 --- a/webviews/createPullRequestViewNew/app.tsx +++ b/webviews/createPullRequestViewNew/app.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { render } from 'react-dom'; import { CreateParamsNew, RemoteInfo } from '../../common/views'; -import { compareIgnoreCase } from '../../src/common/utils'; import { isTeam, MergeMethod } from '../../src/github/interface'; import PullRequestContextNew from '../common/createContextNew'; import { ErrorBoundary } from '../common/errorBoundary'; @@ -89,15 +88,6 @@ export function main() { setBusy(false); } - let isCreateable: boolean = true; - if (ctx.createParams.baseRemote && ctx.createParams.compareRemote && ctx.createParams.baseBranch && ctx.createParams.compareBranch - && compareIgnoreCase(ctx.createParams.baseRemote?.owner, ctx.createParams.compareRemote?.owner) === 0 - && compareIgnoreCase(ctx.createParams.baseRemote?.repositoryName, ctx.createParams.compareRemote?.repositoryName) === 0 - && compareIgnoreCase(ctx.createParams.baseBranch, ctx.createParams.compareBranch) === 0) { - - isCreateable = false; - } - const onKeyDown = useCallback((isTitle: boolean, e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); @@ -198,7 +188,7 @@ export function main() { } return
-
+