jirametrics 2.6 → 2.12pre9

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +9 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +54 -7
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +47 -13
  8. data/lib/jirametrics/board_config.rb +7 -2
  9. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  10. data/lib/jirametrics/change_item.rb +19 -8
  11. data/lib/jirametrics/chart_base.rb +63 -27
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/cycletime_config.rb +59 -8
  14. data/lib/jirametrics/cycletime_histogram.rb +68 -3
  15. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  16. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  17. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  18. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  19. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  20. data/lib/jirametrics/data_quality_report.rb +219 -41
  21. data/lib/jirametrics/dependency_chart.rb +3 -4
  22. data/lib/jirametrics/download_config.rb +2 -2
  23. data/lib/jirametrics/downloader.rb +57 -37
  24. data/lib/jirametrics/estimate_accuracy_chart.rb +35 -12
  25. data/lib/jirametrics/estimation_configuration.rb +25 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +3 -6
  27. data/lib/jirametrics/examples/standard_project.rb +14 -13
  28. data/lib/jirametrics/expedited_chart.rb +7 -8
  29. data/lib/jirametrics/exporter.rb +28 -13
  30. data/lib/jirametrics/file_config.rb +23 -6
  31. data/lib/jirametrics/file_system.rb +41 -4
  32. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  33. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  34. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  35. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  36. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  37. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  38. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  39. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  40. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  41. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  42. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  43. data/lib/jirametrics/html/index.css +28 -5
  44. data/lib/jirametrics/html/index.erb +8 -4
  45. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  46. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  47. data/lib/jirametrics/html_report_config.rb +33 -23
  48. data/lib/jirametrics/issue.rb +170 -54
  49. data/lib/jirametrics/jira_gateway.rb +16 -3
  50. data/lib/jirametrics/project_config.rb +251 -135
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  52. data/lib/jirametrics/sprint_burndown.rb +38 -36
  53. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  54. data/lib/jirametrics/status.rb +81 -26
  55. data/lib/jirametrics/status_collection.rb +77 -39
  56. data/lib/jirametrics/throughput_chart.rb +1 -1
  57. data/lib/jirametrics/value_equality.rb +2 -2
  58. data/lib/jirametrics.rb +23 -6
  59. metadata +11 -13
  60. data/lib/jirametrics/discard_changes_before.rb +0 -37
  61. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -13,6 +13,12 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ # We only check for this here because if a board isn't passed in then things will fail much
17
+ # later and be hard to find. Let's find out early.
18
+ raise "No board for issue #{key}" if board.nil?
19
+
20
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
21
+ # changelogs.
16
22
  return unless @raw['changelog']
17
23
 
18
24
  load_history_into_changes
@@ -43,14 +49,26 @@ class Issue
43
49
 
44
50
  def summary = @raw['fields']['summary']
45
51
 
46
- def status = Status.new(raw: @raw['fields']['status'])
47
-
48
52
  def labels = @raw['fields']['labels'] || []
49
53
 
50
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
51
55
 
52
56
  def resolution = @raw['fields']['resolution']&.[]('name')
53
57
 
58
+ def status
59
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
60
+ @status
61
+ end
62
+
63
+ def status= status
64
+ @status = status
65
+ end
66
+
67
+ def due_date
68
+ text = @raw['fields']['duedate']
69
+ text.nil? ? nil : Date.parse(text)
70
+ end
71
+
54
72
  def url
55
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
56
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -65,35 +83,43 @@ class Issue
65
83
  end
66
84
 
67
85
  def first_time_in_status *status_names
68
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
86
+ @changes.find { |change| change.current_status_matches(*status_names) }
69
87
  end
70
88
 
71
89
  def first_time_not_in_status *status_names
72
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
90
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
73
91
  end
74
92
 
75
93
  def first_time_in_or_right_of_column column_name
76
94
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
77
95
  end
78
96
 
97
+ def first_time_label_added *labels
98
+ @changes.each do |change|
99
+ next unless change.labels?
100
+
101
+ change_labels = change.value.split
102
+ return change if change_labels.any? { |l| labels.include?(l) }
103
+ end
104
+ nil
105
+ end
106
+
79
107
  def still_in_or_right_of_column column_name
80
108
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
81
109
  end
82
110
 
83
111
  def still_in
84
- time = nil
85
- @changes.each do |change|
86
- next unless change.status?
87
-
112
+ result = nil
113
+ status_changes.each do |change|
88
114
  current_status_matched = yield change
89
115
 
90
- if current_status_matched && time.nil?
91
- time = change.time
92
- elsif !current_status_matched && time
93
- time = nil
116
+ if current_status_matched && result.nil?
117
+ result = change
118
+ elsif !current_status_matched && result
119
+ result = nil
94
120
  end
95
121
  end
96
- time
122
+ result
97
123
  end
98
124
  private :still_in
99
125
 
@@ -106,58 +132,75 @@ class Issue
106
132
 
107
133
  # If it ever entered one of these categories and it's still there then what was the last time it entered
108
134
  def still_in_status_category *category_names
135
+ category_ids = find_status_category_ids_by_names category_names
136
+
109
137
  still_in do |change|
110
- status = find_status_by_name change.value
111
- category_names.include?(status.category_name) || category_names.include?(status.category_id)
138
+ status = find_or_create_status id: change.value_id, name: change.value
139
+ category_ids.include? status.category.id
112
140
  end
113
141
  end
114
142
 
115
143
  def most_recent_status_change
116
- changes.reverse.find { |change| change.status? }
144
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
145
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
146
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
147
+ status_changes.last
117
148
  end
118
149
 
119
- # Are we currently in this status? If yes, then return the time of the most recent status change.
150
+ # Are we currently in this status? If yes, then return the most recent status change.
120
151
  def currently_in_status *status_names
121
152
  change = most_recent_status_change
122
153
  return false if change.nil?
123
154
 
124
- change.time if change.current_status_matches(*status_names)
155
+ change if change.current_status_matches(*status_names)
125
156
  end
126
157
 
127
- # Are we currently in this status category? If yes, then return the time of the most recent status change.
158
+ # Are we currently in this status category? If yes, then return the most recent status change.
128
159
  def currently_in_status_category *category_names
160
+ category_ids = find_status_category_ids_by_names category_names
161
+
129
162
  change = most_recent_status_change
130
163
  return false if change.nil?
131
164
 
132
- status = find_status_by_name change.value
133
- change.time if status && category_names.include?(status.category_name)
165
+ status = find_or_create_status id: change.value_id, name: change.value
166
+ change if status && category_ids.include?(status.category.id)
134
167
  end
135
168
 
136
- def find_status_by_name name
137
- status = board.possible_statuses.find_by_name(name)
138
- return status if status
169
+ def find_or_create_status id:, name:
170
+ status = board.possible_statuses.find_by_id(id)
171
+
172
+ unless status
173
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
174
+ # list this status as one it actually found
175
+ found_statuses = board.possible_statuses.to_s
176
+
177
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
178
+
179
+ message = +'The history for issue '
180
+ message << key
181
+ message << ' references the status ('
182
+ message << "#{name.inspect}:#{id.inspect}"
183
+ message << ') that can\'t be found. We are guessing that this belongs to the '
184
+ message << status.category.to_s
185
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
186
+ message << 'details on defining statuses.'
187
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
188
+ end
139
189
 
140
- @board.project_config.file_system.log(
141
- "Warning: Status name #{name.inspect} for issue #{key} not found in" \
142
- " #{board.possible_statuses.collect(&:name).inspect}" \
143
- "\n See Q1 in the FAQ for more details: https://github.com/mikebowler/jirametrics/wiki/FAQ\n",
144
- also_write_to_stderr: true
145
- )
146
- status = Status.new(name: name, category_name: 'In Progress')
147
- board.possible_statuses << status
148
190
  status
149
191
  end
150
192
 
151
193
  def first_status_change_after_created
152
- @changes.find { |change| change.status? && change.artificial? == false }&.time
194
+ status_changes.find { |change| change.artificial? == false }
153
195
  end
154
196
 
155
197
  def first_time_in_status_category *category_names
156
- @changes.each do |change|
157
- next unless change.status?
198
+ category_ids = find_status_category_ids_by_names category_names
158
199
 
159
- category = find_status_by_name(change.value).category_name
160
- return change.time if category_names.include? category
200
+ status_changes.each do |change|
201
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
202
+ id = to_status.category.id
203
+ return change if category_ids.include? id
161
204
  end
162
205
  nil
163
206
  end
@@ -176,11 +219,11 @@ class Issue
176
219
  end
177
220
 
178
221
  def first_resolution
179
- @changes.find { |change| change.resolution? }&.time
222
+ @changes.find { |change| change.resolution? }
180
223
  end
181
224
 
182
225
  def last_resolution
183
- @changes.reverse.find { |change| change.resolution? }&.time
226
+ @changes.reverse.find { |change| change.resolution? }
184
227
  end
185
228
 
186
229
  def assigned_to
@@ -259,7 +302,7 @@ class Issue
259
302
 
260
303
  blocked_link_texts = settings['blocked_link_text']
261
304
  stalled_threshold = settings['stalled_threshold_days']
262
- flagged_means_blocked = !!settings['flagged_means_blocked']
305
+ flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
263
306
 
264
307
  blocking_issue_keys = []
265
308
 
@@ -373,12 +416,11 @@ class Issue
373
416
 
374
417
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
375
418
  # It does not include data before issue start or after issue end
376
- def flow_efficiency_numbers end_time:, settings: {}
377
- issue_start = @board.cycletime.started_time(self)
419
+ def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
420
+ issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
378
421
  return [0.0, 0.0] if !issue_start || issue_start > end_time
379
422
 
380
423
  value_add_time = 0.0
381
- issue_stop = @board.cycletime.stopped_time(self)
382
424
  end_time = issue_stop if issue_stop && issue_stop < end_time
383
425
 
384
426
  active_start = nil
@@ -542,6 +584,20 @@ class Issue
542
584
  comparison
543
585
  end
544
586
 
587
+ def discard_changes_before cutoff_time
588
+ rejected_any = false
589
+ @changes.reject! do |change|
590
+ reject = change.status? && change.time <= cutoff_time && change.artificial? == false
591
+ if reject
592
+ (@discarded_changes ||= []) << change
593
+ rejected_any = true
594
+ end
595
+ reject
596
+ end
597
+
598
+ (@discarded_change_times ||= []) << cutoff_time if rejected_any
599
+ end
600
+
545
601
  def dump
546
602
  result = +''
547
603
  result << "#{key} (#{type}): #{compact_text summary, 200}\n"
@@ -549,21 +605,68 @@ class Issue
549
605
  assignee = raw['fields']['assignee']
550
606
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
551
607
 
552
- raw['fields']['issuelinks'].each do |link|
608
+ raw['fields']['issuelinks']&.each do |link|
553
609
  result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
554
610
  result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
555
611
  end
556
- changes.each do |change|
557
- value = change.value
558
- old_value = change.old_value
612
+ history = [] # time, type, detail
613
+
614
+ if board.cycletime
615
+ started_at, stopped_at = board.cycletime.started_stopped_times(self)
616
+ history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
617
+ history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
618
+ else
619
+ result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
620
+ end
621
+
622
+ @discarded_change_times&.each do |time|
623
+ history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
624
+ end
625
+
626
+ (changes + (@discarded_changes || [])).each do |change|
627
+ if change.status?
628
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
629
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
630
+ else
631
+ value = compact_text(change.value).inspect
632
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
633
+ end
559
634
 
560
- message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
561
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
562
- message << compact_text(value).inspect
563
- message << " (#{change.author})"
564
- message << ' <<artificial entry>>' if change.artificial?
565
- result << message << "\n"
635
+ message = +''
636
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
637
+ message << value
638
+ if change.artificial?
639
+ message << ' (Artificial entry)' if change.artificial?
640
+ else
641
+ message << " (Author: #{change.author})"
642
+ end
643
+ history << [change.time, change.field, message, change.artificial?]
566
644
  end
645
+
646
+ result << " History:\n"
647
+ type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
648
+ history.sort! do |a, b|
649
+ if a[0] == b[0]
650
+ if a[1].nil?
651
+ 1
652
+ elsif b[1].nil?
653
+ -1
654
+ else
655
+ a[1] <=> b[1]
656
+ end
657
+ else
658
+ a[0] <=> b[0]
659
+ end
660
+ end
661
+ history.each do |time, type, detail, _artificial|
662
+ if type.nil?
663
+ type = '-' * type_width
664
+ else
665
+ type = (' ' * (type_width - type.length)) << type
666
+ end
667
+ result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
668
+ end
669
+
567
670
  result
568
671
  end
569
672
 
@@ -572,12 +675,16 @@ class Issue
572
675
  # This was probably loaded as a linked issue, which means we don't know what board it really
573
676
  # belonged to. The best we can do is look at the status category. This case should be rare but
574
677
  # it can happen.
575
- status.category_name == 'Done'
678
+ status.category.name == 'Done'
576
679
  else
577
680
  board.cycletime.done? self
578
681
  end
579
682
  end
580
683
 
684
+ def status_changes
685
+ @changes.select { |change| change.status? }
686
+ end
687
+
581
688
  private
582
689
 
583
690
  def assemble_author raw
@@ -653,4 +760,13 @@ class Issue
653
760
  'toString' => first_status
654
761
  }
655
762
  end
763
+
764
+ def find_status_category_ids_by_names category_names
765
+ category_names.filter_map do |name|
766
+ list = board.possible_statuses.find_all_categories_by_name name
767
+ raise "No status categories found for name: #{name}" if list.empty?
768
+
769
+ list
770
+ end.flatten.collect(&:id)
771
+ end
656
772
  end
@@ -14,9 +14,15 @@ 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
- puts "Error #{e.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
@@ -61,4 +67,11 @@ class JiraGateway
61
67
  command << " --url \"#{url}\""
62
68
  command
63
69
  end
70
+
71
+ def json_successful? json
72
+ return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
73
+ return false if json.is_a?(Array) && json.first == 'errorMessage'
74
+
75
+ true
76
+ end
64
77
  end