Commit b2d828d5 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '327576-get-ancestors-of-an-epic-using-graphql' into 'master'

Get ancestors of an Epic using GraphQL

See merge request gitlab-org/gitlab!61154
parents 3edccefc 63aa2fb0
......@@ -7321,6 +7321,38 @@ Represents an epic on an issue board.
#### Fields with arguments
##### `BoardEpic.ancestors`
Ancestors (parents) of the epic.
Returns [`EpicConnection`](#epicconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="boardepicancestorsauthorusername"></a>`authorUsername` | [`String`](#string) | Filter epics by author. |
| <a id="boardepicancestorsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter epics by given confidentiality. |
| <a id="boardepicancestorsenddate"></a>`endDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.end. |
| <a id="boardepicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="boardepicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="boardepicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., [1, 2]. |
| <a id="boardepicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="boardepicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="boardepicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
| <a id="boardepicancestorsmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Filter epics by milestone title, computed from epic's issues. |
| <a id="boardepicancestorsmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. |
| <a id="boardepicancestorsnot"></a>`not` | [`NegatedEpicFilterInput`](#negatedepicfilterinput) | Negated epic arguments. |
| <a id="boardepicancestorssearch"></a>`search` | [`String`](#string) | Search query for epic title or description. |
| <a id="boardepicancestorssort"></a>`sort` | [`EpicSort`](#epicsort) | List epics by sort order. |
| <a id="boardepicancestorsstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. |
| <a id="boardepicancestorsstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. |
| <a id="boardepicancestorstimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. |
##### `BoardEpic.children`
Children (sub-epics) of the epic.
......@@ -8479,6 +8511,38 @@ Represents an epic.
#### Fields with arguments
##### `Epic.ancestors`
Ancestors (parents) of the epic.
Returns [`EpicConnection`](#epicconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
###### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="epicancestorsauthorusername"></a>`authorUsername` | [`String`](#string) | Filter epics by author. |
| <a id="epicancestorsconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter epics by given confidentiality. |
| <a id="epicancestorsenddate"></a>`endDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.end. |
| <a id="epicancestorsiid"></a>`iid` | [`ID`](#id) | IID of the epic, e.g., "1". |
| <a id="epicancestorsiidstartswith"></a>`iidStartsWith` | [`String`](#string) | Filter epics by IID for autocomplete. |
| <a id="epicancestorsiids"></a>`iids` | [`[ID!]`](#id) | List of IIDs of epics, e.g., [1, 2]. |
| <a id="epicancestorsincludeancestorgroups"></a>`includeAncestorGroups` | [`Boolean`](#boolean) | Include epics from ancestor groups. |
| <a id="epicancestorsincludedescendantgroups"></a>`includeDescendantGroups` | [`Boolean`](#boolean) | Include epics from descendant groups. |
| <a id="epicancestorslabelname"></a>`labelName` | [`[String!]`](#string) | Filter epics by labels. |
| <a id="epicancestorsmilestonetitle"></a>`milestoneTitle` | [`String`](#string) | Filter epics by milestone title, computed from epic's issues. |
| <a id="epicancestorsmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. |
| <a id="epicancestorsnot"></a>`not` | [`NegatedEpicFilterInput`](#negatedepicfilterinput) | Negated epic arguments. |
| <a id="epicancestorssearch"></a>`search` | [`String`](#string) | Search query for epic title or description. |
| <a id="epicancestorssort"></a>`sort` | [`EpicSort`](#epicsort) | List epics by sort order. |
| <a id="epicancestorsstartdate"></a>`startDate` **{warning-solid}** | [`Time`](#time) | **Deprecated** in 13.5. Use timeframe.start. |
| <a id="epicancestorsstate"></a>`state` | [`EpicState`](#epicstate) | Filter epics by state. |
| <a id="epicancestorstimeframe"></a>`timeframe` | [`Timeframe`](#timeframe) | List items overlapping the given timeframe. |
##### `Epic.children`
Children (sub-epics) of the epic.
......
......@@ -10,6 +10,7 @@
# state: 'open' or 'closed' or 'all'
# group_id: integer
# parent_id: integer
# child_id: integer
# author_id: integer
# author_username: string
# label_name: string
......@@ -127,6 +128,7 @@ class EpicsFinder < IssuableFinder
items = by_state(items)
items = by_label(items)
items = by_parent(items)
items = by_child(items)
items = by_iids(items)
items = by_my_reaction_emoji(items)
items = by_confidential(items)
......@@ -194,6 +196,10 @@ class EpicsFinder < IssuableFinder
params[:parent_id].present?
end
def child_id?
params[:child_id].present?
end
# rubocop: disable CodeReuse/ActiveRecord
def by_parent(items)
return items unless parent_id?
......@@ -202,6 +208,15 @@ class EpicsFinder < IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def by_child(items)
return items unless child_id?
ancestor_ids = Epic.find(params[:child_id]).ancestors.select(:id)
items.where(id: ancestor_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
def with_confidentiality_access_check(epics, groups)
return epics if can_read_all_epics_in_related_groups?(groups)
......
# frozen_string_literal: true
module Resolvers
class EpicAncestorsResolver < EpicsResolver
type Types::EpicType, null: true
argument :include_ancestor_groups, GraphQL::BOOLEAN_TYPE,
required: false,
description: 'Include epics from ancestor groups.',
default_value: true
private
def set_relative_param(args)
args[:child_id] = parent.id if parent
args
end
end
end
......@@ -102,10 +102,15 @@ module Resolvers
def transform_args(args)
transformed = args.dup
transformed[:group_id] = group
transformed[:parent_id] = parent.id if parent
transformed[:iids] ||= [args[:iid]].compact
transformed
set_relative_param(transformed)
end
def set_relative_param(args)
args[:parent_id] = parent.id if parent
args
end
# `resolver_object` refers to the object we're currently querying on, and is usually a `Group`
......
......@@ -149,6 +149,12 @@ module Types
null: true,
description: 'A list of award emojis associated with the epic.'
field :ancestors, Types::EpicType.connection_type,
null: true,
complexity: 5,
resolver: ::Resolvers::EpicAncestorsResolver,
description: 'Ancestors (parents) of the epic.'
def has_children?
Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate.new(context, object.id, COUNT) do |node, _aggregate_object|
node.children.any?
......
......@@ -9,8 +9,8 @@ RSpec.describe EpicsFinder do
let_it_be(:another_group) { create(:group) }
let_it_be(:reference_time) { Time.parse('2020-09-15 01:00') } # Arbitrary time used for time/date range filters
let_it_be(:epic1) { create(:epic, :opened, group: group, title: 'This is awesome epic', created_at: 1.week.before(reference_time), end_date: 10.days.before(reference_time)) }
let_it_be(:epic2) { create(:epic, :opened, group: group, created_at: 4.days.before(reference_time), author: user, start_date: 2.days.before(reference_time), end_date: 3.days.since(reference_time)) }
let_it_be(:epic3) { create(:epic, :closed, group: group, description: 'not so awesome', start_date: 5.days.before(reference_time), end_date: 3.days.before(reference_time)) }
let_it_be(:epic2) { create(:epic, :opened, group: group, created_at: 4.days.before(reference_time), author: user, start_date: 2.days.before(reference_time), end_date: 3.days.since(reference_time), parent: epic1) }
let_it_be(:epic3) { create(:epic, :closed, group: group, description: 'not so awesome', start_date: 5.days.before(reference_time), end_date: 3.days.before(reference_time), parent: epic2) }
let_it_be(:epic4) { create(:epic, :closed, group: another_group) }
describe '#execute' do
......@@ -258,20 +258,21 @@ RSpec.describe EpicsFinder do
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
}
params = { parent_id: epic1.id }
expect(epics(params)).to contain_exactly(epic2)
end
end
context 'by child' do
it 'returns ancestors of the child epic' do
params = { child_id: epic3.id }
expect(epics(params)).to contain_exactly(epic1, epic2)
end
end
context 'by confidential' do
let_it_be(:confidential_epic) { create(:epic, :confidential, group: group) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::EpicAncestorsResolver do
include GraphqlHelpers
let_it_be_with_refind(:group) { create(:group, :private) }
let_it_be(:current_user) { create(:user) }
let_it_be(:epic1) { create(:epic, group: group) }
let_it_be(:epic2) { create(:epic, group: group, parent: epic1) }
let_it_be_with_reload(:base_epic) { create(:epic, group: group, parent: epic2) }
let_it_be(:confidential_epic1) { create(:epic, :confidential, group: group) }
let_it_be(:confidential_epic2) { create(:epic, :confidential, group: group, parent: confidential_epic1) }
let_it_be_with_reload(:confidential_epic) { create(:epic, :confidential, group: group, parent: confidential_epic2) }
let(:args) { { include_ancestor_groups: true } }
before do
stub_licensed_features(epics: true)
end
describe '#resolve' do
it 'returns nothing when feature disabled' do
stub_licensed_features(epics: false)
expect(resolve_ancestors(base_epic, args)).to be_empty
end
it 'does not return ancestor epics when user has no access to group epics' do
expect(resolve_ancestors(base_epic, args)).to be_empty
end
context 'when user has access to the group epics' do
before do
group.add_developer(current_user)
end
it 'returns non confidential ancestor epics' do
expect(resolve_ancestors(base_epic, args)).to contain_exactly(epic1, epic2)
end
it 'returns confidential ancestors' do
expect(resolve_ancestors(confidential_epic, args))
.to contain_exactly(confidential_epic1, confidential_epic2)
end
context 'with subgroups' do
let_it_be(:sub_group) { create(:group, :private, parent: group) }
let_it_be(:epic3) { create(:epic, group: sub_group, parent: epic2) }
let_it_be(:epic4) { create(:epic, group: sub_group, parent: epic3) }
before do
sub_group.add_developer(current_user)
end
it 'returns all ancestors' do
expect(resolve_ancestors(epic4, args)).to contain_exactly(epic1, epic2, epic3)
end
it 'does not return parent group epics when include_ancestor_groups is false' do
expect(resolve_ancestors(epic4, { include_ancestor_groups: false }))
.to contain_exactly(epic3)
end
end
end
context 'when user is a guest' do
before do
group.add_guest(current_user)
end
it 'returns non confidential ancestor epics' do
expect(resolve_ancestors(base_epic, args)).to contain_exactly(epic1, epic2)
end
it 'does not return confidential epics' do
expect(resolve_ancestors(confidential_epic, args)).to be_empty
end
end
end
def resolve_ancestors(object, args = {}, context = { current_user: current_user })
resolve(described_class, obj: object, args: args, ctx: context)
end
end
......@@ -13,7 +13,7 @@ RSpec.describe GitlabSchema.types['Epic'] do
notes discussions relative_position subscribed participants
descendant_counts descendant_weight_sum upvotes downvotes
user_notes_count user_discussions_count health_status current_user_todos
award_emoji events
award_emoji events ancestors
]
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Getting ancestors of an epic' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:parent_group) { create(:group, :private) }
let_it_be(:group) { create(:group, :private, parent: parent_group) }
let_it_be(:ancestor_a) { create(:epic, group: parent_group) }
let_it_be(:ancestor_b) { create(:epic, group: group, parent: ancestor_a) }
let_it_be(:epic) { create(:epic, group: group, parent: ancestor_b) }
let(:epics_data) { graphql_data['group']['epics']['edges'] }
let(:epic_node) do
<<~NODE
edges {
node {
id
iid
ancestors {
edges {
node {
iid
}
}
}
}
}
NODE
end
def query(params = {})
graphql_query_for(
"group", { "fullPath" => group.full_path },
['epicsEnabled', query_graphql_field("epics", params, epic_node)]
)
end
def epic_node_array(extract_attribute = nil)
node_array(epics_data, extract_attribute)
end
context 'when epics are enabled' do
before do
stub_licensed_features(epics: true)
group.add_developer(user)
end
it 'finds ancestors from group' do
post_graphql(query(iid: epic.iid), current_user: user)
expect(epic_node_array('ancestors'))
.to include({ 'edges' => [{ 'node' => { 'iid' => ancestor_b.iid.to_s } }] })
end
context 'when user has access to the parent group epics' do
before do
parent_group.add_developer(user)
end
it 'finds ancestors from group and parent group' do
post_graphql(query(iid: epic.iid), current_user: user)
expect(epic_node_array('ancestors')).to include(
{ 'edges' =>
[{ 'node' => { 'iid' => ancestor_b.iid.to_s } },
{ 'node' => { 'iid' => ancestor_a.iid.to_s } }] }
)
end
end
end
context 'when epics are disabled' do
before do
group.add_developer(user)
stub_licensed_features(epics: false)
end
it 'does not find the epic ancestors' do
post_graphql(query(iid: epic.iid), current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to be_nil
expect(epic_node_array('ancestors')).to be_empty
end
end
end
......@@ -7,13 +7,20 @@ require 'spec_helper'
RSpec.describe 'Epics through GroupQuery' do
include GraphqlHelpers
let(:user) { create(:user) }
let(:epics_data) { graphql_data['group']['epics']['edges'] }
let(:epic_data) { graphql_data['group']['epic'] }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:label) { create(:label) }
let(:epic) { create(:labeled_epic, group: group, labels: [label]) }
let(:epics_data) { graphql_data['group']['epics']['edges'] }
let(:epic_data) { graphql_data['group']['epic'] }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:label) { create(:label) }
let_it_be_with_reload(:epic) do
create(:labeled_epic, group: group,
state: :closed, created_at: 3.days.ago,
updated_at: 2.days.ago, start_date: 2.days.ago,
end_date: 4.days.ago, labels: [label]
)
end
# similar to GET /groups/:id/epics
describe 'Get list of epics from a group' do
......@@ -44,8 +51,6 @@ RSpec.describe 'Epics through GroupQuery' do
context 'when the request is correct' do
before do
stub_licensed_features(epics: true)
epic && group.reload
post_graphql(query, current_user: user)
end
......@@ -61,7 +66,6 @@ RSpec.describe 'Epics through GroupQuery' do
context 'with multiple epics' do
let_it_be(:user2) { create(:user) }
let_it_be(:epic) { create(:epic, group: group, state: :closed, created_at: 3.days.ago, updated_at: 2.days.ago, start_date: 2.days.ago, end_date: 4.days.ago) }
let_it_be(:epic2) { create(:epic, author: user2, group: group, title: 'foo', description: 'bar', created_at: 2.days.ago, updated_at: 3.days.ago, start_date: 3.days.ago, end_date: 3.days.ago ) }
before do
......@@ -164,7 +168,7 @@ RSpec.describe 'Epics through GroupQuery' do
end
context 'query performance' do
let!(:child_epic) { create(:epic, group: group, parent: epic2) }
let_it_be(:child_epic) { create(:epic, group: group, parent: create(:epic, group: group)) }
let(:epic_node) do
<<~NODE
edges {
......@@ -177,16 +181,12 @@ RSpec.describe 'Epics through GroupQuery' do
NODE
end
before do
group.reload
end
it 'avoids n+1 queries when loading parent field' do
# warm up
post_graphql(query({ iids: [epic.iid] }), current_user: user)
post_graphql(query({ iids: [child_epic.iid] }), current_user: user)
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
post_graphql(query({ iids: [epic.iid] }), current_user: user)
post_graphql(query({ iids: [child_epic.iid] }), current_user: user)
end.count
epics_with_parent = create_list(:epic, 3, group: group) do |epic|
......@@ -194,10 +194,10 @@ RSpec.describe 'Epics through GroupQuery' do
end
group.reload
# Added +5 to control_count due to an existing N+1 with licenses
# Threshold of 3 due to an existing N+1 with licenses
expect do
post_graphql(query({ iids: epics_with_parent.pluck(:iid) }), current_user: user)
end.not_to exceed_all_query_limit(control_count + 5)
end.not_to exceed_query_limit(control_count).with_threshold(3)
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