提交 06888a24 编写于 作者: T Tomas Vik

Merge branch '63-checkout-mr-branch' into 'main'

feat(view issues-and-mrs): checkout local branch for merge request

See merge request gitlab-org/gitlab-vscode-extension!295
......@@ -169,6 +169,10 @@
"command": "gl.createComment",
"title": "Add comment now",
"category": "GitLab"
},
{
"command": "gl.checkoutMrBranch",
"title": "Checkout MR branch"
}
],
"menus": {
......@@ -201,6 +205,10 @@
"command": "gl.createComment",
"when": "false"
},
{
"command": "gl.checkoutMrBranch",
"when": "false"
},
{
"command": "gl.showIssuesAssignedToMe",
"when": "gitlab:validState"
......@@ -281,6 +289,12 @@
"group": "navigation"
}
],
"view/item/context": [
{
"command": "gl.checkoutMrBranch",
"when": "view =~ /issuesAndMrs/ && viewItem == mr-item-from-same-project"
}
],
"comments/comment/title": [
{
"command": "gl.startEditingComment",
......
......@@ -17,6 +17,7 @@ module.exports = {
createCommentController: jest.fn(),
},
window: {
showInformationMessage: jest.fn(),
showWarningMessage: jest.fn(),
showErrorMessage: jest.fn(),
createStatusBarItem: jest.fn(),
......
......@@ -31,6 +31,7 @@ export const USER_COMMANDS = {
CANCEL_EDITING_COMMENT: 'gl.cancelEditingComment',
SUBMIT_COMMENT_EDIT: 'gl.submitCommentEdit',
CREATE_COMMENT: 'gl.createComment',
CHECKOUT_MR_BRANCH: 'gl.checkoutMrBranch',
};
/*
......@@ -44,4 +45,5 @@ export const PROGRAMMATIC_COMMANDS = {
export const VS_COMMANDS = {
DIFF: 'vscode.diff',
OPEN: 'vscode.open',
GIT_SHOW_OUTPUT: 'git.showOutput',
};
import * as vscode from 'vscode';
import { MrItemModel } from '../data_providers/items/mr_item_model';
import { checkoutMrBranch } from './checkout_mr_branch';
import { WrappedRepository } from '../git/wrapped_repository';
import { mr } from '../test_utils/entities';
import { GitErrorCodes } from '../api/git';
describe('checkout MR branch', () => {
let mrItemModel: MrItemModel;
let wrappedRepository: WrappedRepository;
beforeEach(() => {
const mockRepository: Partial<WrappedRepository> = {
fetch: jest.fn().mockResolvedValue(undefined),
checkout: jest.fn().mockResolvedValue(undefined),
lastCommitSha: mr.sha,
};
wrappedRepository = mockRepository as WrappedRepository;
});
afterEach(() => {
jest.resetAllMocks();
});
describe('with branch from the same project', () => {
beforeEach(() => {
const mrFromTheSameProject = {
...mr,
source_project_id: 123,
target_project_id: 123,
source_branch_name: 'feature-a',
};
mrItemModel = new MrItemModel(mrFromTheSameProject, wrappedRepository);
});
it('checks out the local branch', async () => {
await checkoutMrBranch(mrItemModel);
expect(wrappedRepository.fetch).toBeCalled();
expect(wrappedRepository.checkout).toBeCalledWith('feature-a');
});
it('shows a success message', async () => {
await checkoutMrBranch(mrItemModel);
expect(vscode.window.showInformationMessage).toBeCalledWith('Branch changed to feature-a');
});
it('rejects with an error if error occurred', async () => {
(wrappedRepository.checkout as jest.Mock).mockRejectedValue(new Error('error'));
await expect(checkoutMrBranch(mrItemModel)).rejects.toEqual(new Error('error'));
});
it('handles errors from the Git Extension', async () => {
(wrappedRepository.checkout as jest.Mock).mockRejectedValue({
gitErrorCode: GitErrorCodes.DirtyWorkTree,
stderr: 'Git standard output',
});
await checkoutMrBranch(mrItemModel);
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
'Checkout failed: Git standard output',
'See Git Log',
);
});
it('warns user that their local branch is not in sync', async () => {
(wrappedRepository as any).lastCommitSha = 'abdef'; // simulate local sha being different from mr.sha
await checkoutMrBranch(mrItemModel);
expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(
"Branch changed to feature-a, but it's out of sync with the remote branch. Synchronize it by pushing or pulling.",
);
});
});
describe('with branch from a forked project', () => {
beforeEach(() => {
const mrFromAFork = {
...mr,
source_project_id: 123,
target_project_id: 456,
source_branch_name: 'feature-a',
};
mrItemModel = new MrItemModel(mrFromAFork, wrappedRepository);
});
it('throws an error', async () => {
await expect(checkoutMrBranch(mrItemModel)).rejects.toMatchObject({
message: 'this command is only available for same-project MRs',
});
});
});
});
import * as vscode from 'vscode';
import * as assert from 'assert';
import { MrItemModel } from '../data_providers/items/mr_item_model';
import { VS_COMMANDS } from '../command_names';
import { doNotAwait } from '../utils/do_not_await';
const handleGitError = async (e: { stderr: string }) => {
const SEE_GIT_LOG = 'See Git Log';
const choice = await vscode.window.showErrorMessage(`Checkout failed: ${e.stderr}`, SEE_GIT_LOG);
if (choice === SEE_GIT_LOG) {
await vscode.commands.executeCommand(VS_COMMANDS.GIT_SHOW_OUTPUT);
}
};
/**
* Command will checkout source branch for merge request. Merge request must be from local branch.
*/
export const checkoutMrBranch = async (mrItemModel: MrItemModel): Promise<void> => {
const { mr } = mrItemModel;
assert(
mr.target_project_id === mr.source_project_id,
'this command is only available for same-project MRs',
);
try {
const { repository } = mrItemModel;
doNotAwait(vscode.window.showInformationMessage('Fetching branches...'));
await repository.fetch();
await repository.checkout(mr.source_branch);
if (repository.lastCommitSha !== mr.sha) {
await vscode.window.showWarningMessage(
`Branch changed to ${mr.source_branch}, but it's out of sync with the remote branch. Synchronize it by pushing or pulling.`,
);
return;
}
await vscode.window.showInformationMessage(`Branch changed to ${mr.source_branch}`);
} catch (e) {
if (e.gitErrorCode) {
await handleGitError(e);
return;
}
throw e;
}
};
import * as vscode from 'vscode';
import { MrItemModel } from './mr_item_model';
import { mr, repository } from '../../test_utils/entities';
import { mr } from '../../test_utils/entities';
import {
discussionOnDiff,
noteOnDiffTextSnippet,
......@@ -9,6 +9,7 @@ import {
import { CommentingRangeProvider } from '../../review/commenting_range_provider';
import { createWrappedRepository } from '../../test_utils/create_wrapped_repository';
import { fromReviewUri } from '../../review/review_uri';
import { WrappedRepository } from '../../git/wrapped_repository';
const createCommentControllerMock = vscode.comments.createCommentController as jest.Mock;
......@@ -18,6 +19,7 @@ describe('MrItemModel', () => {
let canUserCommentOnMr = false;
let commentController: any;
let gitLabService: any;
let repository: WrappedRepository;
const createCommentThreadMock = jest.fn();
......@@ -27,7 +29,7 @@ describe('MrItemModel', () => {
getMrDiff: jest.fn().mockResolvedValue({ diffs: [] }),
canUserCommentOnMr: jest.fn(async () => canUserCommentOnMr),
};
const repository = createWrappedRepository({
repository = createWrappedRepository({
gitLabService,
});
item = new MrItemModel(mr, repository);
......@@ -45,6 +47,24 @@ describe('MrItemModel', () => {
createCommentThreadMock.mockReset();
});
describe('MR item context', () => {
it('should return return correct context when MR comes from the same project', () => {
item = new MrItemModel(
{ ...mr, source_project_id: 1234, target_project_id: 1234 },
repository,
);
expect(item.getTreeItem().contextValue).toBe('mr-item-from-same-project');
});
it('should return return correct context when MR comes from a fork', () => {
item = new MrItemModel(
{ ...mr, source_project_id: 5678, target_project_id: 1234 },
repository,
);
expect(item.getTreeItem().contextValue).toBe('mr-item-from-fork');
});
});
it('should add comment thread to VS Code', async () => {
await item.getChildren();
expect(createCommentControllerMock).toBeCalledWith(
......
......@@ -58,6 +58,7 @@ export class MrItemModel extends ItemModel {
if (author.avatar_url) {
item.iconPath = vscode.Uri.parse(author.avatar_url);
}
item.contextValue = `mr-item-from-${this.isFromFork ? 'fork' : 'same-project'}`;
return item;
}
......@@ -121,4 +122,8 @@ export class MrItemModel extends ItemModel {
);
});
}
get isFromFork(): boolean {
return this.mr.target_project_id !== this.mr.source_project_id;
}
}
......@@ -29,6 +29,7 @@ const {
} = require('./commands/mr_discussion_commands');
const { fileDecorationProvider } = require('./review/file_decoration_provider');
const { checkVersion } = require('./utils/check_version');
const { checkoutMrBranch } = require('./commands/checkout_mr_branch');
vscode.gitLabWorkflow = {
sidebarDataProviders: [],
......@@ -87,6 +88,7 @@ const registerCommands = (context, outputChannel) => {
[USER_COMMANDS.CANCEL_EDITING_COMMENT]: cancelEdit,
[USER_COMMANDS.SUBMIT_COMMENT_EDIT]: submitEdit,
[USER_COMMANDS.CREATE_COMMENT]: createComment,
[USER_COMMANDS.CHECKOUT_MR_BRANCH]: checkoutMrBranch,
[PROGRAMMATIC_COMMANDS.NO_IMAGE_REVIEW]: () =>
vscode.window.showInformationMessage("GitLab MR review doesn't support images yet."),
};
......
......@@ -79,6 +79,25 @@ export class WrappedRepository {
return preferredRemote || branchRemote || firstRemote || 'origin';
}
async fetch(): Promise<void> {
await this.rawRepository.fetch();
}
async checkout(branchName: string): Promise<void> {
await this.rawRepository.checkout(branchName);
assert(
this.rawRepository.state.HEAD,
"We can't read repository HEAD. We suspect that your `git head` command fails and we can't continue till it succeeds",
);
const currentBranchName = this.rawRepository.state.HEAD.name;
assert(
currentBranchName === branchName,
`The branch name after the checkout (${currentBranchName}) is not the branch that the extension tried to check out (${branchName}). Inspect your repository before making any more changes.`,
);
}
getRemoteByName(remoteName: string): GitRemote {
const remoteUrl = this.rawRepository.state.remotes.find(r => r.name === remoteName)?.fetchUrl;
assert(remoteUrl, `could not find any URL for git remote with name '${this.remoteName}'`);
......
......@@ -30,6 +30,9 @@ export const mr: RestMr = {
full: 'gitlab-org/gitlab!2000',
},
sha: '69ad609e8891b8aa3db85a35cd2c5747705bd76a',
source_project_id: 9999,
target_project_id: 9999,
source_branch: 'feature-a',
};
export const diffFile: RestDiffFile = {
......
......@@ -23,6 +23,9 @@ interface RestIssuable {
interface RestMr extends RestIssuable {
sha: string;
source_project_id: number;
target_project_id: number;
source_branch: string;
}
interface RestMrVersion {
......
/** doNotAwait is used to circumvent the otherwise invaluable
* @typescript-eslint/no-floating-promises rule. This util is meant
* for informative messages that would otherwise block execution */
export const doNotAwait = (promise: Thenable<any>): void => {
// not waiting for the promise
};
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册