diff --git a/package.json b/package.json index 8710200fc..f1ab2b93d 100644 --- a/package.json +++ b/package.json @@ -1211,6 +1211,12 @@ "title": "%command.review.requestChangesOnDotCom.title%", "category": "%command.pull.request.category%" }, + { + "command": "review.createSuggestionsFromChanges", + "title": "%command.review.createSuggestionsFromChanges.title%", + "icon": "$(gift)", + "category": "%command.pull.request.category%" + }, { "command": "pr.createPrMenuCreate", "title": "%command.pr.createPrMenuCreate.title%", @@ -1718,6 +1724,10 @@ "command": "review.requestChangesOnDotComDescription", "when": "false" }, + { + "command": "review.createSuggestionsFromChanges", + "when": "false" + }, { "command": "pr.refreshList", "when": "gitHubOpenRepositoryCount != 0 && github:authenticated && github:hasGitHubRemotes" @@ -2442,6 +2452,13 @@ "group": "navigation" } ], + "scm/resourceGroup/context": [ + { + "command": "review.createSuggestionsFromChanges", + "when": "scmProviderRootUri in github:reposInReviewMode && scmProvider =~ /^git|^remoteHub:github/ && scmResourceGroup == workingTree", + "group": "inline@-2" + } + ], "comments/commentThread/context": [ { "command": "pr.createComment", diff --git a/package.nls.json b/package.nls.json index be0107b9e..81a31475f 100644 --- a/package.nls.json +++ b/package.nls.json @@ -191,6 +191,8 @@ "command.review.requestChanges.title": "Request Changes", "command.review.approveOnDotCom.title": "Approve on github.com", "command.review.requestChangesOnDotCom.title": "Request changes on github.com", + "command.review.createSuggestionsFromChanges.title": "Create Pull Request Suggestions", + "command.review.createSuggestionsFromChange.title": "Convert to Pull Request Suggestion", "command.pr.refreshList.title": "Refresh Pull Requests List", "command.pr.setFileListLayoutAsTree.title": "View as Tree", "command.pr.setFileListLayoutAsFlat.title": "View as List", diff --git a/src/commands.ts b/src/commands.ts index 8625413b3..066d51fe9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1154,6 +1154,18 @@ ${contents} }), ); + context.subscriptions.push(vscode.commands.registerCommand('review.createSuggestionsFromChanges', async (value: { resourceStates: { resourceUri }[] }) => { + if (value.resourceStates.length === 0) { + return; + } + const folderManager = reposManager.getManagerForFile(value.resourceStates[0].resourceUri); + if (!folderManager || !folderManager.activePullRequest) { + return; + } + const reviewManager = ReviewManager.getReviewManagerForFolderManager(reviewsManager.reviewManagers, folderManager); + return reviewManager?.createSuggestionsFromChanges(); + })); + context.subscriptions.push( vscode.commands.registerCommand('pr.refreshChanges', _ => { reviewsManager.reviewManagers.forEach(reviewManager => { diff --git a/src/view/reviewCommentController.ts b/src/view/reviewCommentController.ts index c6465dbc9..bd7c7546f 100644 --- a/src/view/reviewCommentController.ts +++ b/src/view/reviewCommentController.ts @@ -11,6 +11,7 @@ import { GitApiImpl } from '../api/api1'; import { CommentHandler, registerCommentHandler, unregisterCommentHandler } from '../commentHandlerResolver'; import { DiffSide, IReviewThread, SubjectType } from '../common/comment'; import { getCommentingRanges } from '../common/commentingRanges'; +import { DiffChangeType, DiffHunk } from '../common/diffHunk'; import { mapNewPositionToOld, mapOldPositionToNew } from '../common/diffPositionMapping'; import { GitChangeType } from '../common/file'; import Logger from '../common/logger'; @@ -796,6 +797,56 @@ export class ReviewCommentController extends CommentControllerBase } } + private trimContextFromHunk(hunk: DiffHunk) { + let oldLineNumber = hunk.oldLineNumber; + let oldLength = hunk.oldLength; + + // start at 1 to skip the control line + let i = 1; + for (; i < hunk.diffLines.length; i++) { + const line = hunk.diffLines[i]; + if (line.type === DiffChangeType.Context) { + oldLineNumber++; + oldLength--; + } else { + break; + } + } + let j = hunk.diffLines.length - 1; + for (; j >= 0; j--) { + if (hunk.diffLines[j].type === DiffChangeType.Context) { + oldLength--; + } else { + break; + } + } + hunk.diffLines = hunk.diffLines.slice(i, j + 1); + hunk.oldLength = oldLength; + hunk.oldLineNumber = oldLineNumber; + } + + async createSuggestionsFromChanges(file: vscode.Uri, hunk: DiffHunk) { + const activePr = this._folderRepoManager.activePullRequest; + if (!activePr) { + return; + } + + this.trimContextFromHunk(hunk); + + const path = this.gitRelativeRootPath(file.path); + const body = `\`\`\`suggestion +${hunk.diffLines.filter(line => (line.type === DiffChangeType.Add) || (line.type == DiffChangeType.Context)).map(line => line.text).join('\n')} +\`\`\``; + await activePr.createReviewThread( + body, + path, + hunk.oldLineNumber, + hunk.oldLineNumber + hunk.oldLength - 1, + DiffSide.RIGHT, + false, + ); + } + private async createCommentOnResolve(thread: GHPRCommentThread, input: string): Promise { if (!this._folderRepoManager.activePullRequest) { throw new Error('Cannot create comment on resolve without an active pull request.'); diff --git a/src/view/reviewManager.ts b/src/view/reviewManager.ts index 88e816c97..6c0fcb748 100644 --- a/src/view/reviewManager.ts +++ b/src/view/reviewManager.ts @@ -6,9 +6,9 @@ import * as nodePath from 'path'; import * as vscode from 'vscode'; import type { Branch, Repository } from '../api/api'; -import { GitApiImpl, GitErrorCodes } from '../api/api1'; +import { GitApiImpl, GitErrorCodes, Status } from '../api/api1'; import { openDescription } from '../commands'; -import { DiffChangeType } from '../common/diffHunk'; +import { DiffChangeType, parsePatch } from '../common/diffHunk'; import { commands } from '../common/executeCommands'; import { GitChangeType, InMemFileChange, SlimFileChange } from '../common/file'; import Logger from '../common/logger'; @@ -53,7 +53,7 @@ export class ReviewManager { private _localToDispose: vscode.Disposable[] = []; private _disposables: vscode.Disposable[]; - private _reviewModel: ReviewModel = new ReviewModel(); + private readonly _reviewModel: ReviewModel = new ReviewModel(); private _lastCommitSha?: string; private _updateMessageShown: boolean = false; private _validateStatusInProgress?: Promise; @@ -691,6 +691,37 @@ export class ReviewManager { return Promise.all(reopenPromises); } + async createSuggestionsFromChanges() { + let hasError: boolean = false; + const convertedFiles: vscode.Uri[] = []; + await vscode.window.withProgress({ location: vscode.ProgressLocation.Window, title: 'Converting changes to suggestions' }, async () => { + await Promise.all(this._folderRepoManager.repository.state.workingTreeChanges.map(async changeFile => { + if (changeFile.status !== Status.MODIFIED) { + return; + } + const diff = parsePatch(await this._folderRepoManager.repository.diffWithHEAD(changeFile.uri.fsPath)); + try { + await Promise.all(diff.map(async hunk => { + await this._reviewCommentController?.createSuggestionsFromChanges(changeFile.uri, hunk); + convertedFiles.push(changeFile.uri); + })); + } catch (e) { + hasError = true; + } + })); + }); + if (!hasError) { + const checkoutAllFilesResponse = vscode.l10n.t('Checkout all files'); + vscode.window.showInformationMessage(vscode.l10n.t('All changes have been converted to suggestions.'), { modal: true, detail: vscode.l10n.t('Do you want to checkout all files and reset your working state to match the pull request state?') }, checkoutAllFilesResponse).then((response) => { + if (response === checkoutAllFilesResponse) { + return Promise.all(convertedFiles.map(changeFile => this._folderRepoManager.repository.checkout(changeFile.fsPath))); + } + }); + } else { + vscode.window.showWarningMessage(vscode.l10n.t('Not all changes could be converted to suggestions.'), { detail: vscode.l10n.t('Some of the changes may be outside of commenting ranges.'), modal: true }); + } + } + public async updateComments(): Promise { const branch = this._repository.state.HEAD; if (!branch) {