Commit fbcd3261 authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '218621-roadmaps-filter-by-milestone-be' into 'master'

Add milestone_title filter to Epics Finder

Closes #218621

See merge request gitlab-org/gitlab!36193
parents 279d3314 2944f413
...@@ -3950,6 +3950,11 @@ type Epic implements Noteable { ...@@ -3950,6 +3950,11 @@ type Epic implements Noteable {
""" """
last: Int last: Int
"""
Filter epics by milestone title, computed from epic's issues
"""
milestoneTitle: String
""" """
Search query for epic title or description Search query for epic title or description
""" """
...@@ -5247,6 +5252,11 @@ type Group { ...@@ -5247,6 +5252,11 @@ type Group {
""" """
labelName: [String!] labelName: [String!]
"""
Filter epics by milestone title, computed from epic's issues
"""
milestoneTitle: String
""" """
Search query for epic title or description Search query for epic title or description
""" """
...@@ -5324,6 +5334,11 @@ type Group { ...@@ -5324,6 +5334,11 @@ type Group {
""" """
last: Int last: Int
"""
Filter epics by milestone title, computed from epic's issues
"""
milestoneTitle: String
""" """
Search query for epic title or description Search query for epic title or description
""" """
......
...@@ -11050,6 +11050,16 @@ ...@@ -11050,6 +11050,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "milestoneTitle",
"description": "Filter epics by milestone title, computed from epic's issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "iidStartsWith", "name": "iidStartsWith",
"description": "Filter epics by iid for autocomplete", "description": "Filter epics by iid for autocomplete",
...@@ -14693,6 +14703,16 @@ ...@@ -14693,6 +14703,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "milestoneTitle",
"description": "Filter epics by milestone title, computed from epic's issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "iidStartsWith", "name": "iidStartsWith",
"description": "Filter epics by iid for autocomplete", "description": "Filter epics by iid for autocomplete",
...@@ -14822,6 +14842,16 @@ ...@@ -14822,6 +14842,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "milestoneTitle",
"description": "Filter epics by milestone title, computed from epic's issues",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "iidStartsWith", "name": "iidStartsWith",
"description": "Filter epics by iid for autocomplete", "description": "Filter epics by iid for autocomplete",
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
# author_id: integer # author_id: integer
# author_username: string # author_username: string
# label_name: string # label_name: string
# milestone_title: string
# search: string # search: string
# sort: string # sort: string
# start_date: datetime # start_date: datetime
...@@ -33,6 +34,7 @@ class EpicsFinder < IssuableFinder ...@@ -33,6 +34,7 @@ class EpicsFinder < IssuableFinder
author_id author_id
author_username author_username
label_name label_name
milestone_title
start_date start_date
end_date end_date
search search
...@@ -71,7 +73,6 @@ class EpicsFinder < IssuableFinder ...@@ -71,7 +73,6 @@ class EpicsFinder < IssuableFinder
sort(items) sort(items)
end end
# rubocop: disable CodeReuse/ActiveRecord
def init_collection def init_collection
groups = if params[:iids].present? groups = if params[:iids].present?
# If we are querying for specific iids, then we should only be looking at # If we are querying for specific iids, then we should only be looking at
...@@ -82,22 +83,23 @@ class EpicsFinder < IssuableFinder ...@@ -82,22 +83,23 @@ class EpicsFinder < IssuableFinder
permissioned_related_groups permissioned_related_groups
end end
epics = Epic.where(group: groups) epics = Epic.in_selected_groups(groups)
with_confidentiality_access_check(epics, groups) with_confidentiality_access_check(epics, groups)
end end
# rubocop: enable CodeReuse/ActiveRecord
private private
def permissioned_related_groups def permissioned_related_groups
strong_memoize(:permissioned_related_groups) do
groups = related_groups groups = related_groups
# if user is member of top-level related group, he can automatically read # if user is member of top-level related group, he can automatically read
# all epics in all subgroups # all epics in all subgroups
return groups if can_read_all_epics_in_related_groups?(groups) next groups if can_read_all_epics_in_related_groups?(groups)
groups_user_can_read_epics(groups) groups_user_can_read_epics(groups)
end end
end
def groups_user_can_read_epics(groups) def groups_user_can_read_epics(groups)
# `same_root` should be set only if we are sure that all groups # `same_root` should be set only if we are sure that all groups
...@@ -116,6 +118,7 @@ class EpicsFinder < IssuableFinder ...@@ -116,6 +118,7 @@ class EpicsFinder < IssuableFinder
items = by_iids(items) items = by_iids(items)
items = by_my_reaction_emoji(items) items = by_my_reaction_emoji(items)
items = by_confidential(items) items = by_confidential(items)
items = by_milestone(items)
starts_with_iid(items) starts_with_iid(items)
end end
...@@ -225,4 +228,19 @@ class EpicsFinder < IssuableFinder ...@@ -225,4 +228,19 @@ class EpicsFinder < IssuableFinder
params[:confidential] ? items.confidential : items.public_only params[:confidential] ? items.confidential : items.public_only
end end
# rubocop: disable CodeReuse/ActiveRecord
def by_milestone(items)
return items unless params[:milestone_title].present?
milestones = Milestone.for_projects_and_groups(group_projects, permissioned_related_groups)
.where(title: params[:milestone_title])
items.in_milestone(milestones)
end
# rubocop: enable CodeReuse/ActiveRecord
def group_projects
Project.in_namespace(permissioned_related_groups).with_issues_available_for_user(current_user)
end
end end
...@@ -32,6 +32,10 @@ module Resolvers ...@@ -32,6 +32,10 @@ module Resolvers
required: false, required: false,
description: 'Filter epics by labels' description: 'Filter epics by labels'
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: "Filter epics by milestone title, computed from epic's issues"
argument :iid_starts_with, GraphQL::STRING_TYPE, argument :iid_starts_with, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'Filter epics by iid for autocomplete' description: 'Filter epics by iid for autocomplete'
......
---
title: Add the option to filter epics by milestone
merge_request: 36193
author:
type: added
...@@ -261,6 +261,167 @@ RSpec.describe EpicsFinder do ...@@ -261,6 +261,167 @@ RSpec.describe EpicsFinder do
end end
end end
context 'by milestone' do
let_it_be(:ancestor_group) { create(:group, :public) }
let_it_be(:ancestor_group_project) { create(:project, :public, group: ancestor_group) }
let_it_be(:base_group) { create(:group, :public, parent: ancestor_group) }
let_it_be(:base_group_project) { create(:project, :public, group: base_group) }
let_it_be(:base_epic1) { create(:epic, group: base_group) }
let_it_be(:base_epic2) { create(:epic, group: base_group) }
let_it_be(:base_group_milestone) { create(:milestone, group: base_group) }
let_it_be(:base_project_milestone) { create(:milestone, project: base_group_project) }
let_it_be(:project2) { base_group_project }
shared_examples 'filtered by milestone' do |milestone_type|
it 'returns expected epics' do
project3 = milestone_type == :group ? project2 : project
create(:issue, project: project, milestone: milestone, epic: epic)
create(:issue, project: project3, milestone: milestone, epic: epic2)
params[:milestone_title] = milestone.title
expect(epics(params)).to contain_exactly(epic, epic2)
end
end
context 'with no hierarchy' do
let_it_be(:project) { base_group_project }
let_it_be(:epic) { base_epic1 }
let_it_be(:epic2) { base_epic2 }
let_it_be(:params) do
{
group_id: base_group.id,
include_descendant_groups: false,
include_ancestor_groups: false
}
end
it_behaves_like 'filtered by milestone', :group do
let_it_be(:milestone) { base_group_milestone }
end
it_behaves_like 'filtered by milestone', :project do
let_it_be(:milestone) { base_project_milestone }
end
it 'returns empty result if the milestone is not present' do
params[:milestone_title] = 'test milestone title'
expect(epics(params)).to be_empty
end
end
context "with hierarchy" do
let_it_be(:subgroup) { create(:group, :public, parent: base_group) }
let_it_be(:subgroup_project) { create(:project, :public, group: subgroup) }
let_it_be(:subgroup_project_milestone) { create(:milestone, project: subgroup_project) }
let_it_be(:ancestor_group_milestone) { create(:milestone, group: ancestor_group) }
let_it_be(:ancestor_project_milestone) { create(:milestone, project: ancestor_group_project) }
let_it_be(:subgroup_epic1) { create(:epic, group: subgroup) }
let_it_be(:subgroup_epic2) { create(:epic, group: subgroup) }
let_it_be(:ancestor_epic1) { create(:epic, group: ancestor_group) }
let_it_be(:ancestor_epic2) { create(:epic, group: ancestor_group) }
let_it_be(:params) { { group_id: base_group.id } }
context 'when include_descendant_groups is true' do
let_it_be(:project) { subgroup_project }
let_it_be(:epic) { subgroup_epic1 }
let_it_be(:epic2) { subgroup_epic2 }
before do
params[:include_descendant_groups] = true
params[:include_ancestor_groups] = false
end
it_behaves_like 'filtered by milestone', :group do
let(:milestone) { base_group_milestone }
end
it_behaves_like 'filtered by milestone', :project do
let(:milestone) { subgroup_project_milestone }
end
it 'returns results with all milestones matching given title' do
project_milestone1 = create(:milestone, project: base_group_project, title: '13.0')
project_milestone2 = create(:milestone, project: subgroup_project, title: '13.0')
create(:issue, project: base_group_project, milestone: project_milestone1, epic: base_epic1)
create(:issue, project: subgroup_project, milestone: project_milestone2, epic: subgroup_epic1)
params[:milestone_title] = '13.0'
expect(epics(params)).to contain_exactly(base_epic1, subgroup_epic1)
end
end
context 'when include_ancestor_groups is true' do
let_it_be(:project) { ancestor_group_project }
let_it_be(:epic) { ancestor_epic1 }
let_it_be(:epic2) { ancestor_epic2 }
before do
params[:include_descendant_groups] = false
params[:include_ancestor_groups] = true
end
it_behaves_like 'filtered by milestone', :group do
let(:milestone) { ancestor_group_milestone }
end
it_behaves_like 'filtered by milestone', :project do
let(:milestone) { ancestor_project_milestone }
end
context 'when include_descendant_groups is true' do
before do
params[:include_descendant_groups] = true
end
it 'returns expected epics when filtering by group milestone' do
create(:issue, project: ancestor_group_project, milestone: ancestor_group_milestone, epic: ancestor_epic1)
create(:issue, project: base_group_project, milestone: ancestor_group_milestone, epic: ancestor_epic1)
create(:issue, project: subgroup_project, milestone: ancestor_group_milestone, epic: subgroup_epic1)
params[:milestone_title] = ancestor_group_milestone.title
expect(epics(params)).to contain_exactly(ancestor_epic1, ancestor_epic1, subgroup_epic1)
end
it_behaves_like 'filtered by milestone', :project do
let(:milestone) { ancestor_project_milestone }
end
end
context 'when a project is restricted' do
let_it_be(:issue) do
create(:issue, project: subgroup_project,
epic: subgroup_epic1,
milestone: subgroup_project_milestone
)
end
before do
params[:milestone_title] = subgroup_project_milestone.title
end
it 'does not return epic if user can not access project' do
subgroup_project
.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
expect(epics(params)).to be_empty
end
it 'does not return epics if user can not access project issues' do
subgroup_project
.project_feature.update!( issues_access_level: ProjectFeature::DISABLED)
expect(epics(params)).to be_empty
end
end
end
end
end
context 'when using iid starts with query' do context 'when using iid starts with query' do
let_it_be(:epic1) { create(:epic, :opened, group: group, iid: '11') } let_it_be(:epic1) { create(:epic, :opened, group: group, iid: '11') }
let_it_be(:epic2) { create(:epic, :opened, group: group, iid: '1112') } let_it_be(:epic2) { create(:epic, :opened, group: group, iid: '1112') }
......
...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::EpicsResolver do ...@@ -9,7 +9,7 @@ RSpec.describe Resolvers::EpicsResolver do
let_it_be(:user2) { create(:user) } let_it_be(:user2) { create(:user) }
context "with a group" do context "with a group" do
let(:group) { create(:group) } let_it_be_with_refind(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
let(:epic1) { create(:epic, group: group, state: :closed, created_at: 3.days.ago, updated_at: 2.days.ago) } let(:epic1) { create(:epic, group: group, state: :closed, created_at: 3.days.ago, updated_at: 2.days.ago) }
let(:epic2) { create(:epic, group: group, author: user2, title: 'foo', description: 'bar', created_at: 2.days.ago, updated_at: 3.days.ago) } let(:epic2) { create(:epic, group: group, author: user2, title: 'foo', description: 'bar', created_at: 2.days.ago, updated_at: 3.days.ago) }
...@@ -134,6 +134,28 @@ RSpec.describe Resolvers::EpicsResolver do ...@@ -134,6 +134,28 @@ RSpec.describe Resolvers::EpicsResolver do
end end
end end
context 'with milestone_title' do
let_it_be(:milestone1) { create(:milestone, group: group) }
it 'filters epics by issues milestone' do
create(:issue, project: project, epic: epic2)
create(:issue, project: project, milestone: milestone1, epic: epic1)
epics = resolve_epics(milestone_title: milestone1.title)
expect(epics).to match_array([epic1])
end
it 'returns empty result if milestone is not assigned to any epic issues' do
milestone2 = create(:milestone, group: group)
create(:issue, project: project, milestone: milestone1, epic: epic1)
epics = resolve_epics(milestone_title: milestone2.title)
expect(epics).to be_empty
end
end
context 'with sort' do context 'with sort' do
let!(:epic1) { create(:epic, group: group, title: 'first created', description: 'description', start_date: 10.days.ago, end_date: 10.days.from_now) } let!(:epic1) { create(:epic, group: group, title: 'first created', description: 'description', start_date: 10.days.ago, end_date: 10.days.from_now) }
let!(:epic2) { create(:epic, group: group, title: 'second created', description: 'text 1', start_date: 20.days.ago, end_date: 20.days.from_now) } let!(:epic2) { create(:epic, group: group, title: 'second created', description: 'text 1', start_date: 20.days.ago, end_date: 20.days.from_now) }
...@@ -181,6 +203,15 @@ RSpec.describe Resolvers::EpicsResolver do ...@@ -181,6 +203,15 @@ RSpec.describe Resolvers::EpicsResolver do
it 'return all epics' do it 'return all epics' do
expect(resolve_epics).to contain_exactly(epic1, epic2, epic3, epic4) expect(resolve_epics).to contain_exactly(epic1, epic2, epic3, epic4)
end end
it 'filter by milestones in subgroups' do
subgroup_project = create(:project, group: sub_group)
milestone = create(:milestone, group: sub_group)
create(:issue, project: subgroup_project, epic: epic1, milestone: milestone)
create(:issue, project: subgroup_project, epic: epic3, milestone: milestone)
expect(resolve_epics(milestone_title: milestone.title)).to contain_exactly(epic1, epic3)
end
end end
context 'with partial iids' do context 'with partial iids' do
......
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