Commit 9022a33d authored by Igor Drozdov's avatar Igor Drozdov

Merge branch 'milestone-burnup-charts-graphql' into 'master'

Add milestone burnup charts to GraphQL

See merge request gitlab-org/gitlab!38672
parents 9c95852d 66143fd6
...@@ -60,3 +60,5 @@ module Types ...@@ -60,3 +60,5 @@ module Types
end end
end end
end end
Types::MilestoneType.prepend_if_ee('::EE::Types::MilestoneType')
...@@ -1427,6 +1427,36 @@ type Branch { ...@@ -1427,6 +1427,36 @@ type Branch {
name: String! name: String!
} }
"""
Represents the total number of issues and their weights for a particular day.
"""
type BurnupChartDailyTotals {
"""
Number of closed issues as of this day
"""
completedCount: Int!
"""
Total weight of closed issues as of this day
"""
completedWeight: Int!
"""
Date for burnup totals
"""
date: ISO8601Date!
"""
Number of issues as of this day
"""
scopeCount: Int!
"""
Total weight of issues as of this day
"""
scopeWeight: Int!
}
type CiGroup { type CiGroup {
""" """
Jobs in group Jobs in group
...@@ -9549,6 +9579,11 @@ type MetricsDashboardAnnotationEdge { ...@@ -9549,6 +9579,11 @@ type MetricsDashboardAnnotationEdge {
Represents a milestone. Represents a milestone.
""" """
type Milestone { type Milestone {
"""
Daily scope and completed totals for burnup charts
"""
burnupTimeSeries: [BurnupChartDailyTotals!]
""" """
Timestamp of milestone creation Timestamp of milestone creation
""" """
......
...@@ -3851,6 +3851,109 @@ ...@@ -3851,6 +3851,109 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "BurnupChartDailyTotals",
"description": "Represents the total number of issues and their weights for a particular day.",
"fields": [
{
"name": "completedCount",
"description": "Number of closed issues as of this day",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "completedWeight",
"description": "Total weight of closed issues as of this day",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "date",
"description": "Date for burnup totals",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ISO8601Date",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopeCount",
"description": "Number of issues as of this day",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "scopeWeight",
"description": "Total weight of issues as of this day",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "CiGroup", "name": "CiGroup",
...@@ -26709,6 +26812,28 @@ ...@@ -26709,6 +26812,28 @@
"name": "Milestone", "name": "Milestone",
"description": "Represents a milestone.", "description": "Represents a milestone.",
"fields": [ "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
},
{ {
"name": "createdAt", "name": "createdAt",
"description": "Timestamp of milestone creation", "description": "Timestamp of milestone creation",
...@@ -245,6 +245,18 @@ Autogenerated return type of BoardListUpdateLimitMetrics ...@@ -245,6 +245,18 @@ Autogenerated return type of BoardListUpdateLimitMetrics
| `commit` | Commit | Commit for the branch | | `commit` | Commit | Commit for the branch |
| `name` | String! | Name of the branch | | `name` | String! | Name of the branch |
## BurnupChartDailyTotals
Represents the total number of issues and their weights for a particular day.
| Name | Type | Description |
| --- | ---- | ---------- |
| `completedCount` | Int! | Number of closed issues as of this day |
| `completedWeight` | Int! | Total weight of closed issues as of this day |
| `date` | ISO8601Date! | Date for burnup totals |
| `scopeCount` | Int! | Number of issues as of this day |
| `scopeWeight` | Int! | Total weight of issues as of this day |
## CiGroup ## CiGroup
| Name | Type | Description | | Name | Type | Description |
...@@ -1477,6 +1489,7 @@ Represents a milestone. ...@@ -1477,6 +1489,7 @@ Represents a milestone.
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `burnupTimeSeries` | BurnupChartDailyTotals! => Array | Daily scope and completed totals for burnup charts |
| `createdAt` | Time! | Timestamp of milestone creation | | `createdAt` | Time! | Timestamp of milestone creation |
| `description` | String | Description of the milestone | | `description` | String | Description of the milestone |
| `dueDate` | Time | Timestamp of the milestone due date | | `dueDate` | Time | Timestamp of the milestone due date |
......
# frozen_string_literal: true
module EE
module Types
module MilestoneType
extend ActiveSupport::Concern
prepended do
field :burnup_time_series, [::Types::BurnupChartDailyTotalsType], null: true,
resolver: ::Resolvers::MilestoneBurnupTimeSeriesResolver,
description: 'Daily scope and completed totals for burnup charts',
complexity: 175
end
end
end
end
# frozen_string_literal: true
module Resolvers
class MilestoneBurnupTimeSeriesResolver < BaseResolver
type [Types::BurnupChartDailyTotalsType], null: true
alias_method :milestone, :synchronized_object
def resolve(*args)
return [] unless milestone.burnup_charts_available?
response = Milestones::BurnupChartService.new(milestone).execute
raise GraphQL::ExecutionError, response.message if response.error?
response.payload
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class BurnupChartDailyTotalsType < BaseObject
graphql_name 'BurnupChartDailyTotals'
description 'Represents the total number of issues and their weights for a particular day.'
field :date, GraphQL::Types::ISO8601Date, null: false,
description: 'Date for burnup totals'
field :scope_count, GraphQL::INT_TYPE, null: false,
description: 'Number of issues as of this day'
field :scope_weight, GraphQL::INT_TYPE, null: false,
description: 'Total weight of issues as of this day'
field :completed_count, GraphQL::INT_TYPE, null: false,
description: 'Number of closed issues as of this day'
field :completed_weight, GraphQL::INT_TYPE, null: false,
description: 'Total weight of closed issues as of this day'
end
end
...@@ -20,6 +20,10 @@ module EE ...@@ -20,6 +20,10 @@ module EE
resource_parent&.feature_available?(feature_name) && supports_weight? resource_parent&.feature_available?(feature_name) && supports_weight?
end end
def supports_burnup_charts?
resource_parent&.feature_available?(:milestone_charts) && supports_weight?
end
def burnup_charts_available? def burnup_charts_available?
::Feature.enabled?(:burnup_charts, resource_parent) ::Feature.enabled?(:burnup_charts, resource_parent)
end end
......
...@@ -28,6 +28,7 @@ class License < ApplicationRecord ...@@ -28,6 +28,7 @@ class License < ApplicationRecord
ldap_group_sync ldap_group_sync
member_lock member_lock
merge_request_approvers merge_request_approvers
milestone_charts
multiple_issue_assignees multiple_issue_assignees
multiple_ldap_servers multiple_ldap_servers
multiple_merge_request_assignees multiple_merge_request_assignees
......
...@@ -12,20 +12,18 @@ class Milestones::BurnupChartService ...@@ -12,20 +12,18 @@ class Milestones::BurnupChartService
EVENT_COUNT_LIMIT = 50_000 EVENT_COUNT_LIMIT = 50_000
TooManyEventsError = Class.new(StandardError)
def initialize(milestone) def initialize(milestone)
raise ArgumentError, 'Milestone must have a start and due date' if milestone.start_date.blank? || milestone.due_date.blank?
@milestone = milestone @milestone = milestone
end end
def execute def execute
return ServiceResponse.error(message: _('Milestone does not support burnup charts')) unless milestone.supports_burnup_charts?
return ServiceResponse.error(message: _('Milestone must have a start and due date')) if milestone.start_date.blank? || milestone.due_date.blank?
return ServiceResponse.error(message: _('Burnup chart could not be generated due to too many events')) if resource_events.num_tuples > EVENT_COUNT_LIMIT
@issue_states = {} @issue_states = {}
@chart_data = [] @chart_data = []
raise TooManyEventsError if resource_events.num_tuples > EVENT_COUNT_LIMIT
resource_events.each do |event| resource_events.each do |event|
case event['event_type'] case event['event_type']
when 'milestone' when 'milestone'
...@@ -37,7 +35,7 @@ class Milestones::BurnupChartService ...@@ -37,7 +35,7 @@ class Milestones::BurnupChartService
end end
end end
chart_data ServiceResponse.success(payload: chart_data)
end end
private private
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['Milestone'] do
it { expect(described_class).to have_graphql_field(:burnup_time_series) }
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::MilestoneBurnupTimeSeriesResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:milestone) { create(:milestone, project: project, start_date: '2020-01-01', due_date: '2020-01-15') }
let_it_be(:issues) { create_list(:issue, 2, project: project) }
before_all do
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2020-01-05')
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2020-01-10')
end
before do
stub_licensed_features(milestone_charts: true, issue_weights: true)
end
subject { resolve(described_class, obj: milestone) }
context 'when the feature flag is disabled' do
before do
stub_feature_flags(burnup_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)
end
it 'returns burnup chart data' do
expect(subject).to eq([
{
date: Date.parse('2020-01-05'),
scope_count: 1,
scope_weight: 0,
completed_count: 0,
completed_weight: 0
},
{
date: Date.parse('2020-01-10'),
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('Milestones::BurnupChartService::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
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['BurnupChartDailyTotals'] do
it { expect(described_class.graphql_name).to eq('BurnupChartDailyTotals') }
it 'has specific fields' do
expect(described_class).to have_graphql_fields(
:date,
:scope_count,
:scope_weight,
:completed_count,
:completed_weight
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Querying a Milestone' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:milestone) { create(:milestone, project: project, start_date: '2020-01-01', due_date: '2020-01-15') }
let(:query) do
graphql_query_for('milestone', { id: milestone.to_global_id.to_s }, fields)
end
subject { graphql_data['milestone'] }
before_all do
project.add_guest(current_user)
end
context 'burnupTimeSeries' do
let(:fields) do
<<~FIELDS
burnupTimeSeries {
date
scopeCount
scopeWeight
completedCount
completedWeight
}
FIELDS
end
let_it_be(:issue) { create(:issue, project: project) }
before_all do
create(:resource_milestone_event, issue: issue, milestone: milestone, action: :add, created_at: '2020-01-05')
end
context 'when feature flag is enabled' do
before do
stub_feature_flags(burnup_charts: true)
end
context 'with insufficient license' do
before do
stub_licensed_features(milestone_charts: false)
end
it 'returns an error' do
post_graphql(query, current_user: current_user)
expect(graphql_errors).to include(a_hash_including('message' => 'Milestone does not support burnup charts'))
end
end
context 'with correct license' do
before do
stub_licensed_features(milestone_charts: true, issue_weights: true)
end
it 'returns burnup chart data' do
post_graphql(query, current_user: current_user)
expect(subject).to eq({
'burnupTimeSeries' => [
{
'date' => '2020-01-05',
'scopeCount' => 1,
'scopeWeight' => 0,
'completedCount' => 0,
'completedWeight' => 0
}
]
})
end
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(burnup_charts: false)
stub_licensed_features(milestone_charts: true, issue_weights: true)
end
it 'returns empty results' do
post_graphql(query, current_user: current_user)
expect(subject).to eq({ 'burnupTimeSeries' => [] })
end
end
end
end
...@@ -4,25 +4,45 @@ require 'spec_helper' ...@@ -4,25 +4,45 @@ require 'spec_helper'
RSpec.describe Milestones::BurnupChartService do RSpec.describe Milestones::BurnupChartService do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:milestone) { create(:milestone, project: project, start_date: '2020-01-01', due_date: '2020-01-15') } let_it_be(:milestone, reload: true) { create(:milestone, project: project, start_date: '2020-01-01', due_date: '2020-01-15') }
let_it_be(:issues) { create_list(:issue, 5, project: project) } let_it_be(:issues) { create_list(:issue, 5, project: project) }
let(:chart_data) { described_class.new(milestone).execute } let(:response) { described_class.new(milestone).execute }
it 'raises an error when milestone does not have a start and due date' do context 'when license is not available' do
milestone = build(:milestone, project: project) before do
stub_licensed_features(milestone_charts: false)
end
expect { described_class.new(milestone) }.to raise_error('Milestone must have a start and due date') it 'returns an error message' do
expect(response.error?).to eq(true)
expect(response.message).to eq('Milestone does not support burnup charts')
end
end end
it 'raises an error when the number of events exceeds the limit' do context 'when license is available' do
before do
stub_licensed_features(milestone_charts: true, issue_weights: true)
end
context 'when milestone does not have a start and due date' do
let(:milestone) { build(:milestone, project: project) }
it 'returns an error message' do
expect(response.error?).to eq(true)
expect(response.message).to eq('Milestone must have a start and due date')
end
end
it 'returns an error when the number of events exceeds the limit' do
stub_const('Milestones::BurnupChartService::EVENT_COUNT_LIMIT', 1) stub_const('Milestones::BurnupChartService::EVENT_COUNT_LIMIT', 1)
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2019-12-15') create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :add, created_at: '2019-12-15')
create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2019-12-16') create(:resource_milestone_event, issue: issues[1], milestone: milestone, action: :add, created_at: '2019-12-16')
expect { chart_data }.to raise_error(described_class::TooManyEventsError) expect(response.error?).to eq(true)
expect(response.message).to eq('Burnup chart could not be generated due to too many events')
end end
it 'aggregates events before the start date to the start date' do it 'aggregates events before the start date to the start date' do
...@@ -40,7 +60,8 @@ RSpec.describe Milestones::BurnupChartService do ...@@ -40,7 +60,8 @@ RSpec.describe Milestones::BurnupChartService do
create(:resource_weight_event, issue: issues[3], weight: 4, created_at: '2019-12-18') create(:resource_weight_event, issue: issues[3], weight: 4, created_at: '2019-12-18')
create(:resource_state_event, issue: issues[3], state: :closed, created_at: '2019-12-26') create(:resource_state_event, issue: issues[3], state: :closed, created_at: '2019-12-26')
expect(chart_data).to eq([ expect(response.success?).to eq(true)
expect(response.payload).to eq([
{ {
date: Date.parse('2020-01-01'), date: Date.parse('2020-01-01'),
scope_count: 4, scope_count: 4,
...@@ -81,7 +102,8 @@ RSpec.describe Milestones::BurnupChartService do ...@@ -81,7 +102,8 @@ RSpec.describe Milestones::BurnupChartService do
# Removing the milestone after the due date should not affect the data. # Removing the milestone after the due date should not affect the data.
create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :remove, created_at: '2020-01-30') create(:resource_milestone_event, issue: issues[0], milestone: milestone, action: :remove, created_at: '2020-01-30')
expect(chart_data).to eq([ expect(response.success?).to eq(true)
expect(response.payload).to eq([
{ {
date: Date.parse('2020-01-05'), date: Date.parse('2020-01-05'),
scope_count: 2, scope_count: 2,
...@@ -141,7 +163,8 @@ RSpec.describe Milestones::BurnupChartService do ...@@ -141,7 +163,8 @@ RSpec.describe Milestones::BurnupChartService do
create(:resource_milestone_event, issue: issues[1], action: :remove, created_at: '2020-01-09') create(:resource_milestone_event, issue: issues[1], action: :remove, created_at: '2020-01-09')
create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-10') create(:resource_state_event, issue: issues[1], state: :closed, created_at: '2020-01-10')
expect(chart_data).to eq([ expect(response.success?).to eq(true)
expect(response.payload).to eq([
{ {
date: Date.parse('2020-01-01'), date: Date.parse('2020-01-01'),
scope_count: 1, scope_count: 1,
...@@ -211,7 +234,8 @@ RSpec.describe Milestones::BurnupChartService do ...@@ -211,7 +234,8 @@ RSpec.describe Milestones::BurnupChartService do
create(:resource_milestone_event, issue: issues[0], milestone: create(:milestone, project: project), action: :add, created_at: '2020-01-05') create(:resource_milestone_event, issue: issues[0], milestone: create(:milestone, project: project), action: :add, created_at: '2020-01-05')
create(:resource_weight_event, issue: issues[0], weight: 10, created_at: '2020-01-06') create(:resource_weight_event, issue: issues[0], weight: 10, created_at: '2020-01-06')
expect(chart_data).to eq([ expect(response.success?).to eq(true)
expect(response.payload).to eq([
{ {
date: Date.parse('2020-01-01'), date: Date.parse('2020-01-01'),
scope_count: 1, scope_count: 1,
...@@ -249,4 +273,5 @@ RSpec.describe Milestones::BurnupChartService do ...@@ -249,4 +273,5 @@ RSpec.describe Milestones::BurnupChartService do
} }
]) ])
end end
end
end end
...@@ -4190,6 +4190,9 @@ msgstr "" ...@@ -4190,6 +4190,9 @@ msgstr ""
msgid "Burnup chart" msgid "Burnup chart"
msgstr "" msgstr ""
msgid "Burnup chart could not be generated due to too many events"
msgstr ""
msgid "Business" msgid "Business"
msgstr "" msgstr ""
...@@ -15705,12 +15708,18 @@ msgid_plural "Milestones" ...@@ -15705,12 +15708,18 @@ msgid_plural "Milestones"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Milestone does not support burnup charts"
msgstr ""
msgid "Milestone lists not available with your current license" msgid "Milestone lists not available with your current license"
msgstr "" msgstr ""
msgid "Milestone lists show all issues from the selected milestone." msgid "Milestone lists show all issues from the selected milestone."
msgstr "" msgstr ""
msgid "Milestone must have a start and due date"
msgstr ""
msgid "MilestoneSidebar|Closed:" msgid "MilestoneSidebar|Closed:"
msgstr "" msgstr ""
......
...@@ -15,7 +15,7 @@ RSpec.describe GitlabSchema.types['Milestone'] do ...@@ -15,7 +15,7 @@ RSpec.describe GitlabSchema.types['Milestone'] do
stats stats
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields).at_least
end end
describe 'stats field' do describe 'stats field' 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