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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  4. data/lib/jirametrics/aging_work_table.rb +56 -13
  5. data/lib/jirametrics/board.rb +38 -10
  6. data/lib/jirametrics/board_config.rb +1 -0
  7. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  8. data/lib/jirametrics/change_item.rb +37 -16
  9. data/lib/jirametrics/chart_base.rb +22 -5
  10. data/lib/jirametrics/css_variable.rb +1 -1
  11. data/lib/jirametrics/cycletime_config.rb +1 -1
  12. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  13. data/lib/jirametrics/daily_view.rb +277 -0
  14. data/lib/jirametrics/data_quality_report.rb +1 -1
  15. data/lib/jirametrics/downloader.rb +11 -14
  16. data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
  17. data/lib/jirametrics/estimation_configuration.rb +25 -0
  18. data/lib/jirametrics/examples/standard_project.rb +2 -0
  19. data/lib/jirametrics/exporter.rb +10 -8
  20. data/lib/jirametrics/file_config.rb +10 -5
  21. data/lib/jirametrics/file_system.rb +4 -0
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  24. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  25. data/lib/jirametrics/html/aging_work_table.erb +7 -3
  26. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  27. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  28. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  29. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  30. data/lib/jirametrics/html/index.css +82 -2
  31. data/lib/jirametrics/html/index.erb +25 -1
  32. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  33. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  34. data/lib/jirametrics/html_report_config.rb +2 -0
  35. data/lib/jirametrics/issue.rb +68 -27
  36. data/lib/jirametrics/issue_collection.rb +33 -0
  37. data/lib/jirametrics/jira_gateway.rb +20 -4
  38. data/lib/jirametrics/project_config.rb +25 -8
  39. data/lib/jirametrics/settings.json +2 -1
  40. data/lib/jirametrics/sprint.rb +1 -0
  41. data/lib/jirametrics/sprint_burndown.rb +35 -33
  42. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  43. data/lib/jirametrics/status.rb +3 -6
  44. data/lib/jirametrics/status_collection.rb +6 -0
  45. data/lib/jirametrics/user.rb +12 -0
  46. data/lib/jirametrics.rb +4 -0
  47. metadata +7 -2
@@ -56,16 +56,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
56
56
  },
57
57
  annotation: {
58
58
  annotations: {
59
- <% holidays().each_with_index do |range, index| %>
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
- <% holidays.each_with_index do |range, index| %>
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
@@ -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 summary = @raw['fields']['summary']
49
+ def priority_name = @raw['fields']['priority']['name']
50
+ def priority_url = @raw['fields']['priority']['iconUrl']
51
51
 
52
- def status = Status.from_raw(@raw['fields']['status'])
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
- # We artificially insert a status change to represent creation so by definition there will always be at least one.
133
- changes.reverse.find { |change| change.status? }
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, author: '', artificial: true, raw: { 'field' => '' }
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
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
599
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
600
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
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
- value = change.value
608
- old_value = change.old_value
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 << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
612
- message << compact_text(value).inspect
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
- private
696
+ def sprints
697
+ sprint_ids = []
664
698
 
665
- def assemble_author raw
666
- raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
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, author: author)
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, author: author, artificial: true)
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
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
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
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
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
- @file_system.log " #{command.gsub(/\s+/, ' ')}"
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 = [] if @issues.nil?
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 = [] if @exporter.downloading?
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
- @issues = issues.select { |i| i.in_initial_query? }
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
- Dir.foreach(path) do |filename|
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]
@@ -6,5 +6,6 @@
6
6
  "blocked_statuses": [],
7
7
  "flagged_means_blocked": true,
8
8
 
9
- "expedited_priority_names": ["Critical", "Highest"]
9
+ "expedited_priority_names": ["Critical", "Highest"],
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
10
11
  }
@@ -12,6 +12,7 @@ class Sprint
12
12
 
13
13
  def id = @raw['id']
14
14
  def active? = (@raw['state'] == 'active')
15
+ def closed? = (@raw['state'] == 'closed')
15
16
 
16
17
  def completed_at? time
17
18
  completed_at = completed_time
@@ -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
- story_points = 0.0
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 = story_points
145
+ value = estimate
144
146
  elsif currently_in_sprint && in_change_item == false
145
147
  action = :leave_sprint
146
- value = -story_points
148
+ value = -estimate
147
149
  end
148
150
  currently_in_sprint = in_change_item
149
- elsif change.story_points? && (issue_completed_time.nil? || change.time < issue_completed_time)
151
+ elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
150
152
  action = :story_points
151
- story_points = change.value.to_f
152
- value = story_points - change.old_value.to_f
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 = -story_points
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, story_points: story_points
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
- story_points = 0.0
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: story_points,
190
+ y: estimate,
189
191
  x: chart_format(sprint.start_time),
190
- title: "Sprint started with #{story_points} points"
192
+ title: "Sprint started with #{estimate} points"
191
193
  }
192
- summary_stats.started = story_points
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
- story_points += change_data.story_points
203
+ estimate += change_data.estimate
202
204
  when :leave_sprint
203
205
  issues_currently_in_sprint.delete change_data.issue.key
204
- story_points -= change_data.story_points
206
+ estimate -= change_data.estimate
205
207
  when :story_points
206
- story_points += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
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
- old_story_points = change_data.story_points - change_data.value
217
- message = "Story points changed from #{old_story_points} points to #{change_data.story_points} points"
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.story_points || 'no'} points"
221
- summary_stats.added += change_data.story_points
222
+ message = "Added to sprint with #{change_data.estimate || 'no'} points"
223
+ summary_stats.added += change_data.estimate
222
224
  when :issue_stopped
223
- story_points -= change_data.story_points
224
- message = "Completed with #{change_data.story_points || 'no'} points"
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.story_points
228
+ summary_stats.completed += change_data.estimate
227
229
  when :leave_sprint
228
- message = "Removed from sprint with #{change_data.story_points || 'no'} points"
229
- summary_stats.removed += change_data.story_points
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: story_points,
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: story_points,
246
+ y: estimate,
245
247
  x: chart_format(sprint.start_time),
246
- title: "Sprint started with #{story_points} points"
248
+ title: "Sprint started with #{estimate} points"
247
249
  }
248
- summary_stats.started = story_points
250
+ summary_stats.started = estimate
249
251
  end
250
252
 
251
253
  if sprint.completed_time
252
254
  data_set << {
253
- y: story_points,
255
+ y: estimate,
254
256
  x: chart_format(sprint.completed_time),
255
- title: "Sprint ended with #{story_points} points unfinished"
257
+ title: "Sprint ended with #{estimate} points unfinished"
256
258
  }
257
- summary_stats.remaining = story_points
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: story_points,
264
+ y: estimate,
263
265
  x: chart_format(time_range.end),
264
- title: "Sprint still active. #{story_points} points still in progress."
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, :story_points
7
+ attr_reader :time, :action, :value, :issue, :estimate
8
8
 
9
- def initialize time:, action:, value:, issue:, story_points:
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
- @story_points = story_points
14
+ @estimate = estimate
15
15
  end
16
16
 
17
17
  def inspect
@@ -36,13 +36,10 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
- category_config = raw['statusCategory']
39
+ raise "raw cannot be nil" if raw.nil?
40
40
 
41
- legal_keys = %w[new indeterminate done]
42
- unless legal_keys.include? category_config['key']
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'],