Commit 77c35d5d authored by Phil Hughes's avatar Phil Hughes

Create private merge requests in forks

https://gitlab.com/gitlab-org/gitlab-ce/issues/58583
parent 9a4b5f08
<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>
<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>
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,
},
});
},
});
}
import Vue from 'vue';
export default Vue.observable({
selectedProject: {},
});
......@@ -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;
}
......
......@@ -287,8 +287,8 @@
list-style: none;
padding: 0 1px;
a,
button,
a:not(.help-link),
button:not(.btn),
.menu-item {
@include dropdown-link;
}
......
......@@ -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])
......
......@@ -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
......
......@@ -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?
......
......@@ -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.')
......@@ -4169,6 +4169,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 ""
......@@ -6825,6 +6828,9 @@ msgstr ""
msgid "No files found."
msgstr ""
msgid "No forks available to you."
msgstr ""
msgid "No job trace"
msgstr ""
......@@ -9192,6 +9198,9 @@ msgstr ""
msgid "Select members to invite"
msgstr ""
msgid "Select private project"
msgstr ""
msgid "Select project"
msgstr ""
......@@ -10753,6 +10762,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 ""
......@@ -11084,6 +11096,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 ""
......
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
// 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>
`;
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');
});
});
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();
});
});
});
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
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment