Skip to content
Snippets Groups Projects
Commit 0d29d658 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu :basketball:
Browse files

Merge branch '250479-show-iteration-lists' into 'master'

Handle listing of issues inside iteration lists and moving of issues to / from iteration lists

See merge request gitlab-org/gitlab!49946
parents 40d9ac1f edfe463a
No related branches found
No related tags found
No related merge requests found
Showing
with 282 additions and 47 deletions
Loading
Loading
@@ -2029,6 +2029,11 @@ type BoardList {
"""
issuesCount: Int
 
"""
Iteration of the list
"""
iteration: Iteration
"""
Label of the list
"""
Loading
Loading
Loading
Loading
@@ -5351,6 +5351,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iteration",
"description": "Iteration of the list",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Iteration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "label",
"description": "Label of the list",
Loading
Loading
@@ -325,6 +325,7 @@ Represents a list for an issue board.
| `id` | ID! | ID (global ID) of the list |
| `issues` | IssueConnection | Board issues |
| `issuesCount` | Int | Count of issues in the list |
| `iteration` | Iteration | Iteration of the list |
| `label` | Label | Label of the list |
| `limitMetric` | ListLimitMetric | The current limit metric for the list |
| `listType` | String! | Type of the list |
Loading
Loading
Loading
Loading
@@ -8,6 +8,8 @@ module BoardListType
prepended do
field :milestone, ::Types::MilestoneType, null: true,
description: 'Milestone of the list'
field :iteration, ::Types::IterationType, null: true,
description: 'Iteration of the list'
field :max_issue_count, GraphQL::INT_TYPE, null: true,
description: 'Maximum number of issues in the list'
field :max_issue_weight, GraphQL::INT_TYPE, null: true,
Loading
Loading
@@ -23,6 +25,10 @@ def milestone
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Milestone, object.milestone_id).find
end
 
def iteration
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Iteration, object.iteration_id).find
end
def assignee
object.assignee? ? object.user : nil
end
Loading
Loading
Loading
Loading
@@ -8,6 +8,10 @@ module List
 
LIMIT_METRIC_TYPES = %w[all_metrics issue_count issue_weights].freeze
 
# When adding a new licensed type, make sure to also add
# it on license.rb with the pattern "board_<list_type>_lists"
LICENSED_LIST_TYPES = %i[assignee milestone iteration].freeze
# ActiveSupport::Concern does not prepend the ClassMethods,
# so we cannot call `super` if we use it.
def self.prepended(base)
Loading
Loading
@@ -42,7 +46,7 @@ class << base
unless: -> { board&.resource_parent&.feature_available?(:board_milestone_lists) }
base.validates :list_type,
exclusion: { in: %w[iteration], message: -> (_object, _data) { _('Iteration lists not available with your current license') } },
unless: -> { board&.resource_parent&.feature_available?(:iterations) }
unless: -> { board&.resource_parent&.feature_available?(:board_iteration_lists) }
 
base.scope :without_types, ->(list_types) { where.not(list_type: list_types) }
end
Loading
Loading
Loading
Loading
@@ -14,6 +14,7 @@ class License < ApplicationRecord
EES_FEATURES = %i[
audit_events
blocked_issues
board_iteration_lists
code_owners
code_review_analytics
contribution_analytics
Loading
Loading
Loading
Loading
@@ -13,6 +13,7 @@ def filter(issues)
unless list&.movable? || list&.closed?
issues = without_assignees_from_lists(issues)
issues = without_milestones_from_lists(issues)
issues = without_iterations_from_lists(issues)
end
 
case list&.list_type
Loading
Loading
@@ -20,6 +21,8 @@ def filter(issues)
with_assignee(super)
when 'milestone'
with_milestone(super)
when 'iteration'
with_iteration(super)
else
super
end
Loading
Loading
@@ -58,6 +61,18 @@ def all_milestone_lists
end
# rubocop: enable CodeReuse/ActiveRecord
 
# rubocop: disable CodeReuse/ActiveRecord
def all_iteration_lists
# Note that the names are very similar but these are different.
# One is a license name and the other is a feature flag
if parent.feature_available?(:board_iteration_lists) && ::Feature.enabled?(:iteration_board_lists, parent)
board.lists.iteration.where.not(iteration_id: nil)
else
::List.none
end
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def without_assignees_from_lists(issues)
return issues if all_assignee_lists.empty?
Loading
Loading
@@ -85,6 +100,14 @@ def without_milestones_from_lists(issues)
end
# rubocop: enable CodeReuse/ActiveRecord
 
# rubocop: disable CodeReuse/ActiveRecord
def without_iterations_from_lists(issues)
return issues if all_iteration_lists.empty?
issues.not_in_iterations(all_iteration_lists.select(:iteration_id))
end
# rubocop: enable CodeReuse/ActiveRecord
def with_assignee(issues)
issues.assigned_to(list.user)
end
Loading
Loading
@@ -95,6 +118,10 @@ def with_milestone(issues)
end
# rubocop: enable CodeReuse/ActiveRecord
 
def with_iteration(issues)
issues.in_iterations(list.iteration_id)
end
# Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone?
Loading
Loading
Loading
Loading
@@ -34,19 +34,28 @@ def list_movement_args(issue)
assignee_ids = assignee_ids(issue)
milestone_id = milestone_id(issue)
 
{
movement_args = {
assignee_ids: assignee_ids,
milestone_id: milestone_id
}
movement_args[:sprint_id] = iteration_id(issue) if ::Feature.enabled?(:iteration_board_lists, parent)
movement_args
end
 
def milestone_id(issue)
# We want to nullify the issue milestone.
return if moving_to_list.backlog? && moving_from_list.milestone?
return moving_to_list.milestone_id if moving_to_list.milestone?
issue.milestone_id
end
def iteration_id(issue)
return if moving_to_list.backlog? && moving_from_list.iteration?
return moving_to_list.iteration_id if moving_to_list.iteration?
 
# Moving to a list which is not a 'milestone list' will keep
# the already existent milestone.
[issue.milestone_id, moving_to_list.milestone_id].compact.last
issue.sprint_id
end
 
def assignee_ids(issue)
Loading
Loading
Loading
Loading
@@ -19,16 +19,7 @@ def execute(board)
private
 
def valid_license?(parent)
license_name = case type
when :assignee
:board_assignee_lists
when :milestone
:board_milestone_lists
when :iteration
:iterations
end
license_name.nil? || parent.feature_available?(license_name)
List::LICENSED_LIST_TYPES.exclude?(type) || parent.feature_available?(:"board_#{type}_lists")
end
 
def license_validation_error
Loading
Loading
Loading
Loading
@@ -6,10 +6,6 @@ module Lists
module ListService
extend ::Gitlab::Utils::Override
 
# When adding a new licensed type, make sure to also add
# it on license.rb with the pattern "board_<list_type>_lists"
LICENSED_LIST_TYPES = %i[assignee milestone].freeze
override :execute
def execute(board, create_default_lists: true)
list_types = unavailable_list_types_for(board)
Loading
Loading
@@ -20,7 +16,10 @@ def execute(board, create_default_lists: true)
private
 
def unavailable_list_types_for(board)
(hidden_lists_for(board) + unlicensed_lists_for(board)).uniq
list_types = hidden_lists_for(board) + unlicensed_lists_for(board)
list_types << ::List.list_types[:iteration] if ::Feature.disabled?(:iteration_board_lists, board.resource_parent)
list_types.uniq
end
 
def hidden_lists_for(board)
Loading
Loading
@@ -35,7 +34,7 @@ def hidden_lists_for(board)
def unlicensed_lists_for(board)
parent = board.resource_parent
 
LICENSED_LIST_TYPES.each_with_object([]) do |list_type, lists|
List::LICENSED_LIST_TYPES.each_with_object([]) do |list_type, lists|
list_type_key = ::List.list_types[list_type]
lists << list_type_key unless parent&.feature_available?(:"board_#{list_type}_lists")
end
Loading
Loading
Loading
Loading
@@ -79,7 +79,7 @@ def read_board_list(user:, board:)
 
context 'when license is available' do
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
 
it 'returns a successful 200 response' do
Loading
Loading
@@ -92,7 +92,7 @@ def read_board_list(user:, board:)
 
context 'when license is unavailable' do
before do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
end
 
it 'returns an error' do
Loading
Loading
Loading
Loading
@@ -10,7 +10,12 @@
factory :milestone_list, parent: :list do
list_type { :milestone }
label { nil }
user { nil }
milestone
end
factory :iteration_list, parent: :list do
list_type { :iteration }
label { nil }
iteration
end
end
Loading
Loading
@@ -21,7 +21,7 @@
end
 
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, iterations: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
end
 
subject { mutation.resolve(board_id: board.to_global_id.to_s, **list_create_params) }
Loading
Loading
@@ -108,7 +108,7 @@
 
context 'when feature unavailable' do
it 'returns an error' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
 
expect(subject[:errors]).to include 'Iteration lists not available with your current license'
end
Loading
Loading
Loading
Loading
@@ -4,7 +4,7 @@
 
RSpec.describe GitlabSchema.types['BoardList'] do
it 'has specific fields' do
expected_fields = %w[milestone max_issue_count max_issue_weight assignee total_weight]
expected_fields = %w[milestone iteration max_issue_count max_issue_weight assignee total_weight]
 
expect(described_class).to include_graphql_fields(*expected_fields)
end
Loading
Loading
Loading
Loading
@@ -88,7 +88,7 @@
it { is_expected.to validate_presence_of(:iteration) }
 
it 'is invalid when feature is not available' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
 
expect(subject).to be_invalid
expect(subject.errors[:list_type])
Loading
Loading
Loading
Loading
@@ -22,6 +22,7 @@
let_it_be(:p3) { create(:group_label, title: 'P3', group: group) }
 
let_it_be(:milestone) { create(:milestone, group: group) }
let_it_be(:iteration) { create(:iteration, group: group) }
 
let_it_be(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, weight: 9, title: 'Issue 1', labels: [bug]) }
let_it_be(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, weight: 1, title: 'Issue 2', labels: [p2]) }
Loading
Loading
@@ -42,15 +43,16 @@
let(:parent) { group }
 
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
 
parent.add_developer(user)
opened_issue3.assignees.push(user_list.user)
end
 
context 'with assignee, milestone and label lists present' do
context 'with assignee, milestone, iteration and label lists present' do
let!(:user_list) { create(:user_list, board: board, position: 2) }
let!(:milestone_list) { create(:milestone_list, board: board, position: 3, milestone: milestone) }
let!(:iteration_list) { create(:iteration_list, board: board, position: 4, iteration: iteration) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
Loading
Loading
@@ -80,6 +82,46 @@
end
end
 
context 'iteration lists' do
let!(:iteration_issue) { create(:labeled_issue, project: project, iteration: iteration, labels: [p3]) }
let(:params) { { board_id: board.id, id: iteration_list.id } }
subject(:issues) { described_class.new(parent.class.find(parent.id), user, params).execute }
it 'returns issues from iteration persisted in the list' do
expect(issues).to contain_exactly(iteration_issue)
end
context 'backlog list' do
let(:params) { { board_id: board.id, id: backlog.id } }
it 'excludes issues in the iteration list' do
expect(issues).not_to include(iteration_issue)
end
context 'when feature is disabled' do
before do
stub_licensed_features(board_iteration_lists: false)
end
it 'includes issues in the iteration list' do
expect(issues).to include(iteration_issue)
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'includes issues in the iteration list' do
expect(issues).to include(iteration_issue)
end
end
end
end
describe '#metadata' do
it 'returns issues count and weight for list' do
params = { board_id: board.id, id: backlog.id }
Loading
Loading
Loading
Loading
@@ -103,6 +103,87 @@
end
end
 
shared_examples 'moving an issue to/from iteration lists' do
context 'from backlog to iteration list' do
let!(:issue) { create(:issue, project: project) }
let(:params) { { board_id: board1.id, from_list_id: backlog.id, to_list_id: iteration_list1.id } }
it 'assigns the iteration' do
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(nil)
.to(iteration_list1.iteration)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'does not assign the iteration' do
expect { described_class.new(parent, user, params).execute(issue) }
.not_to change { issue.reload.iteration }
end
end
end
context 'from iteration to backlog list' do
let!(:issue) { create(:issue, project: project, iteration: iteration_list1.iteration) }
it 'removes the iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: backlog.id }
expect { described_class.new(parent, user, params).execute(issue) } .to change { issue.reload.iteration }
.from(iteration_list1.iteration)
.to(nil)
end
end
context 'from label to iteration list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
it 'assigns the iteration and keeps labels' do
params = { board_id: board1.id, from_list_id: label_list1.id, to_list_id: iteration_list1.id }
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(nil)
.to(iteration_list1.iteration)
expect(issue.labels).to contain_exactly(bug, development)
end
end
context 'from iteration to label list' do
let!(:issue) do
create(:labeled_issue, project: project,
iteration: iteration_list1.iteration,
labels: [bug, development])
end
it 'adds labels and keeps iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: label_list2.id }
expect { described_class.new(parent, user, params).execute(issue) }
.not_to change { issue.reload.iteration }
expect(issue.labels).to contain_exactly(bug, development, testing)
end
end
context 'between iteration lists' do
let!(:issue) { create(:issue, project: project, iteration: iteration_list1.iteration) }
it 'replaces previous list iteration to targeting list iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: iteration_list2.id }
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(iteration_list1.iteration)
.to(iteration_list2.iteration)
end
end
end
shared_examples 'moving an issue to/from assignee lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development], milestone: milestone1) }
let(:params) { { board_id: board1.id, from_list_id: label_list1.id, to_list_id: label_list2.id } }
Loading
Loading
@@ -193,15 +274,20 @@
let(:user_list2) { create(:user_list, board: board1, user: user, position: 3) }
let(:milestone_list1) { create(:milestone_list, board: board1, milestone: milestone1, position: 4) }
let(:milestone_list2) { create(:milestone_list, board: board1, milestone: milestone2, position: 5) }
let(:iteration_list1) { create(:iteration_list, board: board1, iteration: iteration1, position: 6) }
let(:iteration_list2) { create(:iteration_list, board: board1, iteration: iteration2, position: 7) }
let(:closed) { create(:closed_list, board: board1) }
let(:backlog) { create(:backlog_list, board: board1) }
 
context 'when parent is a project' do
let(:project) { create(:project) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:parent_attr) { { project: project } }
let(:parent) { project }
let(:milestone1) { create(:milestone, project: project) }
let(:milestone2) { create(:milestone, project: project) }
let(:iteration1) { create(:iteration, group: group) }
let(:iteration2) { create(:iteration, group: group) }
 
let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') }
Loading
Loading
@@ -209,13 +295,14 @@
let(:regression) { create(:label, project: project, name: 'Regression') }
 
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
parent.add_developer(user)
parent.add_developer(user_list1.user)
end
 
it_behaves_like 'moving an issue to/from assignee lists'
it_behaves_like 'moving an issue to/from milestone lists'
it_behaves_like 'moving an issue to/from iteration lists'
end
 
context 'when parent is a group' do
Loading
Loading
@@ -225,6 +312,8 @@
let(:parent) { group }
let(:milestone1) { create(:milestone, group: group) }
let(:milestone2) { create(:milestone, group: group) }
let(:iteration1) { create(:iteration, group: group) }
let(:iteration2) { create(:iteration, group: group) }
 
let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
Loading
Loading
@@ -239,6 +328,7 @@
 
it_behaves_like 'moving an issue to/from assignee lists'
it_behaves_like 'moving an issue to/from milestone lists'
it_behaves_like 'moving an issue to/from iteration lists'
 
context 'when moving to same list' do
let(:subgroup) { create(:group, parent: group) }
Loading
Loading
Loading
Loading
@@ -62,7 +62,7 @@
subject(:service) { described_class.new(project, user, 'iteration_id' => iteration.id) }
 
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
 
it 'creates an iteration list when param is valid' do
Loading
Loading
@@ -93,7 +93,7 @@
end
 
it 'returns an error when license is unavailable' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
 
response = service.execute(board)
 
Loading
Loading
Loading
Loading
@@ -4,6 +4,14 @@
 
RSpec.describe Boards::Lists::ListService do
describe '#execute' do
before do
stub_licensed_features(board_assignee_lists: false, board_milestone_lists: false, board_iteration_lists: false)
end
def execute_service
service.execute(Board.find(board.id))
end
shared_examples 'list service for board with assignee lists' do
let!(:assignee_list) { build(:user_list, board: board).tap { |l| l.save(validate: false) } }
let!(:backlog_list) { create(:backlog_list, board: board) }
Loading
Loading
@@ -11,18 +19,17 @@
 
context 'when the feature is enabled' do
before do
allow(board.resource_parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(true)
allow(board.resource_parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(false)
stub_licensed_features(board_assignee_lists: true)
end
 
it 'returns all lists' do
expect(service.execute(board)).to match_array [backlog_list, list, assignee_list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, assignee_list, board.closed_list]
end
end
 
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
Loading
Loading
@@ -34,19 +41,51 @@
 
context 'when the feature is enabled' do
before do
allow(board.resource_parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(false)
allow(board.resource_parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(true)
stub_licensed_features(board_milestone_lists: true)
end
 
it 'returns all lists' do
expect(service.execute(board))
expect(execute_service)
.to match_array([backlog_list, list, milestone_list, board.closed_list])
end
end
 
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
shared_examples 'list service for board with iteration lists' do
let!(:iteration_list) { build(:iteration_list, board: board).tap { |l| l.save(validate: false) } }
let!(:backlog_list) { create(:backlog_list, board: board) }
let!(:list) { create(:list, board: board, label: label) }
context 'when the feature is enabled' do
before do
stub_licensed_features(board_iteration_lists: true)
end
it 'returns all lists' do
expect(execute_service)
.to match_array([backlog_list, list, iteration_list, board.closed_list])
end
context 'when the feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'filters out iteration lists that might have been created while subscribed' do
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
context 'when feature is disabled' do
it 'filters out iteration lists that might have been created while subscribed' do
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
Loading
Loading
@@ -58,7 +97,7 @@
it 'hides backlog list' do
board.update(hide_backlog_list: true)
 
expect(service.execute(board)).to match_array([board.closed_list, list])
expect(execute_service).to match_array([board.closed_list, list])
end
end
 
Loading
Loading
@@ -66,7 +105,7 @@
it 'hides closed list' do
board.update(hide_closed_list: true)
 
expect(service.execute(board)).to match_array([board.backlog_list, list])
expect(execute_service).to match_array([board.backlog_list, list])
end
end
end
Loading
Loading
@@ -80,6 +119,7 @@
 
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
it_behaves_like 'list service for board with iteration lists'
it_behaves_like 'hidden lists'
end
 
Loading
Loading
@@ -92,6 +132,7 @@
 
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
it_behaves_like 'list service for board with iteration lists'
it_behaves_like 'hidden lists'
end
end
Loading
Loading
Loading
Loading
@@ -2,7 +2,7 @@
 
RSpec.shared_examples 'iteration board list' do
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
 
context 'when iteration_id is sent' do
Loading
Loading
@@ -24,7 +24,7 @@
end
 
it 'returns 400 if not licensed' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
 
post api(url, user), params: { iteration_id: iteration.id }
 
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment