jirametrics 2.7 → 2.11

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +4 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +50 -2
  6. data/lib/jirametrics/board.rb +33 -5
  7. data/lib/jirametrics/board_config.rb +6 -2
  8. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  9. data/lib/jirametrics/change_item.rb +19 -6
  10. data/lib/jirametrics/chart_base.rb +59 -21
  11. data/lib/jirametrics/css_variable.rb +1 -1
  12. data/lib/jirametrics/cycletime_config.rb +37 -5
  13. data/lib/jirametrics/cycletime_histogram.rb +67 -2
  14. data/lib/jirametrics/data_quality_report.rb +174 -35
  15. data/lib/jirametrics/download_config.rb +2 -2
  16. data/lib/jirametrics/downloader.rb +44 -25
  17. data/lib/jirametrics/examples/aggregated_project.rb +2 -5
  18. data/lib/jirametrics/examples/standard_project.rb +4 -6
  19. data/lib/jirametrics/expedited_chart.rb +7 -7
  20. data/lib/jirametrics/exporter.rb +10 -20
  21. data/lib/jirametrics/file_config.rb +23 -6
  22. data/lib/jirametrics/file_system.rb +39 -4
  23. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  25. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  26. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  27. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  28. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  29. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  30. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  31. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  32. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  33. data/lib/jirametrics/html/index.css +28 -5
  34. data/lib/jirametrics/html/index.erb +8 -4
  35. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  36. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  37. data/lib/jirametrics/html_report_config.rb +32 -23
  38. data/lib/jirametrics/issue.rb +104 -44
  39. data/lib/jirametrics/jira_gateway.rb +16 -3
  40. data/lib/jirametrics/project_config.rb +223 -120
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/status.rb +81 -26
  43. data/lib/jirametrics/status_collection.rb +74 -40
  44. data/lib/jirametrics/throughput_chart.rb +1 -1
  45. data/lib/jirametrics/value_equality.rb +2 -2
  46. data/lib/jirametrics.rb +7 -1
  47. metadata +8 -13
  48. data/lib/jirametrics/discard_changes_before.rb +0 -37
  49. data/lib/jirametrics/html/data_quality_report.erb +0 -138
@@ -13,7 +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.
16
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.
17
22
  return unless @raw['changelog']
18
23
 
19
24
  load_history_into_changes
@@ -44,14 +49,26 @@ class Issue
44
49
 
45
50
  def summary = @raw['fields']['summary']
46
51
 
47
- def status = Status.new(raw: @raw['fields']['status'])
48
-
49
52
  def labels = @raw['fields']['labels'] || []
50
53
 
51
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
52
55
 
53
56
  def resolution = @raw['fields']['resolution']&.[]('name')
54
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
+
55
72
  def url
56
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
57
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -66,35 +83,43 @@ class Issue
66
83
  end
67
84
 
68
85
  def first_time_in_status *status_names
69
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
86
+ @changes.find { |change| change.current_status_matches(*status_names) }
70
87
  end
71
88
 
72
89
  def first_time_not_in_status *status_names
73
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
90
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
74
91
  end
75
92
 
76
93
  def first_time_in_or_right_of_column column_name
77
94
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
78
95
  end
79
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
+
80
107
  def still_in_or_right_of_column column_name
81
108
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
82
109
  end
83
110
 
84
111
  def still_in
85
- time = nil
86
- @changes.each do |change|
87
- next unless change.status?
88
-
112
+ result = nil
113
+ status_changes.each do |change|
89
114
  current_status_matched = yield change
90
115
 
91
- if current_status_matched && time.nil?
92
- time = change.time
93
- elsif !current_status_matched && time
94
- time = nil
116
+ if current_status_matched && result.nil?
117
+ result = change
118
+ elsif !current_status_matched && result
119
+ result = nil
95
120
  end
96
121
  end
97
- time
122
+ result
98
123
  end
99
124
  private :still_in
100
125
 
@@ -107,58 +132,75 @@ class Issue
107
132
 
108
133
  # If it ever entered one of these categories and it's still there then what was the last time it entered
109
134
  def still_in_status_category *category_names
135
+ category_ids = find_status_category_ids_by_names category_names
136
+
110
137
  still_in do |change|
111
- status = find_status_by_name change.value
112
- 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
113
140
  end
114
141
  end
115
142
 
116
143
  def most_recent_status_change
117
- 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
118
148
  end
119
149
 
120
- # 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.
121
151
  def currently_in_status *status_names
122
152
  change = most_recent_status_change
123
153
  return false if change.nil?
124
154
 
125
- change.time if change.current_status_matches(*status_names)
155
+ change if change.current_status_matches(*status_names)
126
156
  end
127
157
 
128
- # 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.
129
159
  def currently_in_status_category *category_names
160
+ category_ids = find_status_category_ids_by_names category_names
161
+
130
162
  change = most_recent_status_change
131
163
  return false if change.nil?
132
164
 
133
- status = find_status_by_name change.value
134
- 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)
135
167
  end
136
168
 
137
- def find_status_by_name name
138
- status = board.possible_statuses.find_by_name(name)
139
- 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
140
189
 
141
- @board.project_config.file_system.log(
142
- "Warning: Status name #{name.inspect} for issue #{key} not found in" \
143
- " #{board.possible_statuses.collect(&:name).inspect}" \
144
- "\n See Q1 in the FAQ for more details: https://github.com/mikebowler/jirametrics/wiki/FAQ\n",
145
- also_write_to_stderr: true
146
- )
147
- status = Status.new(name: name, category_name: 'In Progress')
148
- board.possible_statuses << status
149
190
  status
150
191
  end
151
192
 
152
193
  def first_status_change_after_created
153
- @changes.find { |change| change.status? && change.artificial? == false }&.time
194
+ status_changes.find { |change| change.artificial? == false }
154
195
  end
155
196
 
156
197
  def first_time_in_status_category *category_names
157
- @changes.each do |change|
158
- next unless change.status?
198
+ category_ids = find_status_category_ids_by_names category_names
159
199
 
160
- category = find_status_by_name(change.value).category_name
161
- 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
162
204
  end
163
205
  nil
164
206
  end
@@ -177,11 +219,11 @@ class Issue
177
219
  end
178
220
 
179
221
  def first_resolution
180
- @changes.find { |change| change.resolution? }&.time
222
+ @changes.find { |change| change.resolution? }
181
223
  end
182
224
 
183
225
  def last_resolution
184
- @changes.reverse.find { |change| change.resolution? }&.time
226
+ @changes.reverse.find { |change| change.resolution? }
185
227
  end
186
228
 
187
229
  def assigned_to
@@ -578,12 +620,17 @@ class Issue
578
620
  end
579
621
 
580
622
  (changes + (@discarded_changes || [])).each do |change|
581
- value = change.value
582
- old_value = change.old_value
623
+ if change.status?
624
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
625
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
626
+ else
627
+ value = compact_text(change.value).inspect
628
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
629
+ end
583
630
 
584
631
  message = +''
585
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
586
- message << compact_text(value).inspect
632
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
633
+ message << value
587
634
  if change.artificial?
588
635
  message << ' (Artificial entry)' if change.artificial?
589
636
  else
@@ -624,12 +671,16 @@ class Issue
624
671
  # This was probably loaded as a linked issue, which means we don't know what board it really
625
672
  # belonged to. The best we can do is look at the status category. This case should be rare but
626
673
  # it can happen.
627
- status.category_name == 'Done'
674
+ status.category.name == 'Done'
628
675
  else
629
676
  board.cycletime.done? self
630
677
  end
631
678
  end
632
679
 
680
+ def status_changes
681
+ @changes.select { |change| change.status? }
682
+ end
683
+
633
684
  private
634
685
 
635
686
  def assemble_author raw
@@ -705,4 +756,13 @@ class Issue
705
756
  'toString' => first_status
706
757
  }
707
758
  end
759
+
760
+ def find_status_category_ids_by_names category_names
761
+ category_names.filter_map do |name|
762
+ list = board.possible_statuses.find_all_categories_by_name name
763
+ raise "No status categories found for name: #{name}" if list.empty?
764
+
765
+ list
766
+ end.flatten.collect(&:id)
767
+ end
708
768
  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
- 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
@@ -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