Commit e70fc65a authored by Jarka Košanová's avatar Jarka Košanová

Support epics hierarchy

Add controller + services for:
 - setting a parent
 - list epics
 - delete associarion
parent fe43c8aa
......@@ -1040,6 +1040,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
t.integer "state", limit: 2, default: 1, null: false
t.integer "closed_by_id"
t.datetime "closed_at"
t.integer "parent_id"
t.index ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree
t.index ["author_id"], name: "index_epics_on_author_id", using: :btree
t.index ["closed_by_id"], name: "index_epics_on_closed_by_id", using: :btree
......@@ -1047,6 +1048,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
t.index ["group_id"], name: "index_epics_on_group_id", using: :btree
t.index ["iid"], name: "index_epics_on_iid", using: :btree
t.index ["milestone_id"], name: "index_milestone", using: :btree
t.index ["parent_id"], name: "index_epics_on_parent_id", using: :btree
t.index ["start_date"], name: "index_epics_on_start_date", using: :btree
end
......@@ -3227,6 +3229,7 @@ ActiveRecord::Schema.define(version: 20181218192239) do
add_foreign_key "epic_issues", "epics", on_delete: :cascade
add_foreign_key "epic_issues", "issues", on_delete: :cascade
add_foreign_key "epic_metrics", "epics", on_delete: :cascade
add_foreign_key "epics", "epics", column: "parent_id", name: "fk_25b99c1be3", on_delete: :cascade
add_foreign_key "epics", "milestones", on_delete: :nullify
add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
......
# frozen_string_literal: true
class Groups::EpicLinksController < Groups::EpicsController
include EpicRelations
before_action :check_feature_flag!
before_action do
push_frontend_feature_flag(:epic_links)
end
def destroy
result = ::Epics::UpdateService.new(group, current_user, { parent: nil }).execute(child_epic)
render json: { issuables: issuables }, status: result[:http_status]
end
private
def create_service
EpicLinks::CreateService.new(epic, current_user, create_params)
end
def list_service
EpicLinks::ListService.new(epic, current_user)
end
def child_epic
@child_epic ||= Epic.find(params[:id])
end
def check_feature_flag!
render_404 unless Feature.enabled?(:epic_links, group)
end
end
......@@ -8,6 +8,10 @@ class Groups::EpicsController < Groups::ApplicationController
include RendersNotes
include EpicsActions
before_action do
push_frontend_feature_flag(:epic_links)
end
before_action :check_epics_available!
before_action :epic, except: [:index, :create]
before_action :set_issuables_index, only: :index
......
......@@ -30,6 +30,7 @@ class EpicsFinder < IssuableFinder
items = by_timeframe(items)
items = by_state(items)
items = by_label(items)
items = by_parent(items)
sort(items)
end
......@@ -89,4 +90,16 @@ class EpicsFinder < IssuableFinder
items
end
# rubocop: enable CodeReuse/ActiveRecord
def parent_id?
params[:parent_id].present?
end
# rubocop: disable CodeReuse/ActiveRecord
def by_parent(items)
return items unless parent_id?
items.where(parent_id: params[:parent_id])
end
# rubocop: enable CodeReuse/ActiveRecord
end
......@@ -20,6 +20,7 @@ module EE
if parent.is_a?(Group)
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
data[:epicLinksEndpoint] = group_epic_links_path(parent, issuable)
end
data
......
......@@ -9,6 +9,7 @@ module EpicsHelper
epic_id: epic.id,
created: epic.created_at,
author: epic_author(epic, opts),
parent: epic_parent(epic.parent),
todo_exists: todo.present?,
todo_path: group_todos_path(group),
start_date: epic.start_date,
......@@ -67,6 +68,16 @@ module EpicsHelper
}
end
def epic_parent(epic)
return unless epic
{
id: epic.id,
title: epic.title,
url: epic_path(epic)
}
end
def epic_endpoint_query_params(opts)
opts[:data] ||= {}
opts[:data][:endpoint_query_params] = {
......
......@@ -33,6 +33,8 @@ module EE
belongs_to :group
belongs_to :start_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :due_date_sourcing_milestone, class_name: 'Milestone'
belongs_to :parent, class_name: "Epic"
has_many :children, class_name: "Epic", foreign_key: :parent_id
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.epics&.maximum(:iid) }
......@@ -215,6 +217,21 @@ module EE
from && from != group
end
def ancestors
return self.class.none unless parent_id
hierarchy.ancestors
end
def descendants
hierarchy.descendants
end
def hierarchy
::Gitlab::GroupHierarchy
.new(self.class.where(id: id))
end
# we don't support project epics for epics yet, planned in the future #4019
def update_project_counter_caches
end
......
module EpicLinks
class CreateService < IssuableLinks::CreateService
def execute
return error('Epic hierarchy level too deep', 409) if parent_ancestors_count >= 4
super
end
private
def relate_issuables(referenced_epic)
affected_epics = [issuable]
affected_epics << referenced_epic if referenced_epic.parent
referenced_epic.update(parent: issuable)
affected_epics.each(&:update_start_and_due_dates)
end
def linkable_issuables(epics)
@linkable_issuables ||= begin
return [] unless can?(current_user, :admin_epic, issuable.group)
epics.select do |epic|
issuable_group_descendants.include?(epic.group) &&
!previous_related_issuables.include?(epic) &&
!level_depth_exceeded?(epic)
end
end
end
def references(extractor)
extractor.epics
end
def extractor_context
{ group: issuable.group }
end
def previous_related_issuables
issuable.children.to_a
end
def issuable_group_descendants
@descendants ||= issuable.group.self_and_descendants
end
def level_depth_exceeded?(epic)
depth_level(epic) + parent_ancestors_count >= 5
end
def depth_level(epic)
epic.descendants.count + 1 # level including epic -> therefore +1
end
def parent_ancestors_count
@parent_ancestors_count ||= issuable.ancestors.count
end
def issuables_assigned_message
'Epic(s) already assigned'
end
def issuables_not_found_message
'No Epic found for given params'
end
end
end
module EpicLinks
class ListService < IssuableLinks::ListService
private
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
EpicsFinder.new(current_user, parent_id: issuable.id, group_id: issuable.group.id).execute
end
def reference(epic)
epic.to_reference(issuable.group)
end
def issuable_path(epic)
group_epic_path(epic.group, epic)
end
def relation_path(epic)
group_epic_link_path(epic.group, issuable.iid, epic.id)
end
def to_hash(object)
{
id: object.id,
title: object.title,
state: object.state,
reference: reference(object),
path: issuable_path(object),
relation_path: relation_path(object)
}
end
end
end
......@@ -57,6 +57,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end
resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues'
resources :epic_links, only: [:index, :create, :destroy, :update], as: 'links', path: 'links'
scope module: :epics do
resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ }
......
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddParentToEpic < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_column :epics, :parent_id, :integer unless parent_id_exists?
add_concurrent_foreign_key :epics, :epics, column: :parent_id, on_delete: :cascade
add_concurrent_index :epics, :parent_id
end
def down
remove_foreign_key_without_error(:epics, column: :parent_id)
remove_concurrent_index(:epics, :parent_id)
remove_column(:epics, :parent_id) if parent_id_exists?
end
private
def parent_id_exists?
column_exists?(:epics, :parent_id)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Groups::EpicLinksController do
let(:group) { create(:group, :public) }
let(:parent_epic) { create(:epic, group: group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group) }
let(:user) { create(:user) }
before do
sign_in(user)
end
shared_examples 'unlicensed epics action' do
before do
stub_licensed_features(epics: false)
group.add_developer(user)
subject
end
it 'returns 400 status' do
expect(response).to have_gitlab_http_status(404)
end
end
shared_examples 'feature flag disabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_links: false)
group.add_developer(user)
subject
end
it 'returns 400 status' do
expect(response).to have_gitlab_http_status(404)
end
end
describe 'GET #index' do
before do
epic1.update(parent: parent_epic)
end
subject { get :index, group_id: group, epic_id: parent_epic.to_param }
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
group.add_developer(user)
subject
end
it 'returns the correct JSON response' do
list_service_response = EpicLinks::ListService.new(parent_epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq(list_service_response.as_json)
end
end
end
describe 'POST #create' do
subject do
reference = [epic1.to_reference(full: true)]
post :create, group_id: group, epic_id: parent_epic.to_param, issuable_references: reference
end
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
end
context 'when user has permissions to create requested association' do
before do
group.add_developer(user)
end
it 'returns correct response for the correct issue reference' do
subject
list_service_response = EpicLinks::ListService.new(parent_epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issuables' => list_service_response.as_json)
end
it 'updates a parent for the referenced epic' do
expect { subject }.to change { epic1.reload.parent }.from(nil).to(parent_epic)
end
end
context 'when user does not have permissions to create requested association' do
it 'returns 403 status' do
subject
expect(response).to have_gitlab_http_status(403)
end
it 'does not update parent attribute' do
expect { subject }.not_to change { epic1.reload.parent }.from(nil)
end
end
end
end
describe 'DELETE #destroy' do
before do
epic1.update(parent: parent_epic)
end
subject { delete :destroy, group_id: group, epic_id: parent_epic.to_param, id: epic1.id }
it_behaves_like 'unlicensed epics action'
it_behaves_like 'feature flag disabled'
context 'when epic_links feature is enabled' do
before do
stub_licensed_features(epics: true)
stub_feature_flags(epic_linkcs: true)
end
context 'when user has permissions to update the parent epic' do
before do
group.add_developer(user)
end
it 'returns status 200' do
subject
expect(response.status).to eq(200)
end
it 'destroys the link' do
expect { subject }.to change { epic1.reload.parent }.from(parent_epic).to(nil)
end
end
context 'when user does not have permissions to update the parent epic' do
it 'returns status 404' do
subject
expect(response.status).to eq(403)
end
it 'does not destroy the link' do
expect { subject }.not_to change { epic1.reload.parent }.from(parent_epic)
end
end
context 'when the epic does not have any parent' do
it 'returns status 404' do
delete :destroy, group_id: group, epic_id: parent_epic.to_param, id: epic2.id
expect(response.status).to eq(403)
end
end
end
end
end
......@@ -169,6 +169,21 @@ describe EpicsFinder do
expect(epics(params)).to contain_exactly(epic3)
end
end
context 'by parent' do
before do
epic2.update(parent: epic1)
epic3.update(parent: epic2)
end
it 'returns direct children of the parent' do
params = {
parent_id: epic1.id
}
expect(epics(params)).to contain_exactly(epic2)
end
end
end
end
end
......
......@@ -5,16 +5,20 @@ describe EpicsHelper do
describe '#epic_show_app_data' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:milestone1) { create(:milestone, title: 'make me a sandwich', start_date: '2010-01-01', due_date: '2019-12-31') }
let(:milestone2) { create(:milestone, title: 'make me a pizza', start_date: '2020-01-01', due_date: '2029-12-31') }
let(:parent_epic) { create(:epic, group: group) }
let!(:epic) do
create(
:epic,
group: group,
author: user,
start_date_sourcing_milestone: milestone1,
start_date: Date.new(2000, 1, 1),
due_date_sourcing_milestone: milestone2,
due_date: Date.new(2000, 1, 2)
due_date: Date.new(2000, 1, 2),
parent: parent_epic
)
end
......@@ -30,7 +34,7 @@ describe EpicsHelper do
expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date
epic_id created author todo_exists todo_path start_date parent
start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
......@@ -43,6 +47,11 @@ describe EpicsHelper do
'username' => "@#{user.username}",
'src' => 'icon_path'
})
expect(meta_data['parent']).to eq({
'id' => parent_epic.id,
'title' => parent_epic.title,
'url' => "/groups/#{group.full_path}/-/epics/#{parent_epic.id}"
})
expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title)
expect(meta_data['start_date_sourcing_milestone_dates']['start_date']).to eq(milestone1.start_date.to_s)
......@@ -76,7 +85,7 @@ describe EpicsHelper do
meta_data = JSON.parse(data[:meta])
expect(meta_data.keys).to match_array(%w[
epic_id created author todo_exists todo_path start_date
epic_id created author todo_exists todo_path start_date parent
start_date_is_fixed start_date_fixed start_date_from_milestones
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
......
......@@ -7,7 +7,9 @@ describe Epic do
it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:assignee).class_name('User') }
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:parent) }
it { is_expected.to have_many(:epic_issues) }
it { is_expected.to have_many(:children) }
end
describe 'validations' do
......@@ -77,6 +79,36 @@ describe Epic do
end
end
describe '#ancestors' do
let(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do
expect(epic3.ancestors).to match_array([epic1, epic2])
end
it 'returns an empty array if an epic does not have any parent' do
expect(epic1.ancestors).to be_empty
end
end
describe '#descendants' do
let(:group) { create(:group) }
let(:epic1) { create(:epic, group: group) }
let(:epic2) { create(:epic, group: group, parent: epic1) }
let(:epic3) { create(:epic, group: group, parent: epic2) }
it 'returns all ancestors for an epic' do
expect(epic1.descendants).to match_array([epic2, epic3])
end
it 'returns an empty array if an epic does not have any descendants' do
expect(epic3.descendants).to be_empty
end
end
describe '#upcoming?' do
it 'returns true when start_date is in the future' do
epic = build(:epic, start_date: 1.month.from_now)
......
# frozen_string_literal: true
require 'spec_helper'
describe EpicLinks::CreateService do
describe '#execute' do
let(:group) { create(:group) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
let(:epic_to_add) { create(:epic, group: group) }
let(:valid_reference) { epic_to_add.to_reference(full: true) }
shared_examples 'returns success' do
it 'creates a new relationship and updates epic' do
expect { subject }.to change { epic.children.count }.by(1)
expect(epic.reload.children).to include(epic_to_add)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
shared_examples 'returns not found error' do
it 'returns an error' do
expect(subject).to eq(message: 'No Epic found for given params', status: :error, http_status: 404)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
def add_epic(references)
params = { issuable_references: references }
described_class.new(epic, user, params).execute
end
context 'when epics feature is disabled' do
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when user has permissions to link the issue' do
before do
group.add_developer(user)
end
context 'when the reference list is empty' do
subject { add_epic([]) }
include_examples 'returns not found error'
end
context 'when a correct reference is given' do
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
context 'when an epic from a subgroup is given', :nested_groups do
let(:subgroup) { create(:group, parent: group) }
before do
epic_to_add.update!(group: subgroup)
end
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
context 'when an epic from a another group is given' do
let(:other_group) { create(:group) }
before do
epic_to_add.update!(group: other_group)
end
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
context 'when multiple valid epics are given' do
let(:another_epic) { create(:epic, group: group) }
subject do
add_epic(
[epic_to_add.to_reference(full: true), another_epic.to_reference(full: true)]
)
end
it 'creates new relationships' do
expect { subject }.to change { epic.children.count }.by(2)
expect(epic.reload.children).to match_array([epic_to_add, another_epic])
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
context 'when at least one epic is still not assigned to the parent epic' do
let(:another_epic) { create(:epic, group: group) }
before do
epic_to_add.update(parent: epic)
end
subject do
add_epic(
[epic_to_add.to_reference(full: true), another_epic.to_reference(full: true)]
)
end
it 'creates new relationships' do
expect { subject }.to change { epic.children.count }.from(1).to(2)
expect(epic.reload.children).to match_array([epic_to_add, another_epic])
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
context 'when adding an epic that is already a child of the parent epic' do
before do
epic_to_add.update(parent: epic)
end
subject { add_epic([valid_reference]) }
it 'returns an error' do
expect(subject).to eq(message: 'Epic(s) already assigned', status: :error, http_status: 409)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
context 'when adding an wolud would exceed level 5 in hierarchy' do
context 'when adding to already deep structure' do
before do
epic1 = create(:epic, group: group)
epic2 = create(:epic, group: group, parent: epic1)
epic3 = create(:epic, group: group, parent: epic2)
epic4 = create(:epic, group: group, parent: epic3)
epic.update(parent: epic4)
end
subject { add_epic([valid_reference]) }
it 'returns an error' do
expect(subject).to eq(message: 'Epic hierarchy level too deep', status: :error, http_status: 409)
end
it 'no relationship is created' do
expect { subject }.not_to change { epic.children.count }
end
end
context 'when adding an epic already having some epics as children' do
before do
epic1 = create(:epic, group: group)
epic.update(parent: epic1) # epic is on level 2
# epic_to_add has 3 children (level 4 inlcuding epic_to_add)
# that would mean level 6 after relating epic_to_add on epic
epic2 = create(:epic, group: group, parent: epic_to_add)
epic3 = create(:epic, group: group, parent: epic2)
create(:epic, group: group, parent: epic3)
end
subject { add_epic([valid_reference]) }
include_examples 'returns not found error'
end
end
context 'when an epic is already assigned to another epic' do
let(:another_epic) { create(:epic, group: group) }
before do
epic_to_add.update(parent: another_epic)
end
subject { add_epic([valid_reference]) }
include_examples 'returns success'
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe EpicLinks::ListService do
let(:user) { create :user }
let(:group) { create(:group, :public) }
let(:parent_epic) { create(:epic, group: group) }
let!(:epic1) { create :epic, group: group, parent: parent_epic }
let!(:epic2) { create :epic, group: group, parent: parent_epic }
def epics_to_results(epics)
epics.map do |epic|
{
id: epic.id,
title: epic.title,
state: epic.state,
reference: epic.to_reference(group),
path: "/groups/#{epic.group.full_path}/-/epics/#{epic.iid}",
relation_path: "/groups/#{epic.group.full_path}/-/epics/#{parent_epic.iid}/links/#{epic.id}"
}
end
end
describe '#execute' do
subject { described_class.new(parent_epic, user).execute }
context 'when epics feature is disabled' do
it 'returns an empty array' do
group.add_developer(user)
expect(subject).to be_empty
end
end
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'group member can see all child epics' do
before do
group.add_developer(user)
end
it 'returns related issues JSON' do
expected_result = epics_to_results([epic1, epic2])
expect(subject).to match_array(expected_result)
end
end
context 'with nested groups', :nested_groups do
let(:subgroup1) { create(:group, :private, parent: group) }
let(:subgroup2) { create(:group, :private, parent: group) }
let!(:epic_subgroup1) { create :epic, group: subgroup1, parent: parent_epic }
let!(:epic_subgroup2) { create :epic, group: subgroup2, parent: parent_epic }
it 'returns all child epics for a group member' do
group.add_developer(user)
expected_result = epics_to_results([epic1, epic2, epic_subgroup1, epic_subgroup2])
expect(subject).to match_array(expected_result)
end
it 'returns only some child epics for a subgroup member' do
subgroup2.add_developer(user)
expected_result = epics_to_results([epic1, epic2, epic_subgroup2])
expect(subject).to match_array(expected_result)
end
end
end
end
end
......@@ -204,6 +204,7 @@ describe IssuablesHelper do
expected_data = {
endpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
epicLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/links",
updateEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
issueLinksEndpoint: "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
canUpdate: true,
......
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