diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
index 1c24032dbbbd93e208809f7540cd15eccdd13cea..5cbc937e465f6773abcb175110e17457c8e9dd7c 100644
--- a/app/models/concerns/issue_resource_event.rb
+++ b/app/models/concerns/issue_resource_event.rb
@@ -8,6 +8,10 @@ module IssueResourceEvent
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
+ scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) }
+ scope :by_issue_ids, ->(issue_ids) do
+ table = self.klass.arel_table
+ where(table[:issue_id].in(issue_ids))
+ end
end
end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index f126c828240c9b3d813d574faff7ba9f019d3d81..a760c6e4e1a38446399a7f9ad55a06b777a52693 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -11942,7 +11942,6 @@ Represents an iteration object.
| `id` | [`ID!`](#id) | ID of the iteration. |
| `iid` | [`ID!`](#id) | Internal ID of the iteration. |
| `iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. |
-| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
| `scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
| `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
| `sequence` | [`Int!`](#int) | Sequence number for the iteration when you sort the containing cadence's iterations by the start and end date. The earliest starting and ending iteration is assigned 1. |
@@ -11953,6 +11952,20 @@ Represents an iteration object.
| `webPath` | [`String!`](#string) | Web path of the iteration. |
| `webUrl` | [`String!`](#string) | Web URL of the iteration. |
+#### Fields with arguments
+
+##### `Iteration.report`
+
+Historically accurate report about the timebox.
+
+Returns [`TimeboxReport`](#timeboxreport).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. |
+
### `IterationCadence`
Represents an iteration cadence.
@@ -12875,7 +12888,6 @@ Represents a milestone.
| `id` | [`ID!`](#id) | ID of the milestone. |
| `iid` | [`ID!`](#id) | Internal ID of the milestone. |
| `projectMilestone` | [`Boolean!`](#boolean) | Indicates if milestone is at project level. |
-| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
| `startDate` | [`Time`](#time) | Timestamp of the milestone start date. |
| `state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. |
| `stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. |
@@ -12884,6 +12896,20 @@ Represents a milestone.
| `updatedAt` | [`Time!`](#time) | Timestamp of last milestone update. |
| `webPath` | [`String!`](#string) | Web path of the milestone. |
+#### Fields with arguments
+
+##### `Milestone.report`
+
+Historically accurate report about the timebox.
+
+Returns [`TimeboxReport`](#timeboxreport).
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. |
+
### `MilestoneStats`
Contains statistics about a milestone.
@@ -19253,11 +19279,19 @@ Implementations:
- [`Iteration`](#iteration)
- [`Milestone`](#milestone)
-##### Fields
+##### Fields with arguments
+
+###### `TimeboxReportInterface.report`
+
+Historically accurate report about the timebox.
+
+Returns [`TimeboxReport`](#timeboxreport).
+
+####### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
-| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
+| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. |
#### `User`
diff --git a/ee/app/graphql/resolvers/timebox_report_resolver.rb b/ee/app/graphql/resolvers/timebox_report_resolver.rb
index 85528121ee888bbf3a17464ae0d5149a84ab6ad2..c610f29bebf81741ff9a4b7c6a2937a0ae545fce 100644
--- a/ee/app/graphql/resolvers/timebox_report_resolver.rb
+++ b/ee/app/graphql/resolvers/timebox_report_resolver.rb
@@ -2,16 +2,54 @@
module Resolvers
class TimeboxReportResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
type Types::TimeboxReportType, null: true
+ argument :full_path, GraphQL::Types::String,
+ required: false,
+ description: 'Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`.'
+
alias_method :timebox, :object
- def resolve(*args)
- response = TimeboxReportService.new(timebox).execute
+ def resolve(**args)
+ find_and_authorize_scope!(args)
+ project_scopes = projects_in_scope(args)
+
+ response = TimeboxReportService.new(timebox, project_scopes).execute
raise GraphQL::ExecutionError, response.message if response.error?
response.payload
end
+
+ private
+
+ def find_and_authorize_scope!(args)
+ return unless args[:full_path].present?
+
+ @group_scope = Group.find_by_full_path(args[:full_path])
+ @project_scope = Project.find_by_full_path(args[:full_path]) if @group_scope.nil?
+
+ raise_resource_not_available_error! if @group_scope.nil? && @project_scope.nil?
+
+ authorize_scope!
+ end
+
+ def authorize_scope!
+ if @project_scope
+ Ability.allowed?(context[:current_user], :read_issue, @project_scope) || raise_resource_not_available_error!
+ elsif @group_scope
+ Ability.allowed?(context[:current_user], :read_group, @group_scope) || raise_resource_not_available_error!
+ end
+ end
+
+ def projects_in_scope(args)
+ if @project_scope
+ Project.id_in(@project_scope.id)
+ elsif @group_scope
+ Project.for_group_and_its_subgroups(@group_scope)
+ end
+ end
end
end
diff --git a/ee/app/services/timebox_report_service.rb b/ee/app/services/timebox_report_service.rb
index 8855865f3a72e45c1a5bbe802114192d0aa44be5..c016323c77773c622b3eb07f57ab8662eed45d93 100644
--- a/ee/app/services/timebox_report_service.rb
+++ b/ee/app/services/timebox_report_service.rb
@@ -10,10 +10,17 @@
class TimeboxReportService
include Gitlab::Utils::StrongMemoize
+ # A timebox report needs to gather all the events - issue assignment, weight, status - associated with its timebox.
+ # To avoid straining the DB and the application hosts, an upperbound needs to be placed on the number of events queried.
EVENT_COUNT_LIMIT = 50_000
- def initialize(timebox)
+ # While running the UNION query for events, PostgreSQL could still read unlimited amount of buffers.
+ # As a safety measure, each subquery in the UNION query should have a limit.
+ SINGLE_EVENT_COUNT_LIMIT = 20_000
+
+ def initialize(timebox, scoped_projects = nil)
@timebox = timebox
+ @scoped_projects = scoped_projects
end
def execute
@@ -163,29 +170,60 @@ def find_issue_state(issue_id)
}
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def materialized_ctes
+ ctes = if @scoped_projects.nil?
+ [Gitlab::SQL::CTE.new(:scoped_issue_ids, issue_ids)]
+ else
+ timebox_cte = Gitlab::SQL::CTE.new(:timebox_issue_ids, issue_ids)
+ scope_cte = Gitlab::SQL::CTE.new(:scoped_issue_ids,
+ Issue
+ .where(Arel.sql('"issues"."id" IN (SELECT "issue_id" FROM "timebox_issue_ids")'))
+ .in_projects(@scoped_projects)
+ .select(:id)
+ )
+
+ [timebox_cte, scope_cte]
+ end
+
+ ctes.map { |cte| cte.to_arel }
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
# rubocop: disable CodeReuse/ActiveRecord
def resource_events
strong_memoize(:resource_events) do
union = Gitlab::SQL::Union.new([resource_timebox_events, state_events, weight_events]) # rubocop: disable Gitlab/Union
+ query = Arel::SelectManager.new
+ .with(materialized_ctes)
+ .project(Arel.star)
+ .from("((#{union.to_sql}) ORDER BY created_at LIMIT #{EVENT_COUNT_LIMIT + 1}) resource_events_union").to_sql
- ApplicationRecord.connection.execute("(#{union.to_sql}) ORDER BY created_at LIMIT #{EVENT_COUNT_LIMIT + 1}")
+ ApplicationRecord.connection.execute(query)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def resource_timebox_events
- resource_timebox_event_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
- .select("'timebox' AS event_type, created_at, #{timebox_fk} AS value, action, issue_id")
+ resource_timebox_event_class.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids)
+ .select("'timebox' AS event_type", "created_at", "#{timebox_fk} AS value", "action", "issue_id")
+ .limit(SINGLE_EVENT_COUNT_LIMIT)
end
def state_events
- ResourceStateEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
- .select('\'state\' AS event_type, created_at, state AS value, NULL AS action, issue_id')
+ ResourceStateEvent.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids)
+ .select("'state' AS event_type", "created_at", "state AS value", "NULL AS action", "issue_id")
+ .limit(SINGLE_EVENT_COUNT_LIMIT)
end
def weight_events
- ResourceWeightEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time)
- .select('\'weight\' AS event_type, created_at, weight AS value, NULL AS action, issue_id')
+ ResourceWeightEvent.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids)
+ .select("'weight' AS event_type", "created_at", "weight AS value", "NULL AS action", "issue_id")
+ .limit(SINGLE_EVENT_COUNT_LIMIT)
+ end
+
+ def in_scoped_issue_ids
+ Arel.sql('SELECT * FROM "scoped_issue_ids"')
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb
index 8995e1c9e1dab6259a2fe1542e281f00bfd49488..71e3f272916d6b99e0d0a1715f96ba24ea7cc0ec 100644
--- a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb
@@ -7,49 +7,136 @@
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:subgroup_project) { create(:project, group: subgroup) }
+ let_it_be(:private_group) { create(:group, :private) }
+ let_it_be(:private_subgroup) { create(:group, :private, parent: private_group) }
+ let_it_be(:private_project1) { create(:project, group: private_group) }
+ let_it_be(:private_project2) { create(:project, group: private_group) }
+ let_it_be(:group_member) { create(:user) }
+ let_it_be(:private_group_member) { create(:user) }
+ let_it_be(:private_project1_member) { create(:user) }
+ let_it_be(:private_project2_member) { create(:user) }
let_it_be(:issues) { create_list(:issue, 2, project: project) }
let_it_be(:start_date) { Date.today }
let_it_be(:due_date) { start_date + 2.weeks }
+ before_all do
+ group.add_guest(group_member)
+ private_group.add_guest(private_group_member)
+ private_project1.add_guest(private_project1_member)
+ private_project2.add_guest(private_project2_member)
+ end
+
before do
stub_licensed_features(milestone_charts: true, issue_weights: true, iterations: true)
end
RSpec.shared_examples 'timebox time series' do
- subject { resolve(described_class, obj: timebox) }
-
- it 'returns burnup chart data' do
- expect(subject).to eq(
- stats: {
- complete: { count: 0, weight: 0 },
- incomplete: { count: 2, weight: 0 },
- total: { count: 2, weight: 0 }
- },
- burnup_time_series: [
- {
- date: start_date + 4.days,
- scope_count: 1,
- scope_weight: 0,
- completed_count: 0,
- completed_weight: 0
- },
- {
- date: start_date + 9.days,
- scope_count: 2,
- scope_weight: 0,
- completed_count: 0,
- completed_weight: 0
- }
- ])
+ using RSpec::Parameterized::TableSyntax
+
+ subject { resolve(described_class, obj: timebox, ctx: { current_user: current_user }) }
+
+ context 'when authorized to view "project"' do
+ let(:current_user) { group_member }
+
+ it 'returns burnup chart data' do
+ expect(subject).to eq(
+ stats: {
+ complete: { count: 0, weight: 0 },
+ incomplete: { count: 2, weight: 0 },
+ total: { count: 2, weight: 0 }
+ },
+ burnup_time_series: [
+ {
+ date: start_date + 4.days,
+ scope_count: 1,
+ scope_weight: 0,
+ completed_count: 0,
+ completed_weight: 0
+ },
+ {
+ date: start_date + 9.days,
+ scope_count: 2,
+ scope_weight: 0,
+ completed_count: 0,
+ completed_weight: 0
+ }
+ ])
+ end
+
+ context 'when the service returns an error' do
+ before do
+ stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
+ end
+
+ it 'raises a GraphQL exception' do
+ expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events')
+ end
+ end
end
- context 'when the service returns an error' do
- before do
- stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
+ context 'when fullPath is provided' do
+ subject { resolve(described_class, obj: timebox, args: { full_path: full_path }, ctx: { current_user: current_user }) }
+
+ context "when no group or project matches the provided fullPath" do
+ let(:full_path) { "abc" }
+ let(:current_user) { group_member }
+
+ it 'raises a GraphQL exception' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action")
+ end
end
- it 'raises a GraphQL exception' do
- expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events')
+ context "when current user is not authorized to read group or view project issues, or resource doesn't exist" do
+ let(:full_path) { scope.full_path }
+
+ where(:scope, :current_user) do
+ ref(:private_group) | nil
+ ref(:private_group) | ref(:group_member)
+ ref(:private_subgroup) | nil
+ ref(:private_subgroup) | ref(:group_member)
+ ref(:private_subgroup) | ref(:private_project1_member)
+ ref(:private_subgroup) | ref(:private_project2_member)
+ ref(:private_project1) | nil
+ ref(:private_project1) | ref(:group_member)
+ ref(:private_project1) | ref(:private_project2_member)
+ ref(:private_project2) | nil
+ ref(:private_project2) | ref(:group_member)
+ ref(:private_project2) | ref(:private_project1_member)
+ end
+
+ with_them do
+ it 'raises a GraphQL exception' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action")
+ end
+ end
+ end
+
+ context 'when current user can read group or view project issues' do
+ let(:full_path) { scope.full_path }
+
+ where(:scope, :current_user, :authorized_projects) do
+ ref(:group) | ref(:group_member) | lazy { [project, subgroup_project] }
+ ref(:subgroup) | ref(:group_member) | lazy { [subgroup_project] }
+ ref(:subgroup_project) | ref(:group_member) | lazy { [subgroup_project] }
+ ref(:private_group) | ref(:private_group_member) | lazy { [private_project1, private_project2] }
+ # As long as a user can read a group ("private_group"),
+ # the user should be able to see the count of the issues coming from the projects to which the user doesn't have access.
+ ref(:private_group) | ref(:private_project1_member) | lazy { [private_project1, private_project2] }
+ ref(:private_group) | ref(:private_project2_member) | lazy { [private_project1, private_project2] }
+ ref(:private_project1) | ref(:private_project1_member) | lazy { [private_project1] }
+ ref(:private_project2) | ref(:private_project2_member) | lazy { [private_project2] }
+ ref(:private_subgroup) | ref(:private_group_member) | lazy { [] }
+ end
+
+ with_them do
+ it 'passes projects to the timebox report service' do
+ expect(TimeboxReportService).to receive(:new).with(timebox, a_collection_containing_exactly(*authorized_projects)).and_call_original
+
+ subject
+ end
+ end
end
end
end
diff --git a/ee/spec/requests/api/graphql/iteration_spec.rb b/ee/spec/requests/api/graphql/iteration_spec.rb
index 10ba323b5a89118e4488968fd3d24fb8eb871889..631723fe5e5718e115fa1daf7156162d277d6e2b 100644
--- a/ee/spec/requests/api/graphql/iteration_spec.rb
+++ b/ee/spec/requests/api/graphql/iteration_spec.rb
@@ -5,12 +5,14 @@
RSpec.describe 'Querying an Iteration' do
include GraphqlHelpers
- let_it_be(:current_user) { create(:user) }
+ let_it_be(:group_member) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:iteration) { create(:iteration, group: group) }
+ let(:current_user) { group_member }
+ let(:fields) { 'title' }
let(:query) do
- graphql_query_for('iteration', { id: iteration.to_global_id.to_s }, 'title')
+ graphql_query_for('iteration', { id: iteration.to_global_id.to_s }, fields)
end
subject { graphql_data['iteration'] }
@@ -21,12 +23,114 @@
context 'when the user has access to the iteration' do
before_all do
- group.add_guest(current_user)
+ group.add_guest(group_member)
end
it_behaves_like 'a working graphql query'
it { is_expected.to include('title' => iteration.name) }
+
+ context 'when `report` field is included' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:subgroup) { create(:group, :private, parent: group) }
+ let_it_be(:project1) { create(:project, group: group) }
+ let_it_be(:project2) { create(:project, group: group) }
+ let_it_be(:subgroup_project) { create(:project, group: subgroup) }
+ let_it_be(:project1_member) { create(:user) }
+ let_it_be(:project2_member) { create(:user) }
+ let_it_be(:subgroup_member) { create(:user) }
+
+ subject { graphql_data['iteration']['report'] }
+
+ before_all do
+ project1.add_guest(project1_member)
+ project2.add_guest(project2_member)
+ subgroup.add_guest(subgroup_member)
+
+ issue1 = create(:issue, project: project1)
+ issue2 = create(:issue, project: project1)
+ issue3 = create(:issue, project: project2)
+ subgroup_issue1 = create(:issue, project: subgroup_project)
+
+ create(:resource_iteration_event, issue: issue1, iteration: iteration, action: :add, created_at: 2.days.ago)
+ create(:resource_iteration_event, issue: issue2, iteration: iteration, action: :add, created_at: 2.days.ago)
+ create(:resource_iteration_event, issue: issue3, iteration: iteration, action: :add, created_at: 2.days.ago)
+ create(:resource_iteration_event, issue: subgroup_issue1, iteration: iteration, action: :add, created_at: 2.days.ago)
+
+ # These are created to check the report only counts the iteration events for the "iteration".
+ other_iteration = create(:iteration, group: group)
+ subgroup_iteration = create(:iteration, group: group)
+ issue4 = create(:issue, project: project2)
+ subgroup_issue2 = create(:issue, project: subgroup_project)
+ create(:resource_iteration_event, issue: issue4, iteration: other_iteration, action: :add, created_at: 2.days.ago)
+ create(:resource_iteration_event, issue: subgroup_issue2, iteration: subgroup_iteration, action: :add, created_at: 2.days.ago)
+ end
+
+ context 'when fullPath argument is not provided' do
+ let(:fields) { 'report { burnupTimeSeries { scopeCount } }' }
+
+ where(:current_user, :expected_scope_count) do
+ # Iteration is a group-level object. When a user can see it, the user should be able to
+ # see the count of all the issues belonging to the group even if the user is not authorized for all projects.
+ ref(:group_member) | 4
+ ref(:project1_member) | 4
+ end
+
+ with_them do
+ it { is_expected.to include({ "burnupTimeSeries" => [{ "scopeCount" => expected_scope_count }] })}
+ end
+ end
+
+ context 'when fullPath argument is provided' do
+ let(:fields) { "report(fullPath: \"#{scope.full_path}\") { burnupTimeSeries { scopeCount } }" }
+
+ context 'when current user has authorized access to one or more projects under the namespace' do
+ where(:scope, :current_user, :expected_scope_count) do
+ ref(:group) | ref(:group_member) | 4
+ ref(:group) | ref(:project1_member) | 4
+ ref(:project1) | ref(:group_member) | 2
+ ref(:project1) | ref(:project1_member) | 2
+ ref(:project2) | ref(:project2_member) | 1
+ ref(:project2) | ref(:group_member) | 1
+ ref(:subgroup) | ref(:group_member) | 1
+ ref(:subgroup) | ref(:subgroup_member) | 1
+ end
+
+ with_them do
+ it { is_expected.to include({ "burnupTimeSeries" => [{ "scopeCount" => expected_scope_count }] })}
+ end
+ end
+
+ context 'when no group or project matches the provided fullPath' do
+ let(:fields) { "report(fullPath: \"abc\") { burnupTimeSeries { scopeCount } }" }
+
+ with_them do
+ it 'raises an exception' do
+ expect(graphql_errors).to include(a_hash_including('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action"))
+ end
+ end
+ end
+
+ context 'when current user cannot access the given namespace' do
+ let_it_be(:other_group) { create(:group, :private) }
+
+ where(:scope, :current_user) do
+ ref(:other_group) | ref(:group_member)
+ ref(:project1) | ref(:subgroup_member)
+ ref(:project1) | ref(:project2_member)
+ ref(:project2) | ref(:project1_member)
+ ref(:subgroup) | ref(:project1_member)
+ end
+
+ with_them do
+ it 'raises an exception' do
+ expect(graphql_errors).to include(a_hash_including('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action"))
+ end
+ end
+ end
+ end
+ end
end
context 'when the user does not have access to the iteration' do
@@ -65,7 +169,7 @@
end
before_all do
- group.add_guest(current_user)
+ group.add_guest(group_member)
end
specify do
diff --git a/ee/spec/services/timebox_report_service_spec.rb b/ee/spec/services/timebox_report_service_spec.rb
index 314932947cf47d879115c4e4f67f133ec0ab8c4a..3746e32fe9b81ccb7fc46c299c87b8b8529ae1e0 100644
--- a/ee/spec/services/timebox_report_service_spec.rb
+++ b/ee/spec/services/timebox_report_service_spec.rb
@@ -311,16 +311,85 @@
end
end
end
+
+ context 'with scoped_projects' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:other_project) { create(:project, group: group) }
+ let_it_be(:subgroup_project) { create(:project, group: subgroup) }
+ let_it_be(:other_project_issue) { create(:issue, project: other_project) }
+ let_it_be(:subgroup_project_issue) { create(:issue, project: subgroup_project) }
+
+ before_all do
+ created_at = timebox_start_date - 14.days
+
+ create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :add, created_at: created_at)
+ create(:resource_weight_event, issue: issues[0], weight: 1, created_at: created_at)
+
+ create(:"resource_#{timebox_type}_event", issue: issues[1], "#{timebox_type}" => timebox, action: :add, created_at: created_at)
+ create(:resource_weight_event, issue: issues[1], weight: 1, created_at: created_at)
+
+ create(:"resource_#{timebox_type}_event", issue: other_project_issue, "#{timebox_type}" => timebox, action: :add, created_at: created_at)
+ create(:resource_weight_event, issue: other_project_issue, weight: 2, created_at: created_at)
+
+ create(:"resource_#{timebox_type}_event", issue: subgroup_project_issue, "#{timebox_type}" => timebox, action: :add, created_at: created_at)
+ create(:resource_weight_event, issue: subgroup_project_issue, weight: 3, created_at: created_at)
+ end
+
+ context 'scoped_projects is blank' do
+ where(:scoped_projects) do
+ [[[]], [Project.none]]
+ end
+
+ with_them do
+ it 'returns an empty response' do
+ expect(response.success?).to eq(true)
+ expect(response.payload[:stats]).to eq(nil)
+ expect(response.payload[:burnup_time_series]).to eq([])
+ end
+ end
+ end
+
+ where(:scoped_projects, :expected_count, :expected_weight) do
+ lazy { [project] } | 2 | 2
+ lazy { [other_project] } | 1 | 2
+ lazy { [subgroup_project] } | 1 | 3
+ lazy { [project, other_project, subgroup_project] } | 4 | 7
+ end
+
+ with_them do
+ it "aggregates events scoped to the given projects" do
+ expect(response.success?).to eq(true)
+ expect(response.payload[:stats]).to eq({
+ complete: { count: 0, weight: 0 },
+ incomplete: { count: expected_count, weight: expected_weight },
+ total: { count: expected_count, weight: expected_weight }
+ })
+
+ expect(response.payload[:burnup_time_series]).to eq([
+ {
+ date: timebox_start_date,
+ scope_count: expected_count,
+ scope_weight: expected_weight,
+ completed_count: 0,
+ completed_weight: 0
+ }
+ ])
+ end
+ end
+ end
end
end
-RSpec.describe TimeboxReportService do
+RSpec.describe TimeboxReportService, :aggregate_failures do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:timebox_start_date) { Date.today }
let_it_be(:timebox_end_date) { timebox_start_date + 2.weeks }
- let(:response) { described_class.new(timebox).execute }
+ let(:scoped_projects) { group.projects }
+ let(:response) { described_class.new(timebox, scoped_projects).execute }
context 'milestone charts' do
let_it_be(:timebox, reload: true) { create(:milestone, project: project, start_date: timebox_start_date, due_date: timebox_end_date) }
diff --git a/spec/support/shared_examples/models/resource_event_shared_examples.rb b/spec/support/shared_examples/models/resource_event_shared_examples.rb
index c0158f9b24ba8ac0b2932c2f7132250a3f8706ec..80806ee768ad3a693d2d68f7ac64bb477db30acc 100644
--- a/spec/support/shared_examples/models/resource_event_shared_examples.rb
+++ b/spec/support/shared_examples/models/resource_event_shared_examples.rb
@@ -62,15 +62,15 @@
let_it_be(:issue2) { create(:issue, author: user1) }
let_it_be(:issue3) { create(:issue, author: user2) }
+ let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) }
+ let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) }
+ let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) }
+
describe 'associations' do
it { is_expected.to belong_to(:issue) }
end
describe '.by_issue' do
- let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) }
- let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) }
- let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) }
-
it 'returns the expected records for an issue with events' do
events = described_class.by_issue(issue1)
@@ -84,21 +84,29 @@
end
end
- describe '.by_issue_ids_and_created_at_earlier_or_equal_to' do
+ describe '.by_issue_ids' do
+ it 'returns the expected events' do
+ events = described_class.by_issue_ids([issue1.id])
+
+ expect(events).to contain_exactly(event1, event3)
+ end
+ end
+
+ describe '.by_created_at_earlier_or_equal_to' do
let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') }
let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') }
let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') }
- it 'returns the expected records for an issue with events' do
- events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to([issue1.id, issue2.id], '2020-03-11 23:59:59')
+ it 'returns the expected events' do
+ events = described_class.by_created_at_earlier_or_equal_to('2020-03-11 23:59:59')
expect(events).to contain_exactly(event1, event2)
end
- it 'returns the expected records for an issue with no events' do
- events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12')
+ it 'returns the expected events' do
+ events = described_class.by_created_at_earlier_or_equal_to('2020-03-12')
- expect(events).to be_empty
+ expect(events).to contain_exactly(event1, event2, event3)
end
end