jirametrics 2.9 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +1 -1
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +56 -13
- data/lib/jirametrics/board.rb +38 -10
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +37 -16
- data/lib/jirametrics/chart_base.rb +22 -5
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/cycletime_histogram.rb +65 -2
- data/lib/jirametrics/daily_view.rb +277 -0
- data/lib/jirametrics/data_quality_report.rb +1 -1
- data/lib/jirametrics/downloader.rb +11 -14
- data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/standard_project.rb +2 -0
- data/lib/jirametrics/exporter.rb +10 -8
- data/lib/jirametrics/file_config.rb +10 -5
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +7 -3
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/index.css +82 -2
- data/lib/jirametrics/html/index.erb +25 -1
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +68 -27
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +20 -4
- data/lib/jirametrics/project_config.rb +25 -8
- data/lib/jirametrics/settings.json +2 -1
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +35 -33
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -6
- data/lib/jirametrics/status_collection.rb +6 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +4 -0
- metadata +7 -2
|
@@ -56,16 +56,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
56
56
|
},
|
|
57
57
|
annotation: {
|
|
58
58
|
annotations: {
|
|
59
|
-
|
|
60
|
-
holiday<%= index %>: {
|
|
61
|
-
drawTime: 'beforeDraw',
|
|
62
|
-
type: 'box',
|
|
63
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
64
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
65
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
66
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
67
|
-
},
|
|
68
|
-
<% end %>
|
|
59
|
+
<%= working_days_annotation %>
|
|
69
60
|
}
|
|
70
61
|
}
|
|
71
62
|
}
|
|
@@ -52,16 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
52
52
|
},
|
|
53
53
|
annotation: {
|
|
54
54
|
annotations: {
|
|
55
|
-
|
|
56
|
-
holiday<%= index %>: {
|
|
57
|
-
drawTime: 'beforeDraw',
|
|
58
|
-
type: 'box',
|
|
59
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
60
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
61
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
62
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
63
|
-
},
|
|
64
|
-
<% end %>
|
|
55
|
+
<%= working_days_annotation %>
|
|
65
56
|
}
|
|
66
57
|
}
|
|
67
58
|
}
|
|
@@ -33,6 +33,7 @@ class HtmlReportConfig
|
|
|
33
33
|
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
|
34
34
|
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
|
35
35
|
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
|
36
|
+
define_chart name: 'daily_view', classname: 'DailyView'
|
|
36
37
|
|
|
37
38
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
|
38
39
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
@@ -159,6 +160,7 @@ class HtmlReportConfig
|
|
|
159
160
|
chart.time_range = project_config.time_range
|
|
160
161
|
chart.timezone_offset = timezone_offset
|
|
161
162
|
chart.settings = settings
|
|
163
|
+
chart.users = project_config.users
|
|
162
164
|
|
|
163
165
|
chart.all_boards = project_config.all_boards
|
|
164
166
|
chart.board_id = find_board_id
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -44,12 +44,12 @@ class Issue
|
|
|
44
44
|
def key = @raw['key']
|
|
45
45
|
|
|
46
46
|
def type = @raw['fields']['issuetype']['name']
|
|
47
|
-
|
|
48
47
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
49
48
|
|
|
50
|
-
def
|
|
49
|
+
def priority_name = @raw['fields']['priority']['name']
|
|
50
|
+
def priority_url = @raw['fields']['priority']['iconUrl']
|
|
51
51
|
|
|
52
|
-
def
|
|
52
|
+
def summary = @raw['fields']['summary']
|
|
53
53
|
|
|
54
54
|
def labels = @raw['fields']['labels'] || []
|
|
55
55
|
|
|
@@ -57,6 +57,20 @@ class Issue
|
|
|
57
57
|
|
|
58
58
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
59
59
|
|
|
60
|
+
def status
|
|
61
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
62
|
+
@status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def status= status
|
|
66
|
+
@status = status
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def due_date
|
|
70
|
+
text = @raw['fields']['duedate']
|
|
71
|
+
text.nil? ? nil : Date.parse(text)
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
def url
|
|
61
75
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
62
76
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -129,13 +143,16 @@ class Issue
|
|
|
129
143
|
end
|
|
130
144
|
|
|
131
145
|
def most_recent_status_change
|
|
132
|
-
#
|
|
133
|
-
|
|
146
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
147
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
148
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
149
|
+
status_changes.last
|
|
134
150
|
end
|
|
135
151
|
|
|
136
152
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
137
153
|
def currently_in_status *status_names
|
|
138
154
|
change = most_recent_status_change
|
|
155
|
+
return false if change.nil?
|
|
139
156
|
|
|
140
157
|
change if change.current_status_matches(*status_names)
|
|
141
158
|
end
|
|
@@ -145,6 +162,7 @@ class Issue
|
|
|
145
162
|
category_ids = find_status_category_ids_by_names category_names
|
|
146
163
|
|
|
147
164
|
change = most_recent_status_change
|
|
165
|
+
return false if change.nil?
|
|
148
166
|
|
|
149
167
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
150
168
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -189,6 +207,10 @@ class Issue
|
|
|
189
207
|
nil
|
|
190
208
|
end
|
|
191
209
|
|
|
210
|
+
def first_time_visible_on_board
|
|
211
|
+
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
|
212
|
+
end
|
|
213
|
+
|
|
192
214
|
def parse_time text
|
|
193
215
|
Time.parse(text).getlocal(@timezone_offset)
|
|
194
216
|
end
|
|
@@ -214,6 +236,10 @@ class Issue
|
|
|
214
236
|
@raw['fields']&.[]('assignee')&.[]('displayName')
|
|
215
237
|
end
|
|
216
238
|
|
|
239
|
+
def assigned_to_icon_url
|
|
240
|
+
@raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
|
|
241
|
+
end
|
|
242
|
+
|
|
217
243
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
218
244
|
# on for pages. Shorten it up.
|
|
219
245
|
def inspect
|
|
@@ -299,7 +325,7 @@ class Issue
|
|
|
299
325
|
|
|
300
326
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
301
327
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
302
|
-
mock_change = ChangeItem.new time: end_time,
|
|
328
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
303
329
|
|
|
304
330
|
(changes + [mock_change]).each do |change|
|
|
305
331
|
previous_was_active = false if check_for_stalled(
|
|
@@ -446,8 +472,6 @@ class Issue
|
|
|
446
472
|
end
|
|
447
473
|
|
|
448
474
|
def expedited?
|
|
449
|
-
return false unless @board&.project_config
|
|
450
|
-
|
|
451
475
|
names = @board.project_config.settings['expedited_priority_names']
|
|
452
476
|
return false unless names
|
|
453
477
|
|
|
@@ -564,7 +588,7 @@ class Issue
|
|
|
564
588
|
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
|
565
589
|
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
|
566
590
|
comparison = project_code1 <=> project_code2
|
|
567
|
-
comparison = id1 <=> id2 if comparison.zero?
|
|
591
|
+
comparison = id1.to_i <=> id2.to_i if comparison.zero?
|
|
568
592
|
comparison
|
|
569
593
|
end
|
|
570
594
|
|
|
@@ -595,21 +619,30 @@ class Issue
|
|
|
595
619
|
end
|
|
596
620
|
history = [] # time, type, detail
|
|
597
621
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
622
|
+
if board.cycletime
|
|
623
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
624
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
625
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
626
|
+
else
|
|
627
|
+
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
628
|
+
end
|
|
601
629
|
|
|
602
630
|
@discarded_change_times&.each do |time|
|
|
603
631
|
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
604
632
|
end
|
|
605
633
|
|
|
606
634
|
(changes + (@discarded_changes || [])).each do |change|
|
|
607
|
-
|
|
608
|
-
|
|
635
|
+
if change.status?
|
|
636
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
637
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
638
|
+
else
|
|
639
|
+
value = compact_text(change.value).inspect
|
|
640
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
641
|
+
end
|
|
609
642
|
|
|
610
643
|
message = +''
|
|
611
|
-
message << "#{
|
|
612
|
-
message <<
|
|
644
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
645
|
+
message << value
|
|
613
646
|
if change.artificial?
|
|
614
647
|
message << ' (Artificial entry)' if change.artificial?
|
|
615
648
|
else
|
|
@@ -660,34 +693,40 @@ class Issue
|
|
|
660
693
|
@changes.select { |change| change.status? }
|
|
661
694
|
end
|
|
662
695
|
|
|
663
|
-
|
|
696
|
+
def sprints
|
|
697
|
+
sprint_ids = []
|
|
664
698
|
|
|
665
|
-
|
|
666
|
-
|
|
699
|
+
changes.each do |change|
|
|
700
|
+
next unless change.sprint?
|
|
701
|
+
|
|
702
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
|
703
|
+
end
|
|
704
|
+
sprint_ids.flatten!
|
|
705
|
+
|
|
706
|
+
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
667
707
|
end
|
|
668
708
|
|
|
709
|
+
private
|
|
710
|
+
|
|
669
711
|
def load_history_into_changes
|
|
670
712
|
@raw['changelog']['histories']&.each do |history|
|
|
671
713
|
created = parse_time(history['created'])
|
|
672
714
|
|
|
673
|
-
# It should be impossible to not have an author but we've seen it in production
|
|
674
|
-
author = assemble_author history
|
|
675
715
|
history['items']&.each do |item|
|
|
676
|
-
@changes << ChangeItem.new(raw: item, time: created,
|
|
716
|
+
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
677
717
|
end
|
|
678
718
|
end
|
|
679
719
|
end
|
|
680
720
|
|
|
681
721
|
def load_comments_into_changes
|
|
682
722
|
@raw['fields']['comment']['comments']&.each do |comment|
|
|
683
|
-
raw = {
|
|
723
|
+
raw = comment.merge({
|
|
684
724
|
'field' => 'comment',
|
|
685
725
|
'to' => comment['id'],
|
|
686
726
|
'toString' => comment['body']
|
|
687
|
-
}
|
|
688
|
-
author = assemble_author comment
|
|
727
|
+
})
|
|
689
728
|
created = parse_time(comment['created'])
|
|
690
|
-
@changes << ChangeItem.new(raw: raw, time: created,
|
|
729
|
+
@changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
|
|
691
730
|
end
|
|
692
731
|
end
|
|
693
732
|
|
|
@@ -729,7 +768,9 @@ class Issue
|
|
|
729
768
|
first_status = first_change.old_value
|
|
730
769
|
first_status_id = first_change.old_value_id
|
|
731
770
|
end
|
|
732
|
-
|
|
771
|
+
|
|
772
|
+
creator = raw['fields']['creator']
|
|
773
|
+
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
733
774
|
'field' => field_name,
|
|
734
775
|
'to' => first_status_id,
|
|
735
776
|
'toString' => first_status
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class IssueCollection < Array
|
|
4
|
+
attr_reader :hidden
|
|
5
|
+
|
|
6
|
+
def self.[] *issues
|
|
7
|
+
collection = new
|
|
8
|
+
issues.each { |i| collection << i }
|
|
9
|
+
collection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
super
|
|
14
|
+
@hidden = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reject! &block
|
|
18
|
+
select(&block).each do |issue|
|
|
19
|
+
@hidden << issue
|
|
20
|
+
end
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_by_key key:, include_hidden: false
|
|
25
|
+
block = ->(issue) { issue.key == key }
|
|
26
|
+
issue = find(&block)
|
|
27
|
+
issue = hidden.find(&block) if issue.nil? && include_hidden
|
|
28
|
+
issue
|
|
29
|
+
end
|
|
30
|
+
def clone
|
|
31
|
+
raise 'baboom'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -14,13 +14,22 @@ class JiraGateway
|
|
|
14
14
|
def call_url relative_url:
|
|
15
15
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
16
|
result = call_command command
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
begin
|
|
18
|
+
json = JSON.parse(result)
|
|
19
|
+
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
+
raise "Error when parsing result: #{result.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
24
|
+
|
|
25
|
+
json
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def call_command command
|
|
23
|
-
|
|
29
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
30
|
+
log_entry = log_entry.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
|
|
31
|
+
@file_system.log log_entry
|
|
32
|
+
|
|
24
33
|
result = `#{command}`
|
|
25
34
|
@file_system.log result unless $CHILD_STATUS.success?
|
|
26
35
|
return result if $CHILD_STATUS.success?
|
|
@@ -61,4 +70,11 @@ class JiraGateway
|
|
|
61
70
|
command << " --url \"#{url}\""
|
|
62
71
|
command
|
|
63
72
|
end
|
|
73
|
+
|
|
74
|
+
def json_successful? json
|
|
75
|
+
return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
|
|
76
|
+
return false if json.is_a?(Array) && json.first == 'errorMessage'
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
64
80
|
end
|
|
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
|
|
|
6
6
|
class ProjectConfig
|
|
7
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
8
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
9
|
-
:settings, :aggregate_config, :discarded_changes_data
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data, :users
|
|
10
10
|
attr_accessor :time_range, :jira_url, :id
|
|
11
11
|
|
|
12
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -40,6 +40,7 @@ class ProjectConfig
|
|
|
40
40
|
@id = guess_project_id
|
|
41
41
|
load_project_metadata
|
|
42
42
|
load_sprints
|
|
43
|
+
load_users
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def run load_only: false
|
|
@@ -106,7 +107,7 @@ class ProjectConfig
|
|
|
106
107
|
|
|
107
108
|
def board id:, &block
|
|
108
109
|
config = BoardConfig.new(id: id, block: block, project_config: self)
|
|
109
|
-
config.run
|
|
110
|
+
config.run if data_downloaded?
|
|
110
111
|
@board_configs << config
|
|
111
112
|
end
|
|
112
113
|
|
|
@@ -151,6 +152,8 @@ class ProjectConfig
|
|
|
151
152
|
end
|
|
152
153
|
|
|
153
154
|
def status_category_mapping status:, category:
|
|
155
|
+
return if @exporter.downloading?
|
|
156
|
+
|
|
154
157
|
status, status_id = possible_statuses.parse_name_id status
|
|
155
158
|
category, category_id = possible_statuses.parse_name_id category
|
|
156
159
|
|
|
@@ -323,6 +326,15 @@ class ProjectConfig
|
|
|
323
326
|
raise
|
|
324
327
|
end
|
|
325
328
|
|
|
329
|
+
def load_users
|
|
330
|
+
@users = []
|
|
331
|
+
filename = File.join @target_path, "#{get_file_prefix}_users.json"
|
|
332
|
+
return unless File.exist? filename
|
|
333
|
+
|
|
334
|
+
json = file_system.load_json(filename)
|
|
335
|
+
json.each { |user_data| @users << User.new(raw: user_data) }
|
|
336
|
+
end
|
|
337
|
+
|
|
326
338
|
def to_time string, end_of_day: false
|
|
327
339
|
time = end_of_day ? '23:59:59' : '00:00:00'
|
|
328
340
|
string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
|
|
@@ -356,7 +368,7 @@ class ProjectConfig
|
|
|
356
368
|
|
|
357
369
|
# To be used by the aggregate_config only. Not intended to be part of the public API
|
|
358
370
|
def add_issues issues_list
|
|
359
|
-
@issues =
|
|
371
|
+
@issues = IssueCollection.new if @issues.nil?
|
|
360
372
|
@all_boards = {}
|
|
361
373
|
|
|
362
374
|
issues_list.each do |issue|
|
|
@@ -373,7 +385,7 @@ class ProjectConfig
|
|
|
373
385
|
'declaration but none are here. Check your config.'
|
|
374
386
|
end
|
|
375
387
|
|
|
376
|
-
return @issues =
|
|
388
|
+
return @issues = IssueCollection.new if @exporter.downloading?
|
|
377
389
|
raise 'No data found. Must do a download before an export' unless data_downloaded?
|
|
378
390
|
|
|
379
391
|
load_data if all_boards.empty?
|
|
@@ -385,7 +397,7 @@ class ProjectConfig
|
|
|
385
397
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
|
386
398
|
else
|
|
387
399
|
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
|
388
|
-
return
|
|
400
|
+
return IssueCollection.new
|
|
389
401
|
end
|
|
390
402
|
|
|
391
403
|
# Attach related issues
|
|
@@ -397,7 +409,8 @@ class ProjectConfig
|
|
|
397
409
|
|
|
398
410
|
# We'll have some issues that are in the list that weren't part of the initial query. Once we've
|
|
399
411
|
# attached them in the appropriate places, remove any that aren't part of that initial set.
|
|
400
|
-
|
|
412
|
+
issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
|
|
413
|
+
@issues = issues
|
|
401
414
|
end
|
|
402
415
|
|
|
403
416
|
@issues
|
|
@@ -438,7 +451,7 @@ class ProjectConfig
|
|
|
438
451
|
end
|
|
439
452
|
|
|
440
453
|
def load_issues_from_issues_directory path:, timezone_offset:
|
|
441
|
-
issues =
|
|
454
|
+
issues = IssueCollection.new
|
|
442
455
|
default_board = nil
|
|
443
456
|
|
|
444
457
|
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
|
@@ -450,6 +463,10 @@ class ProjectConfig
|
|
|
450
463
|
end
|
|
451
464
|
|
|
452
465
|
boards.each do |board|
|
|
466
|
+
if board.cycletime.nil?
|
|
467
|
+
raise "The board declaration for board #{board.id} must come before the " \
|
|
468
|
+
"first usage of 'issues' in the configuration"
|
|
469
|
+
end
|
|
453
470
|
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
|
454
471
|
end
|
|
455
472
|
end
|
|
@@ -462,7 +479,7 @@ class ProjectConfig
|
|
|
462
479
|
# board ids appropriately.
|
|
463
480
|
def group_filenames_and_board_ids path:
|
|
464
481
|
hash = {}
|
|
465
|
-
|
|
482
|
+
file_system.foreach(path) do |filename|
|
|
466
483
|
# Matches either FAKE-123.json or FAKE-123-456.json
|
|
467
484
|
if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
|
|
468
485
|
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
data/lib/jirametrics/sprint.rb
CHANGED
|
@@ -121,11 +121,13 @@ class SprintBurndown < ChartBase
|
|
|
121
121
|
|
|
122
122
|
# select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
|
|
123
123
|
def changes_for_one_issue issue:, sprint:
|
|
124
|
-
|
|
124
|
+
estimate = 0.0
|
|
125
125
|
ever_in_sprint = false
|
|
126
126
|
currently_in_sprint = false
|
|
127
127
|
change_data = []
|
|
128
128
|
|
|
129
|
+
estimate_display_name = current_board.estimation_configuration.display_name
|
|
130
|
+
|
|
129
131
|
issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
|
|
130
132
|
completed_has_been_tracked = false
|
|
131
133
|
|
|
@@ -140,26 +142,26 @@ class SprintBurndown < ChartBase
|
|
|
140
142
|
if currently_in_sprint == false && in_change_item
|
|
141
143
|
action = :enter_sprint
|
|
142
144
|
ever_in_sprint = true
|
|
143
|
-
value =
|
|
145
|
+
value = estimate
|
|
144
146
|
elsif currently_in_sprint && in_change_item == false
|
|
145
147
|
action = :leave_sprint
|
|
146
|
-
value = -
|
|
148
|
+
value = -estimate
|
|
147
149
|
end
|
|
148
150
|
currently_in_sprint = in_change_item
|
|
149
|
-
elsif change.
|
|
151
|
+
elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
|
|
150
152
|
action = :story_points
|
|
151
|
-
|
|
152
|
-
value =
|
|
153
|
+
estimate = change.value.to_f
|
|
154
|
+
value = estimate - change.old_value.to_f
|
|
153
155
|
elsif completed_has_been_tracked == false && change.time == issue_completed_time
|
|
154
156
|
completed_has_been_tracked = true
|
|
155
157
|
action = :issue_stopped
|
|
156
|
-
value = -
|
|
158
|
+
value = -estimate
|
|
157
159
|
end
|
|
158
160
|
|
|
159
161
|
next unless action
|
|
160
162
|
|
|
161
163
|
change_data << SprintIssueChangeData.new(
|
|
162
|
-
time: change.time, issue: issue, action: action, value: value,
|
|
164
|
+
time: change.time, issue: issue, action: action, value: value, estimate: estimate
|
|
163
165
|
)
|
|
164
166
|
end
|
|
165
167
|
|
|
@@ -176,7 +178,7 @@ class SprintBurndown < ChartBase
|
|
|
176
178
|
summary_stats = SprintSummaryStats.new
|
|
177
179
|
summary_stats.completed = 0.0
|
|
178
180
|
|
|
179
|
-
|
|
181
|
+
estimate = 0.0
|
|
180
182
|
start_data_written = false
|
|
181
183
|
data_set = []
|
|
182
184
|
|
|
@@ -185,11 +187,11 @@ class SprintBurndown < ChartBase
|
|
|
185
187
|
change_data_for_sprint.each do |change_data|
|
|
186
188
|
if start_data_written == false && change_data.time >= sprint.start_time
|
|
187
189
|
data_set << {
|
|
188
|
-
y:
|
|
190
|
+
y: estimate,
|
|
189
191
|
x: chart_format(sprint.start_time),
|
|
190
|
-
title: "Sprint started with #{
|
|
192
|
+
title: "Sprint started with #{estimate} points"
|
|
191
193
|
}
|
|
192
|
-
summary_stats.started =
|
|
194
|
+
summary_stats.started = estimate
|
|
193
195
|
start_data_written = true
|
|
194
196
|
end
|
|
195
197
|
|
|
@@ -198,12 +200,12 @@ class SprintBurndown < ChartBase
|
|
|
198
200
|
case change_data.action
|
|
199
201
|
when :enter_sprint
|
|
200
202
|
issues_currently_in_sprint << change_data.issue.key
|
|
201
|
-
|
|
203
|
+
estimate += change_data.estimate
|
|
202
204
|
when :leave_sprint
|
|
203
205
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
204
|
-
|
|
206
|
+
estimate -= change_data.estimate
|
|
205
207
|
when :story_points
|
|
206
|
-
|
|
208
|
+
estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
|
|
207
209
|
end
|
|
208
210
|
|
|
209
211
|
next unless change_data.time >= sprint.start_time
|
|
@@ -213,26 +215,26 @@ class SprintBurndown < ChartBase
|
|
|
213
215
|
when :story_points
|
|
214
216
|
next unless issues_currently_in_sprint.include? change_data.issue.key
|
|
215
217
|
|
|
216
|
-
|
|
217
|
-
message = "Story points changed from #{
|
|
218
|
+
old_estimate = change_data.estimate - change_data.value
|
|
219
|
+
message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
|
|
218
220
|
summary_stats.points_values_changed = true
|
|
219
221
|
when :enter_sprint
|
|
220
|
-
message = "Added to sprint with #{change_data.
|
|
221
|
-
summary_stats.added += change_data.
|
|
222
|
+
message = "Added to sprint with #{change_data.estimate || 'no'} points"
|
|
223
|
+
summary_stats.added += change_data.estimate
|
|
222
224
|
when :issue_stopped
|
|
223
|
-
|
|
224
|
-
message = "Completed with #{change_data.
|
|
225
|
+
estimate -= change_data.estimate
|
|
226
|
+
message = "Completed with #{change_data.estimate || 'no'} points"
|
|
225
227
|
issues_currently_in_sprint.delete change_data.issue.key
|
|
226
|
-
summary_stats.completed += change_data.
|
|
228
|
+
summary_stats.completed += change_data.estimate
|
|
227
229
|
when :leave_sprint
|
|
228
|
-
message = "Removed from sprint with #{change_data.
|
|
229
|
-
summary_stats.removed += change_data.
|
|
230
|
+
message = "Removed from sprint with #{change_data.estimate || 'no'} points"
|
|
231
|
+
summary_stats.removed += change_data.estimate
|
|
230
232
|
else
|
|
231
233
|
raise "Unexpected action: #{change_data.action}"
|
|
232
234
|
end
|
|
233
235
|
|
|
234
236
|
data_set << {
|
|
235
|
-
y:
|
|
237
|
+
y: estimate,
|
|
236
238
|
x: chart_format(change_data.time),
|
|
237
239
|
title: "#{change_data.issue.key} #{message}"
|
|
238
240
|
}
|
|
@@ -241,27 +243,27 @@ class SprintBurndown < ChartBase
|
|
|
241
243
|
unless start_data_written
|
|
242
244
|
# There was nothing that triggered us to write the sprint started block so do it now.
|
|
243
245
|
data_set << {
|
|
244
|
-
y:
|
|
246
|
+
y: estimate,
|
|
245
247
|
x: chart_format(sprint.start_time),
|
|
246
|
-
title: "Sprint started with #{
|
|
248
|
+
title: "Sprint started with #{estimate} points"
|
|
247
249
|
}
|
|
248
|
-
summary_stats.started =
|
|
250
|
+
summary_stats.started = estimate
|
|
249
251
|
end
|
|
250
252
|
|
|
251
253
|
if sprint.completed_time
|
|
252
254
|
data_set << {
|
|
253
|
-
y:
|
|
255
|
+
y: estimate,
|
|
254
256
|
x: chart_format(sprint.completed_time),
|
|
255
|
-
title: "Sprint ended with #{
|
|
257
|
+
title: "Sprint ended with #{estimate} points unfinished"
|
|
256
258
|
}
|
|
257
|
-
summary_stats.remaining =
|
|
259
|
+
summary_stats.remaining = estimate
|
|
258
260
|
end
|
|
259
261
|
|
|
260
262
|
unless sprint.completed_at?(time_range.end)
|
|
261
263
|
data_set << {
|
|
262
|
-
y:
|
|
264
|
+
y: estimate,
|
|
263
265
|
x: chart_format(time_range.end),
|
|
264
|
-
title: "Sprint still active. #{
|
|
266
|
+
title: "Sprint still active. #{estimate} points still in progress."
|
|
265
267
|
}
|
|
266
268
|
end
|
|
267
269
|
|
|
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class SprintIssueChangeData
|
|
6
6
|
include ValueEquality
|
|
7
|
-
attr_reader :time, :action, :value, :issue, :
|
|
7
|
+
attr_reader :time, :action, :value, :issue, :estimate
|
|
8
8
|
|
|
9
|
-
def initialize time:, action:, value:, issue:,
|
|
9
|
+
def initialize time:, action:, value:, issue:, estimate:
|
|
10
10
|
@time = time
|
|
11
11
|
@action = action
|
|
12
12
|
@value = value
|
|
13
13
|
@issue = issue
|
|
14
|
-
@
|
|
14
|
+
@estimate = estimate
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def inspect
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -36,13 +36,10 @@ class Status
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.from_raw raw
|
|
39
|
-
|
|
39
|
+
raise "raw cannot be nil" if raw.nil?
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
|
|
44
|
-
"#{category_config}"
|
|
45
|
-
end
|
|
41
|
+
category_config = raw['statusCategory']
|
|
42
|
+
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
46
43
|
|
|
47
44
|
Status.new(
|
|
48
45
|
name: raw['name'],
|