Commit 8c801481 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Generalize burnup chart service for other metrics

Renames TimeboxBurnupChartService to TimeboxReportService since this
will also be used to compute other statistics.

This also moves the burnup time series in GraphQL under a `report`
field.
parent b1719cb4
---
title: Add GraphQL burnup endpoint under milestone and iteration reports
merge_request: 45121
author:
type: added
...@@ -10818,7 +10818,7 @@ enum IssueType { ...@@ -10818,7 +10818,7 @@ enum IssueType {
""" """
Represents an iteration object Represents an iteration object
""" """
type Iteration implements TimeboxBurnupTimeSeriesInterface { type Iteration implements TimeboxReportInterface {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
...@@ -10854,6 +10854,11 @@ type Iteration implements TimeboxBurnupTimeSeriesInterface { ...@@ -10854,6 +10854,11 @@ type Iteration implements TimeboxBurnupTimeSeriesInterface {
""" """
iid: ID! iid: ID!
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
""" """
Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts
""" """
...@@ -12831,7 +12836,7 @@ type MetricsDashboardAnnotationEdge { ...@@ -12831,7 +12836,7 @@ type MetricsDashboardAnnotationEdge {
""" """
Represents a milestone Represents a milestone
""" """
type Milestone implements TimeboxBurnupTimeSeriesInterface { type Milestone implements TimeboxReportInterface {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
...@@ -12867,6 +12872,11 @@ type Milestone implements TimeboxBurnupTimeSeriesInterface { ...@@ -12867,6 +12872,11 @@ type Milestone implements TimeboxBurnupTimeSeriesInterface {
""" """
projectMilestone: Boolean! projectMilestone: Boolean!
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
""" """
Timestamp of the milestone start date Timestamp of the milestone start date
""" """
...@@ -20155,13 +20165,28 @@ Time represented in ISO 8601 ...@@ -20155,13 +20165,28 @@ Time represented in ISO 8601
""" """
scalar Time scalar Time
interface TimeboxBurnupTimeSeriesInterface { """
Represents a historically accurate report about the timebox
"""
type TimeboxReport {
""" """
Daily scope and completed totals for burnup charts Daily scope and completed totals for burnup charts
""" """
burnupTimeSeries: [BurnupChartDailyTotals!] burnupTimeSeries: [BurnupChartDailyTotals!]
} }
interface TimeboxReportInterface {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
"""
Historically accurate report about the timebox
"""
report: TimeboxReport
}
""" """
A time-frame defined as a closed inclusive range of two dates A time-frame defined as a closed inclusive range of two dates
""" """
......
...@@ -29533,6 +29533,20 @@ ...@@ -29533,6 +29533,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "scopedPath", "name": "scopedPath",
"description": "Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts", "description": "Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts",
...@@ -29670,7 +29684,7 @@ ...@@ -29670,7 +29684,7 @@
"interfaces": [ "interfaces": [
{ {
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"ofType": null "ofType": null
} }
], ],
...@@ -35318,6 +35332,20 @@ ...@@ -35318,6 +35332,20 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "startDate", "name": "startDate",
"description": "Timestamp of the milestone start date", "description": "Timestamp of the milestone start date",
...@@ -35441,7 +35469,7 @@ ...@@ -35441,7 +35469,7 @@
"interfaces": [ "interfaces": [
{ {
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"ofType": null "ofType": null
} }
], ],
...@@ -58503,9 +58531,44 @@ ...@@ -58503,9 +58531,44 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "TimeboxReport",
"description": "Represents a historically accurate report about the timebox",
"fields": [
{
"name": "burnupTimeSeries",
"description": "Daily scope and completed totals for burnup charts",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "INTERFACE", "kind": "INTERFACE",
"name": "TimeboxBurnupTimeSeriesInterface", "name": "TimeboxReportInterface",
"description": null, "description": null,
"fields": [ "fields": [
{ {
...@@ -58529,6 +58592,20 @@ ...@@ -58529,6 +58592,20 @@
}, },
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "report",
"description": "Historically accurate report about the timebox",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "TimeboxReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"inputFields": null, "inputFields": null,
...@@ -1652,6 +1652,7 @@ Represents an iteration object. ...@@ -1652,6 +1652,7 @@ Represents an iteration object.
| `dueDate` | Time | Timestamp of the iteration due date | | `dueDate` | Time | Timestamp of the iteration due date |
| `id` | ID! | ID of the iteration | | `id` | ID! | ID of the iteration |
| `iid` | ID! | Internal ID of the iteration | | `iid` | ID! | Internal ID of the iteration |
| `report` | TimeboxReport | Historically accurate report about the timebox |
| `scopedPath` | String | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts | | `scopedPath` | String | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts |
| `scopedUrl` | String | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts | | `scopedUrl` | String | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts |
| `startDate` | Time | Timestamp of the iteration start date | | `startDate` | Time | Timestamp of the iteration start date |
...@@ -1964,6 +1965,7 @@ Represents a milestone. ...@@ -1964,6 +1965,7 @@ Represents a milestone.
| `groupMilestone` | Boolean! | Indicates if milestone is at group level | | `groupMilestone` | Boolean! | Indicates if milestone is at group level |
| `id` | ID! | ID of the milestone | | `id` | ID! | ID of the milestone |
| `projectMilestone` | Boolean! | Indicates if milestone is at project level | | `projectMilestone` | Boolean! | Indicates if milestone is at project level |
| `report` | TimeboxReport | Historically accurate report about the timebox |
| `startDate` | Time | Timestamp of the milestone start date | | `startDate` | Time | Timestamp of the milestone start date |
| `state` | MilestoneStateEnum! | State of the milestone | | `state` | MilestoneStateEnum! | State of the milestone |
| `stats` | MilestoneStats | Milestone statistics | | `stats` | MilestoneStats | Milestone statistics |
...@@ -2958,6 +2960,14 @@ Represents a requirement test report. ...@@ -2958,6 +2960,14 @@ Represents a requirement test report.
| `id` | ID! | ID of the test report | | `id` | ID! | ID of the test report |
| `state` | TestReportState! | State of the test report | | `state` | TestReportState! | State of the test report |
### TimeboxReport
Represents a historically accurate report about the timebox.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `burnupTimeSeries` | BurnupChartDailyTotals! => Array | Daily scope and completed totals for burnup charts |
### Timelog ### Timelog
| Field | Type | Description | | Field | Type | Description |
......
...@@ -6,7 +6,7 @@ module EE ...@@ -6,7 +6,7 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
implements ::Types::TimeboxBurnupTimeSeriesInterface implements ::Types::TimeboxReportInterface
end end
end end
end end
......
...@@ -9,11 +9,11 @@ module Resolvers ...@@ -9,11 +9,11 @@ module Resolvers
def resolve(*args) def resolve(*args)
return [] unless timebox.burnup_charts_available? return [] unless timebox.burnup_charts_available?
response = TimeboxBurnupChartService.new(timebox).execute response = TimeboxReportService.new(timebox).execute
raise GraphQL::ExecutionError, response.message if response.error? raise GraphQL::ExecutionError, response.message if response.error?
response.payload response.payload[:burnup_time_series]
end end
end end
end end
# frozen_string_literal: true
module Resolvers
class TimeboxReportResolver < BaseResolver
type Types::TimeboxReportType, null: true
alias_method :timebox, :synchronized_object
def resolve(*args)
return {} unless timebox.burnup_charts_available?
response = TimeboxReportService.new(timebox).execute
raise GraphQL::ExecutionError, response.message if response.error?
response.payload
end
end
end
...@@ -9,7 +9,7 @@ module Types ...@@ -9,7 +9,7 @@ module Types
authorize :read_iteration authorize :read_iteration
implements ::Types::TimeboxBurnupTimeSeriesInterface implements ::Types::TimeboxReportInterface
field :id, GraphQL::ID_TYPE, null: false, field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the iteration' description: 'ID of the iteration'
......
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module TimeboxBurnupTimeSeriesInterface module TimeboxReportInterface
include BaseInterface include BaseInterface
field :report, Types::TimeboxReportType, null: true,
resolver: ::Resolvers::TimeboxReportResolver,
description: 'Historically accurate report about the timebox',
complexity: 175
field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true, field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
resolver: ::Resolvers::TimeboxBurnupTimeSeriesResolver, resolver: ::Resolvers::TimeboxBurnupTimeSeriesResolver,
description: 'Daily scope and completed totals for burnup charts', description: 'Daily scope and completed totals for burnup charts',
......
# frozen_string_literal: true
# rubocop: disable Graphql/AuthorizeTypes
module Types
class TimeboxReportType < BaseObject
graphql_name 'TimeboxReport'
description 'Represents a historically accurate report about the timebox'
field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
description: 'Daily scope and completed totals for burnup charts'
end
end
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
# This is implemented by iterating over all relevant resource events ordered by time. We need to do this # This is implemented by iterating over all relevant resource events ordered by time. We need to do this
# so that we can keep track of the issue's state during that point in time and handle the events based on that. # so that we can keep track of the issue's state during that point in time and handle the events based on that.
class TimeboxBurnupChartService class TimeboxReportService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
EVENT_COUNT_LIMIT = 50_000 EVENT_COUNT_LIMIT = 50_000
...@@ -35,7 +35,9 @@ class TimeboxBurnupChartService ...@@ -35,7 +35,9 @@ class TimeboxBurnupChartService
end end
end end
ServiceResponse.success(payload: chart_data) ServiceResponse.success(payload: {
burnup_time_series: chart_data
})
end end
private private
......
...@@ -3,5 +3,11 @@ ...@@ -3,5 +3,11 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe GitlabSchema.types['Milestone'] do RSpec.describe GitlabSchema.types['Milestone'] do
it { expect(described_class).to have_graphql_field(:burnup_time_series) } it 'has the expected fields' do
expected_fields = %w[
report burnup_time_series
]
expect(described_class).to have_graphql_fields(*expected_fields).at_least
end
end end
...@@ -54,7 +54,7 @@ RSpec.describe Resolvers::TimeboxBurnupTimeSeriesResolver do ...@@ -54,7 +54,7 @@ RSpec.describe Resolvers::TimeboxBurnupTimeSeriesResolver do
context 'when the service returns an error' do context 'when the service returns an error' do
before do before do
stub_const('TimeboxBurnupChartService::EVENT_COUNT_LIMIT', 1) stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
end end
it 'raises a GraphQL exception' do it 'raises a GraphQL exception' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::TimeboxReportResolver do
include GraphqlHelpers
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
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 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) }
context 'when the feature flag is disabled' do
before do
stub_feature_flags(burnup_charts: false, iteration_charts: false)
end
it 'returns empty data' do
expect(subject).to be_empty
end
end
context 'when the feature flag is enabled' do
before do
stub_feature_flags(burnup_charts: true, iteration_charts: true)
end
it 'returns burnup chart data' do
expect(subject).to eq(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
end
context 'when timebox is a milestone' do
let_it_be(:timebox) { create(:milestone, project: project, start_date: start_date, due_date: due_date) }
before_all do
create(:resource_milestone_event, issue: issues[0], milestone: timebox, action: :add, created_at: start_date + 4.days)
create(:resource_milestone_event, issue: issues[1], milestone: timebox, action: :add, created_at: start_date + 9.days)
end
it_behaves_like 'timebox time series'
end
context 'when timebox is an iteration' do
let_it_be(:timebox) { create(:iteration, group: group, start_date: start_date, due_date: due_date) }
before_all do
create(:resource_iteration_event, issue: issues[0], iteration: timebox, action: :add, created_at: start_date + 4.days)
create(:resource_iteration_event, issue: issues[1], iteration: timebox, action: :add, created_at: start_date + 9.days)
end
it_behaves_like 'timebox time series'
end
end
...@@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Iteration'] do ...@@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Iteration'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
id id title description state web_path web_url scoped_path scoped_url id id title description state web_path web_url scoped_path scoped_url
due_date start_date created_at updated_at burnup_time_series due_date start_date created_at updated_at report burnup_time_series
] ]
expect(described_class).to have_graphql_fields(*expected_fields).at_least expect(described_class).to have_graphql_fields(*expected_fields).at_least
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['TimeboxReport'] do
it { expect(described_class.graphql_name).to eq('TimeboxReport') }
it { expect(described_class).to have_graphql_field(:burnup_time_series) }
end
...@@ -31,7 +31,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -31,7 +31,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end end
it 'returns an error when the number of events exceeds the limit' do it 'returns an error when the number of events exceeds the limit' do
stub_const('TimeboxBurnupChartService::EVENT_COUNT_LIMIT', 1) stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 21.days) create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 21.days)
create(:"resource_#{timebox_type}_event", issue: issues[1], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 20.days) create(:"resource_#{timebox_type}_event", issue: issues[1], "#{timebox_type}" => timebox, action: :add, created_at: timebox_start_date - 20.days)
...@@ -56,7 +56,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -56,7 +56,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_state_event, issue: issues[3], state: :closed, created_at: timebox_start_date - 6.days) create(:resource_state_event, issue: issues[3], state: :closed, created_at: timebox_start_date - 6.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 4, scope_count: 4,
...@@ -98,7 +98,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -98,7 +98,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :remove, created_at: timebox_start_date + 21.days) create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :remove, created_at: timebox_start_date + 21.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date + 4.days, date: timebox_start_date + 4.days,
scope_count: 2, scope_count: 2,
...@@ -159,7 +159,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -159,7 +159,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_state_event, issue: issues[1], state: :closed, created_at: timebox_start_date + 9.days) create(:resource_state_event, issue: issues[1], state: :closed, created_at: timebox_start_date + 9.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 1, scope_count: 1,
...@@ -230,7 +230,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -230,7 +230,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
create(:resource_weight_event, issue: issues[0], weight: 10, created_at: timebox_start_date + 5.days) create(:resource_weight_event, issue: issues[0], weight: 10, created_at: timebox_start_date + 5.days)
expect(response.success?).to eq(true) expect(response.success?).to eq(true)
expect(response.payload).to eq([ expect(response.payload[:burnup_time_series]).to eq([
{ {
date: timebox_start_date, date: timebox_start_date,
scope_count: 1, scope_count: 1,
...@@ -271,7 +271,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -271,7 +271,7 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end end
end end
RSpec.describe TimeboxBurnupChartService do RSpec.describe TimeboxReportService do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:timebox_start_date) { Date.today } let_it_be(:timebox_start_date) { Date.today }
......
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