Merge branch 'fetch-forked-projects-create-mr' into 'master'

Create private merge requests in forks

Closes #58583

See merge request gitlab-org/gitlab-ce!29984
This commit is contained in:
Filipa Lacerda 2019-07-05 13:16:19 +00:00
commit a816bad9a4
16 changed files with 599 additions and 15 deletions

View file

@ -0,0 +1,58 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
Icon,
},
props: {
projects: {
type: Array,
required: true,
},
selectedProject: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
dropdownText() {
if (Object.keys(this.selectedProject).length) {
return this.selectedProject.name;
}
return __('Select private project');
},
},
methods: {
selectProject(project) {
this.$emit('click', project);
},
},
};
</script>
<template>
<gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100">
<template slot="button-content">
<span class="str-truncated-100 mr-2">
<icon name="lock" />
{{ dropdownText }}
</span>
<icon name="chevron-down" class="ml-auto" />
</template>
<gl-dropdown-item v-for="project in projects" :key="project.id" @click="selectProject(project)">
<icon
name="mobile-issue-close"
:class="{ icon: project.id !== selectedProject.id }"
class="js-active-project-check"
/>
<span class="ml-1">{{ project.name }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>

View file

@ -0,0 +1,136 @@
<script>
import { GlLink } from '@gitlab/ui';
import { __, sprintf } from '../../locale';
import createFlash from '../../flash';
import Api from '../../api';
import state from '../state';
import Dropdown from './dropdown.vue';
export default {
components: {
GlLink,
Dropdown,
},
props: {
namespacePath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
newForkPath: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
},
data() {
return {
projects: [],
};
},
computed: {
selectedProject() {
return state.selectedProject;
},
noForkText() {
return sprintf(
__(
'To protect this issues confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.',
),
{ link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' },
false,
);
},
},
mounted() {
this.fetchProjects();
this.createBtn = document.querySelector('.js-create-target');
this.warningText = document.querySelector('.js-exposed-info-warning');
},
methods: {
selectProject(project) {
if (project) {
Object.assign(state, {
selectedProject: project,
});
if (project.namespaceFullPath !== this.namespacePath) {
this.showWarning();
}
} else if (this.createBtn) {
this.createBtn.setAttribute('disabled', 'disabled');
}
},
normalizeProjectData(data) {
return data.map(p => ({
id: p.id,
name: p.name_with_namespace,
pathWithNamespace: p.path_with_namespace,
namespaceFullpath: p.namespace.full_path,
}));
},
fetchProjects() {
Api.projectForks(this.projectPath, {
with_merge_requests_enabled: true,
min_access_level: 30,
visibility: 'private',
})
.then(({ data }) => {
this.projects = this.normalizeProjectData(data);
this.selectProject(this.projects[0]);
})
.catch(e => {
createFlash(__('Error fetching forked projects. Please try again.'));
throw e;
});
},
showWarning() {
if (this.warningText) {
this.warningText.classList.remove('hidden');
}
if (this.createBtn) {
this.createBtn.classList.add('btn-warning');
this.createBtn.classList.remove('btn-success');
}
},
},
};
</script>
<template>
<div class="form-group">
<label>{{ __('Project') }}</label>
<div>
<dropdown
v-if="projects.length"
:projects="projects"
:selected-project="selectedProject"
@click="selectProject"
/>
<p class="text-muted mt-1 mb-0">
<template v-if="projects.length">
{{
__(
'To protect this issues confidentiality, a private fork of this project was selected.',
)
}}
</template>
<template v-else>
{{ __('No forks available to you.') }}<br />
<span v-html="noForkText"></span>
</template>
<gl-link :href="helpPagePath" class="help-link" target="_blank">
<span class="sr-only">{{ __('Read more') }}</span>
<i class="fa fa-question-circle" aria-hidden="true"></i>
</gl-link>
</p>
</div>
</div>
</template>

View file

@ -0,0 +1,30 @@
import Vue from 'vue';
import { parseBoolean } from '../lib/utils/common_utils';
import ProjectFormGroup from './components/project_form_group.vue';
import state from './state';
export function isConfidentialIssue() {
return parseBoolean(document.querySelector('.js-create-mr').dataset.isConfidential);
}
export function canCreateConfidentialMergeRequest() {
return isConfidentialIssue() && Object.keys(state.selectedProject).length > 0;
}
export function init() {
const el = document.getElementById('js-forked-project');
return new Vue({
el,
render(h) {
return h(ProjectFormGroup, {
props: {
namespacePath: el.dataset.namespacePath,
projectPath: el.dataset.projectPath,
newForkPath: el.dataset.newForkPath,
helpPagePath: el.dataset.helpPagePath,
},
});
},
});
}

View file

@ -0,0 +1,5 @@
import Vue from 'vue';
export default Vue.observable({
selectedProject: {},
});

View file

@ -5,6 +5,12 @@ import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
import {
init as initConfidentialMergeRequest,
isConfidentialIssue,
canCreateConfidentialMergeRequest,
} from './confidential_merge_request';
import confidentialMergeRequestState from './confidential_merge_request/state';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
@ -12,6 +18,17 @@ const InputSetter = Object.assign({}, ISetter);
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
function createEndpoint(projectPath, endpoint) {
if (canCreateConfidentialMergeRequest()) {
return endpoint.replace(
projectPath,
confidentialMergeRequestState.selectedProject.pathWithNamespace,
);
}
return endpoint;
}
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
@ -42,6 +59,8 @@ export default class CreateMergeRequestDropdown {
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
this.projectPath = this.wrapperEl.dataset.projectPath;
this.projectId = this.wrapperEl.dataset.projectId;
// These regexps are used to replace
// a backend generated new branch name and its source (ref)
@ -58,6 +77,14 @@ export default class CreateMergeRequestDropdown {
};
this.init();
if (isConfidentialIssue()) {
this.createMergeRequestButton.setAttribute(
'data-dropdown-trigger',
'#create-merge-request-dropdown',
);
initConfidentialMergeRequest();
}
}
available() {
@ -113,7 +140,9 @@ export default class CreateMergeRequestDropdown {
this.isCreatingBranch = true;
return axios
.post(this.createBranchPath)
.post(createEndpoint(this.projectPath, this.createBranchPath), {
confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null,
})
.then(({ data }) => {
this.branchCreated = true;
window.location.href = data.url;
@ -125,7 +154,11 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = true;
return axios
.post(this.createMrPath)
.post(this.createMrPath, {
target_project_id: canCreateConfidentialMergeRequest()
? confidentialMergeRequestState.selectedProject.id
: null,
})
.then(({ data }) => {
this.mergeRequestCreated = true;
window.location.href = data.url;
@ -149,6 +182,8 @@ export default class CreateMergeRequestDropdown {
}
enable() {
if (!canCreateConfidentialMergeRequest()) return;
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
@ -205,7 +240,7 @@ export default class CreateMergeRequestDropdown {
if (!ref) return false;
return axios
.get(`${this.refsPath}${encodeURIComponent(ref)}`)
.get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`)
.then(({ data }) => {
const branches = data[Object.keys(data)[0]];
const tags = data[Object.keys(data)[1]];
@ -325,6 +360,12 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
this.droplab.hooks.forEach(hook => hook.list.toggle());
return;
}
if (this.isBusy()) {
return;
}

View file

@ -287,8 +287,8 @@
list-style: none;
padding: 0 1px;
a,
button,
a:not(.help-link),
button:not(.btn),
.menu-item {
@include dropdown-link;
}

View file

@ -169,7 +169,7 @@ class Projects::BranchesController < Projects::ApplicationController
end
def confidential_issue_project
return unless Feature.enabled?(:create_confidential_merge_request, @project)
return unless helpers.create_confidential_merge_request_enabled?
return if params[:confidential_issue_project_id].blank?
confidential_issue_project = Project.find(params[:confidential_issue_project_id])

View file

@ -172,7 +172,7 @@ class Projects::IssuesController < Projects::ApplicationController
def create_merge_request
create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
create_params[:target_project_id] = params[:target_project_id] if Feature.enabled?(:create_confidential_merge_request, @project)
create_params[:target_project_id] = params[:target_project_id] if helpers.create_confidential_merge_request_enabled?
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success

View file

@ -137,7 +137,7 @@ module IssuesHelper
end
def create_confidential_merge_request_enabled?
Feature.enabled?(:create_confidential_merge_request, @project)
Feature.enabled?(:create_confidential_merge_request, @project, default_enabled: true)
end
def show_new_branch_button?

View file

@ -3,13 +3,14 @@
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
- value = can_create_merge_request ? 'Create merge request' : 'Create branch'
- value = can_create_confidential_merge_request? ? _('Create confidential merge request') : value
- create_mr_text = can_create_confidential_merge_request? ? _('Create confidential merge request') : _('Create merge request')
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
.create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } }
.btn-group.btn-group-sm.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
@ -26,7 +27,7 @@
.droplab-dropdown
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: _('Create merge request') } }
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', text: create_mr_text } }
.menu-item
= icon('check', class: 'icon')
- if can_create_confidential_merge_request?
@ -41,6 +42,8 @@
%li.divider.droplab-item-ignore
%li.droplab-item-ignore.prepend-left-8.append-right-8.prepend-top-16
- if can_create_confidential_merge_request?
#js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests') } }
.form-group
%label{ for: 'new-branch-name' }
= _('Branch name')
@ -55,4 +58,8 @@
.form-group
%button.btn.btn-success.js-create-target{ type: 'button', data: { action: 'create-mr' } }
= _('Create merge request')
= create_mr_text
- if can_create_confidential_merge_request?
%p.text-warning.js-exposed-info-warning.hidden
= _('This may expose confidential information as the selected fork is in another namespace that can have other members.')

View file

@ -4204,6 +4204,9 @@ msgstr ""
msgid "Error fetching diverging counts for branches. Please try again."
msgstr ""
msgid "Error fetching forked projects. Please try again."
msgstr ""
msgid "Error fetching labels."
msgstr ""
@ -6869,6 +6872,9 @@ msgstr ""
msgid "No files found."
msgstr ""
msgid "No forks available to you."
msgstr ""
msgid "No job trace"
msgstr ""
@ -9242,6 +9248,9 @@ msgstr ""
msgid "Select members to invite"
msgstr ""
msgid "Select private project"
msgstr ""
msgid "Select project"
msgstr ""
@ -10815,6 +10824,9 @@ msgstr ""
msgid "This job will automatically run after its timer finishes. Often they are used for incremental roll-out deploys to production environments. When unscheduled it converts into a manual action."
msgstr ""
msgid "This may expose confidential information as the selected fork is in another namespace that can have other members."
msgstr ""
msgid "This means you can not push code until you create an empty repository or import existing one."
msgstr ""
@ -11155,6 +11167,12 @@ msgstr ""
msgid "To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed."
msgstr ""
msgid "To protect this issues confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private."
msgstr ""
msgid "To protect this issues confidentiality, a private fork of this project was selected."
msgstr ""
msgid "To see all the user's personal access tokens you must impersonate them first."
msgstr ""

View file

@ -0,0 +1,54 @@
require 'rails_helper'
describe 'User creates confidential merge request on issue page', :js do
include ProjectForksHelper
let(:user) { create(:user) }
let(:project) { create(:project, :repository, :public) }
let(:issue) { create(:issue, project: project, confidential: true) }
def visit_confidential_issue
sign_in(user)
visit project_issue_path(project, issue)
wait_for_requests
end
before do
project.add_developer(user)
end
context 'user has no private fork' do
before do
fork_project(project, user, repository: true)
visit_confidential_issue
end
it 'shows that user has no fork available' do
click_button 'Create confidential merge request'
page.within '.create-confidential-merge-request-dropdown-menu' do
expect(page).to have_content('No forks available to you')
end
end
end
describe 'user has private fork' do
let(:forked_project) { fork_project(project, user, repository: true) }
before do
forked_project.update(visibility: Gitlab::VisibilityLevel::PRIVATE)
visit_confidential_issue
end
it 'create merge request in fork' do
click_button 'Create confidential merge request'
page.within '.create-confidential-merge-request-dropdown-menu' do
expect(page).to have_button(forked_project.name_with_namespace)
click_button 'Create confidential merge request'
end
expect(page).to have_content(forked_project.namespace.name)
end
end
end

View file

@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Confidential merge request project form group component renders empty state when response is empty 1`] = `
<div
class="form-group"
>
<label>
Project
</label>
<div>
<!---->
<p
class="text-muted mt-1 mb-0"
>
No forks available to you.
<br />
<span>
To protect this issues confidentiality,
<a
class="help-link"
href="https://test.com"
>
fork the project
</a>
and set the forks visiblity to private.
</span>
<gllink-stub
class="help-link"
href="/help"
target="_blank"
>
<span
class="sr-only"
>
Read more
</span>
<i
aria-hidden="true"
class="fa fa-question-circle"
/>
</gllink-stub>
</p>
</div>
</div>
`;
exports[`Confidential merge request project form group component renders fork dropdown 1`] = `
<div
class="form-group"
>
<label>
Project
</label>
<div>
<!---->
<p
class="text-muted mt-1 mb-0"
>
No forks available to you.
<br />
<span>
To protect this issues confidentiality,
<a
class="help-link"
href="https://test.com"
>
fork the project
</a>
and set the forks visiblity to private.
</span>
<gllink-stub
class="help-link"
href="/help"
target="_blank"
>
<span
class="sr-only"
>
Read more
</span>
<i
aria-hidden="true"
class="fa fa-question-circle"
/>
</gllink-stub>
</p>
</div>
</div>
`;

View file

@ -0,0 +1,56 @@
import { mount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui';
import Dropdown from '~/confidential_merge_request/components/dropdown.vue';
let vm;
function factory(projects = []) {
vm = mount(Dropdown, {
propsData: {
projects,
selectedProject: projects[0],
},
});
}
describe('Confidential merge request project dropdown component', () => {
afterEach(() => {
vm.destroy();
});
it('renders dropdown items', () => {
factory([
{
id: 1,
name: 'test',
},
{
id: 2,
name: 'test',
},
]);
expect(vm.findAll(GlDropdownItem).length).toBe(2);
});
it('renders selected project icon', () => {
factory([
{
id: 1,
name: 'test',
},
{
id: 2,
name: 'test 2',
},
]);
expect(vm.find('.js-active-project-check').classes()).not.toContain('icon');
expect(
vm
.findAll('.js-active-project-check')
.at(1)
.classes(),
).toContain('icon');
});
});

View file

@ -0,0 +1,77 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import ProjectFormGroup from '~/confidential_merge_request/components/project_form_group.vue';
const localVue = createLocalVue();
const mockData = [
{
id: 1,
name_with_namespace: 'root / gitlab-ce',
path_with_namespace: 'root/gitlab-ce',
namespace: {
full_path: 'root',
},
},
{
id: 2,
name_with_namespace: 'test / gitlab-ce',
path_with_namespace: 'test/gitlab-ce',
namespace: {
full_path: 'test',
},
},
];
let vm;
let mock;
function factory(projects = mockData) {
mock = new MockAdapter(axios);
mock.onGet(/api\/(.*)\/projects\/gitlab-org%2Fgitlab-ce\/forks/).reply(200, projects);
vm = shallowMount(ProjectFormGroup, {
localVue,
propsData: {
namespacePath: 'gitlab-org',
projectPath: 'gitlab-org/gitlab-ce',
newForkPath: 'https://test.com',
helpPagePath: '/help',
},
});
}
describe('Confidential merge request project form group component', () => {
afterEach(() => {
mock.restore();
vm.destroy();
});
it('renders fork dropdown', () => {
factory();
return localVue.nextTick(() => {
expect(vm.element).toMatchSnapshot();
});
});
it('sets selected project as first fork', () => {
factory();
return localVue.nextTick(() => {
expect(vm.vm.selectedProject).toEqual({
id: 1,
name: 'root / gitlab-ce',
pathWithNamespace: 'root/gitlab-ce',
namespaceFullpath: 'root',
});
});
});
it('renders empty state when response is empty', () => {
factory([]);
return localVue.nextTick(() => {
expect(vm.element).toMatchSnapshot();
});
});
});

View file

@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import CreateMergeRequestDropdown from '~/create_merge_request_dropdown';
import { TEST_HOST } from 'spec/test_constants';
import { TEST_HOST } from './helpers/test_constants';
describe('CreateMergeRequestDropdown', () => {
let axiosMock;
@ -10,7 +10,7 @@ describe('CreateMergeRequestDropdown', () => {
beforeEach(() => {
axiosMock = new MockAdapter(axios);
setFixtures(`
document.body.innerHTML = `
<div id="dummy-wrapper-element">
<div class="available"></div>
<div class="unavailable">
@ -18,11 +18,12 @@ describe('CreateMergeRequestDropdown', () => {
<div class="text"></div>
</div>
<div class="js-ref"></div>
<div class="js-create-mr"></div>
<div class="js-create-merge-request"></div>
<div class="js-create-target"></div>
<div class="js-dropdown-toggle"></div>
</div>
`);
`;
const dummyElement = document.getElementById('dummy-wrapper-element');
dropdown = new CreateMergeRequestDropdown(dummyElement);
@ -36,7 +37,7 @@ describe('CreateMergeRequestDropdown', () => {
describe('getRef', () => {
it('escapes branch names correctly', done => {
const endpoint = `${dropdown.refsPath}contains%23hash`;
spyOn(axios, 'get').and.callThrough();
jest.spyOn(axios, 'get');
axiosMock.onGet(endpoint).replyOnce({});
dropdown