Commit a835727a authored by Jan Provaznik's avatar Jan Provaznik Committed by Michael Kozono

Add method to query related epics of an epic

- Add permissions check
- Add specs
parent 5a330773
# frozen_string_literal: true
# This controller is used for relating an epic with other epic (similar to
# issue links). Note that this relation is different from existing
# EpicLinksController (which is used for parent-child epic hierarchy).
class Groups::Epics::RelatedEpicLinksController < Groups::ApplicationController
include EpicRelations
before_action :ensure_related_epics_enabled!
before_action :check_epics_available!
before_action :check_related_epics_available!
feature_category :portfolio_management
urgency :default
private
def list_service
Epics::RelatedEpicLinks::ListService.new(epic, current_user)
end
def ensure_related_epics_enabled!
render_404 unless Feature.enabled?(:related_epics_widget, epic&.group, default_enabled: :yaml)
end
end
......@@ -197,6 +197,15 @@ module EE
end
end
def epic_link_type
return unless respond_to?(:related_epic_link_type_value) && respond_to?(:related_epic_link_source_id)
type = ::Epic::RelatedEpicLink.link_types.key(related_epic_link_type_value) || ::Epic::RelatedEpicLink::TYPE_RELATES_TO
return type if related_epic_link_source_id == id
::Epic::RelatedEpicLink.inverse_link_type(type)
end
private
def set_fixed_start_date
......
# frozen_string_literal: true
module Epics
class RelatedEpicEntity < Grape::Entity
include RequestAwareEntity
expose :id, :confidential, :title, :state, :created_at, :closed_at
expose :reference do |related_epic|
related_epic.to_reference(request.issuable.group)
end
expose :path do |related_epic|
group_epic_path(related_epic.group, related_epic)
end
expose :link_type do |related_epic|
related_epic.epic_link_type
end
end
end
# frozen_string_literal: true
module Epics
class RelatedEpicSerializer < BaseSerializer
entity RelatedEpicEntity
end
end
# frozen_string_literal: true
module Epics::RelatedEpicLinks
class ListService < IssuableLinks::ListService
extend ::Gitlab::Utils::Override
private
def child_issuables
issuable.related_epics(current_user, preload: preload_for_collection)
end
override :serializer
def serializer
Epics::RelatedEpicSerializer
end
override :preload_for_collection
def preload_for_collection
[group: [:saml_provider, :route]]
end
end
end
---
name: related_epics_widget
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81816
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353896
milestone: '14.9'
type: development
group: group::product planning
default_enabled: false
......@@ -123,6 +123,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
scope module: :epics do
resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ }
resources :related_epic_links, only: [:index]
end
collection do
......
{
"type": "object",
"allOf": [
{
"required" : [
"id",
"confidential",
"title",
"state",
"created_at",
"closed_at",
"reference",
"path",
"link_type"
],
"properties" : {
"id": { "type": "integer" },
"confidential": { "type": "boolean" },
"title": { "type": "string" },
"state": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"closed_at": { "type": ["string", "null"], "format": "date-time" },
"reference": { "type": "string" },
"path": { "type": "string" },
"link_type": { "type": "string" }
},
"additionalProperties": false
}
]
}
......@@ -1028,4 +1028,31 @@ RSpec.describe Epic do
end
end
end
describe '#epic_link_type' do
let_it_be(:source_epic) { create(:epic, group: group) }
let_it_be(:target_epic) { create(:epic, group: group) }
let_it_be(:epic_link) { create(:related_epic_link, link_type: ::IssuableLink::TYPE_BLOCKS, source: source_epic, target: target_epic) }
before do
stub_licensed_features(epics: true, related_epics: true)
group.add_developer(user)
end
it 'returns nil if link_type attributes are not available' do
expect(source_epic.epic_link_type).to be_nil
end
it 'returns link type value for sources' do
related_epics = source_epic.related_epics(user)
expect(related_epics.first.epic_link_type).to eq ::IssuableLink::TYPE_BLOCKS
end
it 'returns inverse link type value for targets' do
related_epics = target_epic.related_epics(user)
expect(related_epics.first.epic_link_type).to eq ::IssuableLink::TYPE_IS_BLOCKED_BY
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::Epics::RelatedEpicLinksController do
let_it_be(:user) { create(:user) }
let_it_be(:epic) { create(:epic) }
let_it_be(:epic_link1) { create(:related_epic_link, source: epic) }
let_it_be(:epic_link2) { create(:related_epic_link, source: epic) }
before do
stub_licensed_features(epics: true, related_epics: true)
end
describe 'GET /*group_id/:group_id/epics/:epic_id/related_epic_links' do
subject(:request) do
get group_epic_related_epic_links_path(group_id: epic.group, epic_id: epic.iid, format: :json)
end
before do
epic.group.add_guest(user)
login_as user
end
it 'returns JSON response' do
list_service_response = Epics::RelatedEpicLinks::ListService.new(epic, user).execute
request
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(list_service_response.as_json)
end
it 'avoids N+1 queries' do
def do_request
get group_epic_related_epic_links_path(group_id: epic.group, epic_id: epic.iid, format: :json)
end
do_request # warm up
control = ActiveRecord::QueryRecorder.new { do_request }
create(:related_epic_link, source: epic)
expect { do_request }.not_to exceed_query_limit(control)
end
context 'when related_epics flag is disabled' do
before do
stub_feature_flags(related_epics_widget: false)
end
it 'returns not_found error' do
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when related_epics are not available' do
before do
stub_licensed_features(epics: true, related_epics: false)
end
it 'returns not_found error' do
request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Epics::RelatedEpicEntity do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:source) { create(:epic, group: group) }
let_it_be(:target) { create(:epic, group: group) }
let_it_be(:epic_link) { create(:related_epic_link, source: source, target: target) }
let(:request) { EntityRequest.new(issuable: epic_link.source, current_user: user) }
let(:related_epic) { epic_link.source.related_epics(user).first }
let(:entity) { described_class.new(related_epic, { request: request, current_user: user }) }
before do
stub_licensed_features(epics: true, related_epics: true)
group.add_developer(user)
end
describe '#as_json' do
it 'matches json schema' do
expect(entity.to_json).to match_schema('entities/related_epic', dir: 'ee')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Epics::RelatedEpicLinks::ListService do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:group2) { create(:group, :public) }
let_it_be(:group3) { create(:group, :private) }
let_it_be(:source_epic) { create(:epic, group: group) }
let_it_be(:epic1) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group2) }
let_it_be(:epic3) { create(:epic, group: group2, confidential: true) } # not visible because user is guest
let_it_be(:epic4) { create(:epic, group: group3) } # user has no access to epic's group
let_it_be(:epic_link1) { create(:related_epic_link, source: source_epic, target: epic1) }
let_it_be(:epic_link2) { create(:related_epic_link, source: source_epic, target: epic2) }
let_it_be(:epic_link3) { create(:related_epic_link, source: source_epic, target: epic3) }
let_it_be(:epic_link4) { create(:related_epic_link, source: source_epic, target: epic4) }
before do
stub_licensed_features(epics: true, related_epics: true)
group2.add_guest(user)
end
describe '#execute' do
subject { described_class.new(source_epic, user).execute }
it 'returns JSON list of related epics visible to user' do
expect(subject.size).to eq(2)
expect(subject).to include(include(id: epic1.id,
title: epic1.title,
state: epic1.state,
confidential: epic1.confidential,
link_type: 'relates_to',
reference: epic1.to_reference(source_epic.group),
path: "/groups/#{epic1.group.full_path}/-/epics/#{epic1.iid}"))
expect(subject).to include(include(id: epic2.id,
title: epic2.title,
state: epic2.state,
confidential: epic2.confidential,
link_type: 'relates_to',
reference: epic2.to_reference(source_epic.group),
path: "/groups/#{epic2.group.full_path}/-/epics/#{epic2.iid}"))
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