Skip to content
Snippets Groups Projects
Unverified Commit 77c35d5d authored by Phil Hughes's avatar Phil Hughes
Browse files

Create private merge requests in forks

parent 9a4b5f08
No related branches found
No related tags found
No related merge requests found
Showing
with 599 additions and 15 deletions
<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: {},
});
Loading
Loading
@@ -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);
Loading
Loading
@@ -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;
Loading
Loading
@@ -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)
Loading
Loading
@@ -58,6 +77,14 @@ export default class CreateMergeRequestDropdown {
};
 
this.init();
if (isConfidentialIssue()) {
this.createMergeRequestButton.setAttribute(
'data-dropdown-trigger',
'#create-merge-request-dropdown',
);
initConfidentialMergeRequest();
}
}
 
available() {
Loading
Loading
@@ -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;
Loading
Loading
@@ -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;
Loading
Loading
@@ -149,6 +182,8 @@ export default class CreateMergeRequestDropdown {
}
 
enable() {
if (!canCreateConfidentialMergeRequest()) return;
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
 
Loading
Loading
@@ -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]];
Loading
Loading
@@ -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;
}
Loading
Loading
Loading
Loading
@@ -287,8 +287,8 @@
list-style: none;
padding: 0 1px;
 
a,
button,
a:not(.help-link),
button:not(.btn),
.menu-item {
@include dropdown-link;
}
Loading
Loading
Loading
Loading
@@ -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])
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
Loading
Loading
@@ -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?
Loading
Loading
Loading
Loading
@@ -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')
Loading
Loading
@@ -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?
Loading
Loading
@@ -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')
Loading
Loading
@@ -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.')
Loading
Loading
@@ -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 ""
 
Loading
Loading
@@ -6825,6 +6828,9 @@ msgstr ""
msgid "No files found."
msgstr ""
 
msgid "No forks available to you."
msgstr ""
msgid "No job trace"
msgstr ""
 
Loading
Loading
@@ -9192,6 +9198,9 @@ msgstr ""
msgid "Select members to invite"
msgstr ""
 
msgid "Select private project"
msgstr ""
msgid "Select project"
msgstr ""
 
Loading
Loading
@@ -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 ""
 
Loading
Loading
@@ -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 ""
 
Loading
Loading
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;
Loading
Loading
@@ -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">
Loading
Loading
@@ -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);
Loading
Loading
@@ -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
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment