Skip to content

Commit

Permalink
Allow a PR to be brought up to date with main and resolve conflicts
Browse files Browse the repository at this point in the history
Fixes #1562, #200
  • Loading branch information
alexr00 committed Jan 15, 2024
1 parent 513e28f commit 3b291e8
Show file tree
Hide file tree
Showing 19 changed files with 271 additions and 8 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@
"shareProvider",
"quickDiffProvider",
"readonlyMessage",
"tabInputTextMerge",
"treeViewMarkdownMessage"
],
"version": "0.78.1",
"publisher": "GitHub",
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.86.0"
},
"categories": [
"Other"
Expand Down
2 changes: 2 additions & 0 deletions src/@types/git.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ export interface Repository {
log(options?: LogOptions): Promise<Commit[]>;

commit(message: string, opts?: CommitOptions): Promise<void>;
merge(ref: string): Promise<void>;
mergeAbort(): Promise<void>;
}

export interface RemoteSource {
Expand Down
25 changes: 25 additions & 0 deletions src/@types/vscode.proposed.tabInputTextMerge.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// https://github.com/microsoft/vscode/issues/153213

declare module 'vscode' {

export class TabInputTextMerge {

readonly base: Uri;
readonly input1: Uri;
readonly input2: Uri;
readonly result: Uri;

constructor(base: Uri, input1: Uri, input2: Uri, result: Uri);
}

export interface Tab {

readonly input: TabInputText | TabInputTextDiff | TabInputTextMerge | TabInputCustom | TabInputWebview | TabInputNotebook | TabInputNotebookDiff | TabInputTerminal | unknown;

}
}
2 changes: 2 additions & 0 deletions src/api/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ export interface Repository {

commit(message: string, opts?: CommitOptions): Promise<void>;
add(paths: string[]): Promise<void>;
merge(ref: string): Promise<void>;
mergeAbort(): Promise<void>;
}

/**
Expand Down
150 changes: 150 additions & 0 deletions src/github/conflictGuide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*---------------------------------------------------------------------------------------------
* 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 { Change, Repository } from '../api/api';
import { commands } from '../common/executeCommands';
import { asPromise, dispose } from '../common/utils';

export class ConflictGuide implements vscode.Disposable {
private _progress: vscode.Progress<{ message?: string; increment?: number }> | undefined;
private readonly _startingConflictsCount: number;
private readonly _oneProgressIncrement: number;
private _lastReportedRemainingCount: number;
private _disposables: vscode.Disposable[] = [];
private _finishedCommit: vscode.EventEmitter<boolean> = new vscode.EventEmitter();
private _finishedConflicts: vscode.EventEmitter<boolean> = new vscode.EventEmitter();
private _message: string;

constructor(private readonly _repository: Repository, private readonly _upstream: string, private readonly _into: string) {
this._startingConflictsCount = this.remainingConflicts.length;
this._lastReportedRemainingCount = this._startingConflictsCount;
this._oneProgressIncrement = 100 / this._startingConflictsCount;
this._repository.inputBox.value = this._message = `Merge branch '${this._upstream}' into ${this._into}`;
this._watchForRemainingConflictsChange();
}

private _watchForRemainingConflictsChange() {
this._disposables.push(vscode.window.tabGroups.onDidChangeTabs(async (e) => {
if (e.closed.length > 0) {
await this._repository.status();
this._reportProgress();
}
}));
}

private _reportProgress() {
const remainingCount = this.remainingConflicts.length;
if (this._progress) {
const increment = (this._lastReportedRemainingCount - remainingCount) * this._oneProgressIncrement;
this._progress.report({ message: vscode.l10n.t('Use the Source Control view to resolve conflicts, {0} of {0} remaining', remainingCount, this._startingConflictsCount), increment });
this._lastReportedRemainingCount = remainingCount;
}
if (remainingCount === 0) {
this._finishedConflicts.fire(true);
this.commit();
}
}

private async commitFromNotification(): Promise<boolean> {
const commit = vscode.l10n.t('Commit');
const cancel = vscode.l10n.t('Abort Merge');
const result = await vscode.window.showInformationMessage(vscode.l10n.t('All conflicts resolved. Commit and push the resolution to continue.'), commit, cancel);
if (result === commit) {
await this._repository.commit(this._message);
this._repository.inputBox.value = '';
await this._repository.push();
return true;
} else {
await this.abort();
return false;
}
}

private async commit() {
let localDisposable: vscode.Disposable | undefined;
const scmCommit = new Promise<boolean>(resolve => {
const startingCommit = this._repository.state.HEAD?.commit;
localDisposable = this._repository.state.onDidChange(() => {
if (this._repository.state.HEAD?.commit !== startingCommit && this._repository.state.indexChanges.length === 0 && this._repository.state.mergeChanges.length === 0) {
resolve(true);
}
});
this._disposables.push(localDisposable);
});

const notificationCommit = this.commitFromNotification();

const result = await Promise.race([scmCommit, notificationCommit]);
localDisposable?.dispose();
this._finishedCommit.fire(result);
}

get remainingConflicts(): Change[] {
return this._repository.state.mergeChanges;
}

private async closeMergeEditors(): Promise<void> {
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputTextMerge) {
vscode.window.tabGroups.close(tab);
}
}
}
}

private async abort(): Promise<void> {
this._repository.inputBox.value = '';
// set up an event to listen for when we are all out of merge changes before closing the merge editors.
// Just waiting for the merge doesn't cut it
// Even with this, we still need to wait 1 second, and then it still might say there are conflicts. Why is this?
const disposable = this._repository.state.onDidChange(async () => {
if (this._repository.state.mergeChanges.length === 0) {
await new Promise<void>(resolve => setTimeout(resolve, 1000));
this.closeMergeEditors();
disposable.dispose();
}
});
await this._repository.mergeAbort();
this._finishedCommit.fire(false);
}

private async first(progress: vscode.Progress<{ message?: string; increment?: number }>, cancellationToken: vscode.CancellationToken): Promise<void> {
this._progress = progress;
if (this.remainingConflicts.length === 0) {
return;
}
await commands.focusView('workbench.scm');
this._reportProgress();
const change = this.remainingConflicts[0];
this._disposables.push(cancellationToken.onCancellationRequested(() => this.abort()));
await commands.executeCommand('git.openMergeEditor', change.uri);
}

public static async begin(repository: Repository, upstream: string, into: string): Promise<ConflictGuide | undefined> {
const wizard = new ConflictGuide(repository, upstream, into);
if (wizard.remainingConflicts.length === 0) {
return undefined;
}
vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, cancellable: true }, async (progress, token) => {
wizard.first(progress, token);
return wizard.finishedConflicts();
});
return wizard;
}

private finishedConflicts(): Promise<boolean> {
return asPromise(this._finishedConflicts.event);
}

public finished(): Promise<boolean> {
return asPromise(this._finishedCommit.event);
}

dispose() {
dispose(this._disposables);
}
}
1 change: 1 addition & 0 deletions src/github/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ export interface PullRequest {
};
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanUpdateBranch: boolean;
isDraft?: boolean;
suggestedReviewers: SuggestedReviewerResponse[];
projectItems?: {
Expand Down
1 change: 1 addition & 0 deletions src/github/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export interface PullRequest extends Issue {
merged?: boolean;
mergeable?: PullRequestMergeability;
mergeQueueEntry?: MergeQueueEntry | null;
canUpdateBranch: boolean;
autoMerge?: boolean;
autoMergeMethod?: MergeMethod;
allowAutoMerge?: boolean;
Expand Down
36 changes: 36 additions & 0 deletions src/github/pullRequestOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
'use strict';

import * as vscode from 'vscode';
import { GitErrorCodes } from '../api/api1';
import { onDidUpdatePR, openPullRequestOnGitHub } from '../commands';
import { IComment } from '../common/comment';
import Logger from '../common/logger';
import { DEFAULT_MERGE_METHOD, PR_SETTINGS_NAMESPACE } from '../common/settingKeys';
import { ReviewEvent as CommonReviewEvent } from '../common/timelineEvent';
import { asPromise, dispose, formatError } from '../common/utils';
import { IRequestMessage, PULL_REQUEST_OVERVIEW_VIEW_TYPE } from '../common/webview';
import { ConflictGuide } from './conflictGuide';
import { FolderRepositoryManager } from './folderRepositoryManager';
import {
GithubItemStateEnum,
Expand All @@ -23,6 +25,7 @@ import {
ITeam,
MergeMethod,
MergeMethodsAvailability,
PullRequestMergeability,
reviewerId,
ReviewEvent,
ReviewState,
Expand Down Expand Up @@ -262,6 +265,7 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
hasWritePermission,
status: status[0],
reviewRequirement: status[1],
canUpdateBranch: pullRequest.item.canUpdateBranch,
mergeable: pullRequest.item.mergeable,
reviewers: this._existingReviewers,
isDraft: pullRequest.isDraft,
Expand Down Expand Up @@ -374,6 +378,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
return this.dequeue(message);
case 'pr.enqueue':
return this.enqueue(message);
case 'pr.update-branch':
return this.updateBranch(message);
case 'pr.gotoChangesSinceReview':
this.gotoChangesSinceReview();
break;
Expand Down Expand Up @@ -814,6 +820,36 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
this._replyMessage(message, { mergeQueueEntry: result });
}

private async updateBranch(message: IRequestMessage<string>): Promise<void> {
if (this._folderRepositoryManager.repository.state.workingTreeChanges.length > 0 || this._folderRepositoryManager.repository.state.indexChanges.length > 0) {
await vscode.window.showErrorMessage(vscode.l10n.t('The pull request branch cannot be updated when the there changed files in the working tree or index. Stash or commit all change and then try again.'), { modal: true });
return this._replyMessage(message, {});
}
const qualifiedUpstream = `${this._item.remote.remoteName}/${this._item.base.ref}`;
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification }, async (progress) => {
progress.report({ message: vscode.l10n.t('Fetching branch') });
await this._folderRepositoryManager.repository.fetch({ ref: this._item.base.ref, remote: this._item.remote.remoteName });
progress.report({ message: vscode.l10n.t('Merging branch') });
try {
await this._folderRepositoryManager.repository.merge(qualifiedUpstream);
} catch (e) {
if (e.gitErrorCode !== GitErrorCodes.Conflict) {
throw e;
}
}
});

if (this._item.item.mergeable === PullRequestMergeability.Conflict) {
const wizard = await ConflictGuide.begin(this._folderRepositoryManager.repository, this._item.base.ref, this._folderRepositoryManager.repository.state.HEAD!.name!);
await wizard?.finished();
wizard?.dispose();
} else {
await this._folderRepositoryManager.repository.push();
}

this._replyMessage(message, {});
}

protected editCommentPromise(comment: IComment, text: string): Promise<IComment> {
return this._item.editReviewComment(comment, text);
}
Expand Down
1 change: 1 addition & 0 deletions src/github/queries.gql
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdateBranch
id
databaseId
isDraft
Expand Down
1 change: 1 addition & 0 deletions src/github/queriesExtra.gql
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdateBranch
id
databaseId
isDraft
Expand Down
1 change: 1 addition & 0 deletions src/github/queriesLimited.gql
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fragment PullRequestFragment on PullRequest {
}
viewerCanEnableAutoMerge
viewerCanDisableAutoMerge
viewerCanUpdateBranch
id
databaseId
isDraft
Expand Down
8 changes: 4 additions & 4 deletions src/github/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function convertRESTPullRequestToRawPullRequest(
: undefined,
createdAt: created_at,
updatedAt: updated_at,
canUpdateBranch: false,
head: head.repo ? convertRESTHeadToIGitHubRef(head as OctokitCommon.PullsListResponseItemHead) : undefined,
base: convertRESTHeadToIGitHubRef(base),
labels: labels.map<ILabel>(l => ({ name: '', color: '', ...l })),
Expand All @@ -339,7 +340,7 @@ export function convertRESTPullRequestToRawPullRequest(
export function convertRESTIssueToRawPullRequest(
pullRequest: OctokitCommon.IssuesCreateResponseData,
githubRepository: GitHubRepository,
): PullRequest {
): Issue {
const {
number,
body,
Expand All @@ -355,7 +356,7 @@ export function convertRESTIssueToRawPullRequest(
id,
} = pullRequest;

const item: PullRequest = {
const item: Issue = {
id,
graphNodeId: node_id,
number,
Expand All @@ -373,9 +374,7 @@ export function convertRESTIssueToRawPullRequest(
labels: labels.map<ILabel>(l =>
typeof l === 'string' ? { name: l, color: '' } : { name: l.name ?? '', color: l.color ?? '', description: l.description ?? undefined },
),
suggestedReviewers: [], // suggested reviewers only available through GraphQL API,
projectItems: [], // projects only available through GraphQL API
commits: [], // commits only available through GraphQL API
};

return item;
Expand Down Expand Up @@ -702,6 +701,7 @@ export function parseGraphQLPullRequest(
autoMerge: !!graphQLPullRequest.autoMergeRequest,
autoMergeMethod: parseMergeMethod(graphQLPullRequest.autoMergeRequest?.mergeMethod),
allowAutoMerge: graphQLPullRequest.viewerCanEnableAutoMerge || graphQLPullRequest.viewerCanDisableAutoMerge,
canUpdateBranch: graphQLPullRequest.viewerCanUpdateBranch,
labels: graphQLPullRequest.labels.nodes,
isDraft: graphQLPullRequest.isDraft,
suggestedReviewers: parseSuggestedReviewers(graphQLPullRequest.suggestedReviewers),
Expand Down
1 change: 1 addition & 0 deletions src/github/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface PullRequest {
pendingReviewType?: ReviewType;
status: PullRequestChecks | null;
reviewRequirement: PullRequestReviewRequirement | null;
canUpdateBranch: boolean;
mergeable: PullRequestMergeability;
defaultMergeMethod: MergeMethod;
mergeMethodsAvailability: MergeMethodsAvailability;
Expand Down
1 change: 1 addition & 0 deletions src/test/builders/graphql/pullRequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const PullRequestBuilder = createBuilderClass<PullRequestResponse>()({
suggestedReviewers: { default: [] },
viewerCanEnableAutoMerge: { default: false },
viewerCanDisableAutoMerge: { default: false },
viewerCanUpdateBranch: { default: false },
commits: createLink<CommitsConn>()({
nodes: {
default: [
Expand Down
Loading

0 comments on commit 3b291e8

Please sign in to comment.