Commit 74346cdb authored by Rémy Coutable's avatar Rémy Coutable

Merge branch '3777-promote-to-epic' into 'master'

Promote an issue to an epic

Closes #3777

See merge request gitlab-org/gitlab-ee!8051
parents 5e68eeb8 ca786c4f
......@@ -69,9 +69,11 @@ class GfmAutoComplete {
skipMarkdownCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
const cssClasses = [];
if (GfmAutoComplete.isLoading(value)) return GfmAutoComplete.Loading.template;
// eslint-disable-next-line no-template-curly-in-string
let tpl = '<li><span class="name">/${name}</span>';
let tpl = '<li class="<%- className %>"><span class="name">/${name}</span>';
if (value.aliases.length > 0) {
tpl += ' <small class="aliases">(or /<%- aliases.join(", /") %>)</small>';
}
......@@ -79,10 +81,19 @@ class GfmAutoComplete {
tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
if (value.description !== '') {
tpl += '<small class="description"><i><%- description %></i></small>';
tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>';
}
tpl += '</li>';
return _.template(tpl)(value);
if (value.warning) {
cssClasses.push('has-warning');
}
return _.template(tpl)({
...value,
className: cssClasses.join(' '),
warningText: value.warning ? `(${value.warning})` : '',
});
},
insertTpl(value) {
// eslint-disable-next-line no-template-curly-in-string
......@@ -111,6 +122,7 @@ class GfmAutoComplete {
aliases: c.aliases,
params: c.params,
description: c.description,
warning: c.warning,
search,
};
});
......
......@@ -223,6 +223,13 @@
margin-bottom: 0;
}
.has-warning {
.name,
.description {
color: $orange-700;
}
}
.cur {
.avatar {
@include disableAllAnimation;
......@@ -250,6 +257,11 @@
small {
color: inherit;
}
&.has-warning {
color: $orange-700;
background-color: $orange-100;
}
}
div.avatar {
......
......@@ -51,6 +51,8 @@ module Issuable
end
def group
return new_entity.group if new_entity.respond_to?(:group) && new_entity.group
if new_entity.project&.group && current_user.can?(:read_group, new_entity.project.group)
new_entity.project.group
end
......
......@@ -42,10 +42,10 @@ For each of the dates in the sidebar of an epic, you can choose to either:
- Enter a fixed value.
- Inherit a dynamic value called "From milestones".
If you select "From milestones" for the start date, GitLab will automatically set the
date to be earliest start date across all milestones that are currently assigned
If you select "From milestones" for the start date, GitLab will automatically set the
date to be earliest start date across all milestones that are currently assigned
to the issues that are attached to the epic. Similarly, if you select "From milestones"
for the due date, GitLab will set it to be the latest due date across all
for the due date, GitLab will set it to be the latest due date across all
milestones that are currently assigned to those issues.
These are dynamic dates in that if milestones are re-assigned to the issues, if the
......@@ -91,6 +91,13 @@ link in the issue sidebar.
![containing epic](img/containing_epic.png)
## Promoting an issue to an epic
A user who has permissions to close an issue and create an epic in the parent group
can promote an issue to an epic.
The epic is created in the group the issue project belongs to (direct parent group).
Only issues from projects that are in groups can be promoted.
## Searching for an epic from epics list page
> Introduced in [GitLab Ultimate][ee] 10.5.
......
......@@ -84,3 +84,4 @@ The following quick actions are applicable for epics threads and description:
| `/label ~label1 ~label2` | Add label(s) |
| `/unlabel ~label1 ~label2` | Remove all or specific label(s) |
| `/relabel ~label1 ~label2` | Replace label |
| `/promote` | Promote an issue to an epic |
......@@ -85,6 +85,19 @@ module EE
::MergeRequests::ApprovalService.new(issuable.project, current_user).execute(issuable)
end
end
desc 'Promote issue to an epic'
explanation 'Promote issue to an epic'
warning 'may expose confidential information'
condition do
issuable.is_a?(Issue) &&
issuable.persisted? &&
current_user.can?(:admin_issue, project) &&
current_user.can?(:create_epic, project.group)
end
command :promote do
Epics::IssuePromoteService.new(issuable.project, current_user).execute(issuable)
end
end
def extract_epic(params)
......
......@@ -69,6 +69,19 @@ module EE
create_note(NoteSummary.new(object_epic, nil, user, body, action: action))
end
def issue_promoted(noteable, noteable_ref, author, direction:)
unless [:to, :from].include?(direction)
raise ArgumentError, "Invalid direction `#{direction}`"
end
project = noteable.project
cross_reference = noteable_ref.to_reference(project || noteable.group)
body = "promoted #{direction} #{noteable_ref.class.to_s.downcase} #{cross_reference}"
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
def issue_on_epic(issue, epic, user, type)
return unless validate_epic_issue_action_type(type)
......
......@@ -16,7 +16,7 @@ module Epics
end
def whitelisted_epic_params
params.slice(:title, :description, :start_date, :end_date)
params.slice(:title, :description, :start_date, :end_date, :milestone, :label_ids)
end
end
end
# frozen_string_literal: true
module Epics
class IssuePromoteService < Issuable::Clone::BaseService
PromoteError = Class.new(StandardError)
def execute(issue)
@group = issue.project.group
unless @group
raise PromoteError, 'Cannot promote issue because it does not belong to a group!'
end
unless current_user.can?(:admin_issue, issue) && current_user.can?(:create_epic, @group)
raise PromoteError, 'Cannot promote issue due to insufficient permissions!'
end
super
new_entity
end
private
def create_new_entity
@new_entity = Epics::CreateService.new(@group, current_user, params).execute
end
def params
{
title: original_entity.title
}
end
def add_note_from
SystemNoteService.issue_promoted(new_entity, original_entity, current_user, direction: :from)
end
def add_note_to
SystemNoteService.issue_promoted(original_entity, new_entity, current_user, direction: :to)
end
end
end
---
title: Promote an Issue to an Epic using quick action
merge_request: 8051
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe 'Issue promotion', :js do
include Spec::Support::Helpers::Features::NotesHelpers
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
before do
sign_in(user)
end
context 'when epics feature is disabled' do
it 'does not promote the issue' do
visit project_issue_path(project, issue)
expect(page).not_to have_content 'Commands applied'
expect(issue.reload).to be_open
expect(Epic.count).to be_zero
end
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when current user does not have permissions to promote an issue' do
before do
visit project_issue_path(project, issue)
end
it 'does not promote the issue' do
expect(page).not_to have_content 'Commands applied'
expect(issue.reload).to be_open
expect(Epic.count).to be_zero
end
end
context 'when current user can promote an issue' do
before do
group.add_developer(user)
visit project_issue_path(project, issue)
end
it 'promotes the issue' do
add_note('/promote')
wait_for_requests
epic = Epic.last
expect(issue.reload).to be_closed
expect(epic.title).to eq(issue.title)
expect(epic.description).to eq(issue.description)
expect(epic.author).to eq(user)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Issuable::Clone::AttributesRewriter do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:original_issue) { create(:issue, project: project) }
context 'when a new object is a group entity' do
let(:new_epic) { create(:epic, group: group) }
subject { described_class.new(user, original_issue, new_epic) }
context 'setting labels' do
let!(:project_label1) { create(:label, title: 'label1', project: project) }
let!(:project_label2) { create(:label, title: 'label2', project: project) }
let!(:group_label1) { create(:group_label, title: 'group_label', group: group) }
let!(:group_label2) { create(:group_label, title: 'label2', group: group) }
it 'keeps group labels and merges project labels where possible' do
original_issue.update(labels: [project_label1, project_label2, group_label1])
subject.execute
expect(new_epic.reload.labels).to match_array([group_label1, group_label2])
end
end
context 'setting milestones' do
it 'sets milestone to nil when old issue milestone is not a group milestone' do
milestone = create(:milestone, title: 'milestone', project: project)
original_issue.update(milestone: milestone)
subject.execute
expect(new_epic.reload.milestone).to be_nil
end
it 'copies the milestone when old issue milestone is a group milestone' do
milestone = create(:milestone, title: 'milestone', group: group)
original_issue.update(milestone: milestone)
subject.execute
expect(new_epic.reload.milestone).to eq(milestone)
end
end
end
end
......@@ -8,7 +8,7 @@ describe Epics::CreateService do
subject { described_class.new(group, user, params).execute }
describe '#execute' do
it 'creates one issue correctly' do
it 'creates one epic correctly' do
allow(NewEpicWorker).to receive(:perform_async)
expect { subject }.to change { Epic.count }.from(0).to(1)
......
# frozen_string_literal: true
require 'spec_helper'
describe Epics::IssuePromoteService do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
let(:label1) { create(:group_label, group: group) }
let(:label2) { create(:label, project: project) }
let(:milestone) { create(:milestone, group: group) }
let(:description) { 'simple description' }
let(:issue) do
create(:issue, project: project, labels: [label1, label2],
milestone: milestone, description: description)
end
subject { described_class.new(issue.project, user) }
let(:epic) { Epic.last }
describe '#execute' do
context 'when epics are not enabled' do
it 'raises a permission error' do
group.add_developer(user)
expect { subject.execute(issue) }
.to raise_error(Epics::IssuePromoteService::PromoteError, /permissions/)
end
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when a user can not promote the issue' do
it 'raises a permission error' do
expect { subject.execute(issue) }
.to raise_error(Epics::IssuePromoteService::PromoteError, /permissions/)
end
end
context 'when a user can promote the issue' do
before do
group.add_developer(user)
end
context 'when an issue does not belong to a group' do
it 'raises an error' do
other_issue = create(:issue, project: create(:project))
expect { subject.execute(other_issue) }
.to raise_error(Epics::IssuePromoteService::PromoteError, /group/)
end
end
context 'when issue is promoted' do
before do
subject.execute(issue)
end
it 'creates a new epic with correct attributes' do
expect(epic.title).to eq(issue.title)
expect(epic.description).to eq(issue.description)
expect(epic.author).to eq(user)
expect(epic.group).to eq(group)
expect(epic.milestone).to eq(milestone)
end
it 'copies group labels assigned to the issue' do
expect(epic.labels).to eq([label1])
end
it 'creates a system note on the issue' do
expect(issue.notes.last.note).to eq("promoted to epic #{epic.to_reference(project)}")
end
it 'creates a system note on the epic' do
expect(epic.notes.last.note).to eq("promoted from issue #{issue.to_reference(group)}")
end
it 'closes the original issue' do
expect(issue).to be_closed
end
end
end
end
end
end
......@@ -149,6 +149,56 @@ describe QuickActions::InterpretService do
end
end
context 'promote command' do
let(:content) { "/promote" }
context 'when epics are enabled' do
context 'when a user does not have permissions to promote an issue' do
it 'does not promote an issue to an epic' do
expect { service.execute(content, issue) }.not_to change { Epic.count }
end
end
context 'when a user has permissions to promote an issue' do
before do
group.add_developer(current_user)
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
end
it 'promotes an issue to an epic' do
expect { service.execute(content, issue) }.to change { Epic.count }.by(1)
end
context 'when an issue belongs to a project without group' do
let(:user_project) { create(:project) }
let(:issue) { create(:issue, project: user_project) }
before do
user_project.add_developer(user)
end
it 'does not promote an issue to an epic' do
expect { service.execute(content, issue) }
.to raise_error(Epics::IssuePromoteService::PromoteError)
end
end
end
end
end
context 'when epics are disabled' do
it 'does not promote an issue to an epic' do
group.add_developer(current_user)
expect { service.execute(content, issue) }.not_to change { Epic.count }
end
end
end
context 'remove_epic command' do
let(:epic) { create(:epic, group: group)}
let(:content) { "/remove_epic #{epic.to_reference(project)}" }
......
......@@ -10,7 +10,7 @@ describe SystemNoteService do
set(:author) { create(:user) }
let(:noteable) { create(:issue, project: project) }
let(:issue) { noteable }
let(:epic) { create(:epic) }
let(:epic) { create(:epic, group: group) }
shared_examples_for 'a system note' do
let(:expected_noteable) { noteable }
......@@ -96,5 +96,32 @@ describe SystemNoteService do
expect(subject.note).to eq 'removed the start date'
end
end
context '.issue_promoted' do
context 'note on the epic' do
subject { described_class.issue_promoted(epic, issue, author, direction: :from) }
it_behaves_like 'a system note' do
let(:action) { 'moved' }
let(:expected_noteable) { epic }
end
it 'sets the note text' do
expect(subject.note).to eq("promoted from issue #{issue.to_reference(group)}")
end
end
context 'note on the issue' do
subject { described_class.issue_promoted(issue, epic, author, direction: :to) }
it_behaves_like 'a system note' do
let(:action) { 'moved' }
end
it 'sets the note text' do
expect(subject.note).to eq("promoted to epic #{epic.to_reference(project)}")
end
end
end
end
end
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