jirametrics 2.5 → 2.30

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +73 -20
  12. data/lib/jirametrics/board_config.rb +10 -2
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +8 -6
  30. data/lib/jirametrics/download_config.rb +17 -2
  31. data/lib/jirametrics/downloader.rb +177 -108
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +5 -8
  37. data/lib/jirametrics/examples/standard_project.rb +54 -38
  38. data/lib/jirametrics/expedited_chart.rb +10 -9
  39. data/lib/jirametrics/exporter.rb +51 -16
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +481 -97
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  80. data/lib/jirametrics/settings.json +7 -1
  81. data/lib/jirametrics/sprint.rb +13 -0
  82. data/lib/jirametrics/sprint_burndown.rb +47 -39
  83. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  84. data/lib/jirametrics/status.rb +84 -19
  85. data/lib/jirametrics/status_collection.rb +83 -38
  86. data/lib/jirametrics/stitcher.rb +81 -0
  87. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  88. data/lib/jirametrics/throughput_chart.rb +73 -23
  89. data/lib/jirametrics/time_based_histogram.rb +139 -0
  90. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  91. data/lib/jirametrics/user.rb +12 -0
  92. data/lib/jirametrics/value_equality.rb +2 -2
  93. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  94. data/lib/jirametrics.rb +101 -66
  95. metadata +72 -16
  96. data/lib/jirametrics/cycletime_config.rb +0 -69
  97. data/lib/jirametrics/discard_changes_before.rb +0 -37
  98. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  99. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -3,19 +3,27 @@
3
3
  require 'time'
4
4
 
5
5
  class Issue
6
- attr_reader :changes, :raw, :subtasks, :board
7
- attr_accessor :parent
6
+ attr_reader :changes, :raw, :subtasks, :board, :discarded_changes, :discarded_change_times
7
+ attr_accessor :parent, :github_prs
8
8
 
9
9
  def initialize raw:, board:, timezone_offset: '+00:00'
10
10
  @raw = raw
11
11
  @timezone_offset = timezone_offset
12
12
  @subtasks = []
13
13
  @changes = []
14
+ @github_prs = []
14
15
  @board = board
15
16
 
16
- return unless @raw['changelog']
17
+ # We only check for this here because if a board isn't passed in then things will fail much
18
+ # later and be hard to find. Let's find out early.
19
+ raise "No board for issue #{key}" if board.nil?
17
20
 
18
- load_history_into_changes
21
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
22
+ # changelogs.
23
+ load_history_into_changes if @raw['changelog']
24
+
25
+ # As above with fragments, there may not be a fields section
26
+ return unless @raw['fields']
19
27
 
20
28
  # If this is an older pull of data then comments may not be there.
21
29
  load_comments_into_changes if @raw['fields']['comment']
@@ -38,12 +46,12 @@ class Issue
38
46
  def key = @raw['key']
39
47
 
40
48
  def type = @raw['fields']['issuetype']['name']
41
-
42
49
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
43
50
 
44
- def summary = @raw['fields']['summary']
51
+ def priority_name = @raw.dig('fields', 'priority', 'name')
52
+ def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
45
53
 
46
- def status = Status.new(raw: @raw['fields']['status'])
54
+ def summary = @raw['fields']['summary']
47
55
 
48
56
  def labels = @raw['fields']['labels'] || []
49
57
 
@@ -51,6 +59,20 @@ class Issue
51
59
 
52
60
  def resolution = @raw['fields']['resolution']&.[]('name')
53
61
 
62
+ def status
63
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
64
+ @status
65
+ end
66
+
67
+ def status= status
68
+ @status = status
69
+ end
70
+
71
+ def due_date
72
+ text = @raw['fields']['duedate']
73
+ text.nil? ? nil : Date.parse(text)
74
+ end
75
+
54
76
  def url
55
77
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
56
78
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -65,35 +87,43 @@ class Issue
65
87
  end
66
88
 
67
89
  def first_time_in_status *status_names
68
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
90
+ @changes.find { |change| change.current_status_matches(*status_names) }
69
91
  end
70
92
 
71
93
  def first_time_not_in_status *status_names
72
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
94
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
73
95
  end
74
96
 
75
97
  def first_time_in_or_right_of_column column_name
76
98
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
77
99
  end
78
100
 
101
+ def first_time_label_added *labels
102
+ @changes.each do |change|
103
+ next unless change.labels?
104
+
105
+ change_labels = change.value.split
106
+ return change if change_labels.any? { |l| labels.include?(l) }
107
+ end
108
+ nil
109
+ end
110
+
79
111
  def still_in_or_right_of_column column_name
80
112
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
81
113
  end
82
114
 
83
115
  def still_in
84
- time = nil
85
- @changes.each do |change|
86
- next unless change.status?
87
-
116
+ result = nil
117
+ status_changes.each do |change|
88
118
  current_status_matched = yield change
89
119
 
90
- if current_status_matched && time.nil?
91
- time = change.time
92
- elsif !current_status_matched && time
93
- time = nil
120
+ if current_status_matched && result.nil?
121
+ result = change
122
+ elsif !current_status_matched && result
123
+ result = nil
94
124
  end
95
125
  end
96
- time
126
+ result
97
127
  end
98
128
  private :still_in
99
129
 
@@ -106,56 +136,205 @@ class Issue
106
136
 
107
137
  # If it ever entered one of these categories and it's still there then what was the last time it entered
108
138
  def still_in_status_category *category_names
139
+ category_ids = find_status_category_ids_by_names category_names
140
+
109
141
  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)
142
+ status = find_or_create_status id: change.value_id, name: change.value
143
+ category_ids.include? status.category.id
112
144
  end
113
145
  end
114
146
 
115
147
  def most_recent_status_change
116
- changes.reverse.find { |change| change.status? }
148
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
149
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
150
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
151
+ status_changes.last
117
152
  end
118
153
 
119
- # Are we currently in this status? If yes, then return the time of the most recent status change.
154
+ # Are we currently in this status? If yes, then return the most recent status change.
120
155
  def currently_in_status *status_names
121
156
  change = most_recent_status_change
122
- return false if change.nil?
157
+ return nil if change.nil?
123
158
 
124
- change.time if change.current_status_matches(*status_names)
159
+ change if change.current_status_matches(*status_names)
125
160
  end
126
161
 
127
- # Are we currently in this status category? If yes, then return the time of the most recent status change.
162
+ # Are we currently in this status category? If yes, then return the most recent status change.
128
163
  def currently_in_status_category *category_names
164
+ category_ids = find_status_category_ids_by_names category_names
165
+
129
166
  change = most_recent_status_change
130
- return false if change.nil?
167
+ return nil if change.nil?
131
168
 
132
- status = find_status_by_name change.value
133
- change.time if status && category_names.include?(status.category_name)
169
+ status = find_or_create_status id: change.value_id, name: change.value
170
+ change if status && category_ids.include?(status.category.id)
134
171
  end
135
172
 
136
- def find_status_by_name name
137
- status = board.possible_statuses.find_by_name(name)
138
- return status if status
173
+ def find_or_create_status id:, name:
174
+ status = board.possible_statuses.find_by_id(id)
139
175
 
140
- raise "Status name #{name.inspect} for issue #{key} not found in #{board.possible_statuses.collect(&:name).inspect}"
176
+ unless status
177
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
178
+ # list this status as one it actually found
179
+ found_statuses = board.possible_statuses.to_s
180
+
181
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
182
+
183
+ message = +'The history for issue '
184
+ message << key
185
+ message << ' references the status ('
186
+ message << "#{name.inspect}:#{id.inspect}"
187
+ message << ') that can\'t be found. We are guessing that this belongs to the '
188
+ message << status.category.to_s
189
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
190
+ message << 'details on defining statuses.'
191
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
192
+ end
193
+
194
+ status
141
195
  end
142
196
 
143
197
  def first_status_change_after_created
144
- @changes.find { |change| change.status? && change.artificial? == false }&.time
198
+ status_changes.find { |change| change.artificial? == false }
145
199
  end
146
200
 
147
201
  def first_time_in_status_category *category_names
148
- @changes.each do |change|
149
- next unless change.status?
202
+ category_ids = find_status_category_ids_by_names category_names
150
203
 
151
- category = find_status_by_name(change.value).category_name
152
- return change.time if category_names.include? category
204
+ status_changes.each do |change|
205
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
206
+ id = to_status.category.id
207
+ return change if category_ids.include? id
153
208
  end
154
209
  nil
155
210
  end
156
211
 
212
+ def first_time_visible_on_board
213
+ visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
214
+ return first_time_in_status(*visible_status_ids) unless board.scrum?
215
+
216
+ # For scrum boards, an issue is only visible when BOTH conditions are true simultaneously:
217
+ # 1. Its status is in a visible column
218
+ # 2. It is in an active sprint
219
+ # At each moment one condition becomes true, check if the other is already true.
220
+ candidates = []
221
+
222
+ status_changes.each do |change|
223
+ next unless visible_status_ids.include?(change.value_id)
224
+ candidates << change if in_active_sprint_at?(change.time)
225
+ end
226
+
227
+ sprint_entry_events.each do |effective_time, representative_change|
228
+ candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
229
+ end
230
+
231
+ candidates.min_by(&:time)
232
+ end
233
+
234
+ def reasons_not_visible_on_board
235
+ reasons = []
236
+ reasons << 'Not in an active sprint' if board.scrum? && sprints.none?(&:active?)
237
+ unless board.visible_columns.any? { |c| c.status_ids.include?(status.id) }
238
+ reasons << 'Status is not configured for any visible column on the board'
239
+ end
240
+ reasons
241
+ end
242
+
243
+ def visible_on_board?
244
+ reasons_not_visible_on_board.empty?
245
+ end
246
+
247
+ # If this issue will ever be in an active sprint then return the time that it
248
+ # was first added to that sprint, whether or not the sprint was active at that
249
+ # time. Although it seems like an odd thing to calculate, it's a reasonable proxy
250
+ # for 'ready' in cases where the team doesn't have an explicit 'ready' status.
251
+ # You'd be better off with an explicit 'ready' but sometimes that's not an option.
252
+ def first_time_added_to_active_sprint
253
+ unless board.scrum?
254
+ raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
255
+ "issue=#{key}, board=#{board.inspect}"
256
+ end
257
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
258
+
259
+ matching_changes = []
260
+ all_datas = []
261
+
262
+ @changes.each do |change|
263
+ next unless change.sprint?
264
+
265
+ added_sprint_ids = change.value_id - change.old_value_id
266
+ added_sprint_ids.each do |id|
267
+ data = data_clazz.new
268
+ data.sprint_id = id
269
+ data.change = change
270
+ data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
271
+ all_datas << data
272
+ end
273
+
274
+ removed_sprint_ids = change.old_value_id - change.value_id
275
+ removed_sprint_ids.each do |id|
276
+ data = all_datas.find { |d| d.sprint_id == id }
277
+ # It's possible for an issue to be created inside a sprint and therefore for
278
+ # that add-to-sprint not show in the history.
279
+ next unless data
280
+
281
+ all_datas.delete(data)
282
+ next if data.sprint_start.nil? || data.sprint_start >= change.time
283
+
284
+ matching_changes << data.change
285
+ end
286
+ end
287
+
288
+ # There can't be any more removes so whatever is left is a valid option
289
+ # Now all we care about is if the sprint has started.
290
+ all_datas.each do |data|
291
+ matching_changes << data.change if data.sprint_start
292
+ end
293
+
294
+ matching_changes.min_by(&:time)
295
+ end
296
+
297
+ def find_sprint_start_end sprint_id:, change:
298
+ # There are two different places that sprint data could be found. In theory all
299
+ # sprints would be found in both places. In practice, sometimes what we need is
300
+ # in one or the other but not both.
301
+
302
+ # First look in the actual sprints json. If any issues are in this sprint then it should
303
+ # be here.
304
+ sprint = board.sprints.find { |s| s.id == sprint_id }
305
+ if sprint
306
+ return [nil, nil] if sprint.future?
307
+
308
+ return [sprint.start_time, sprint.completed_time]
309
+ end
310
+
311
+ # Then look at the sprints inside the issue. Even though the field id may be specified,
312
+ # that custom field may not be present. This happens if it was in that sprint but was
313
+ # then removed, whether or not that sprint had ever started.
314
+ sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
315
+ if sprint_data
316
+ return [nil, nil] if sprint_data['state'] == 'future'
317
+
318
+ start = parse_time(sprint_data['startDate'])
319
+ stop = parse_time(sprint_data['completeDate'])
320
+ return [start, stop]
321
+ end
322
+
323
+ # If we got this far then the sprint can't be found anywhere, so we pretend that it never
324
+ # started. Is this guaranteed to be true? No. In theory if all issues were removed from
325
+ # an active sprint then it would also disappear, even though it had started. Nothing we
326
+ # can do to detect that edge-case though.
327
+ [nil, nil]
328
+ end
329
+
157
330
  def parse_time text
158
- Time.parse(text).getlocal(@timezone_offset)
331
+ if text.nil?
332
+ nil
333
+ elsif text.is_a? String
334
+ Time.parse(text).getlocal(@timezone_offset)
335
+ else
336
+ Time.at(text / 1000).getlocal(@timezone_offset)
337
+ end
159
338
  end
160
339
 
161
340
  def created
@@ -163,20 +342,28 @@ class Issue
163
342
  parse_time @raw['fields']['created'] if @raw['fields']['created']
164
343
  end
165
344
 
345
+ def time_created
346
+ @changes.first
347
+ end
348
+
166
349
  def updated
167
350
  parse_time @raw['fields']['updated']
168
351
  end
169
352
 
170
353
  def first_resolution
171
- @changes.find { |change| change.resolution? }&.time
354
+ @changes.find { |change| change.resolution? }
172
355
  end
173
356
 
174
357
  def last_resolution
175
- @changes.reverse.find { |change| change.resolution? }&.time
358
+ @changes.reverse.find { |change| change.resolution? }
176
359
  end
177
360
 
178
361
  def assigned_to
179
- @raw['fields']&.[]('assignee')&.[]('displayName')
362
+ @raw['fields']['assignee']&.[]('displayName')
363
+ end
364
+
365
+ def assigned_to_icon_url
366
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
180
367
  end
181
368
 
182
369
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -244,13 +431,10 @@ class Issue
244
431
 
245
432
  blocked_statuses = settings['blocked_statuses']
246
433
  stalled_statuses = settings['stalled_statuses']
247
- unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
248
- raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
249
- "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
250
- end
251
434
 
252
435
  blocked_link_texts = settings['blocked_link_text']
253
436
  stalled_threshold = settings['stalled_threshold_days']
437
+ flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
254
438
 
255
439
  blocking_issue_keys = []
256
440
 
@@ -259,11 +443,13 @@ class Issue
259
443
  previous_change_time = created
260
444
 
261
445
  blocking_status = nil
446
+ blocking_is_blocked = false
262
447
  flag = nil
448
+ flag_reason = nil
263
449
 
264
450
  # This mock change is to force the writing of one last entry at the end of the time range.
265
451
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
266
- mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
452
+ mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
267
453
 
268
454
  (changes + [mock_change]).each do |change|
269
455
  previous_was_active = false if check_for_stalled(
@@ -273,17 +459,20 @@ class Issue
273
459
  blocking_stalled_changes: result
274
460
  )
275
461
 
276
- if change.flagged?
277
- flag = change.value
278
- flag = nil if change.value == ''
462
+ if change.flagged? && flagged_means_blocked
463
+ flag, flag_reason = blocked_stalled_changes_flag_logic change
279
464
  elsif change.status?
280
465
  blocking_status = nil
281
- if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
466
+ blocking_is_blocked = false
467
+ if blocked_statuses.find_by_id(change.value_id)
468
+ blocking_status = change.value
469
+ blocking_is_blocked = true
470
+ elsif stalled_statuses.find_by_id(change.value_id)
282
471
  blocking_status = change.value
283
472
  end
284
473
  elsif change.link?
285
474
  # Example: "This issue is satisfied by ANON-30465"
286
- unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
475
+ unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
287
476
  puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
288
477
  next
289
478
  end
@@ -299,8 +488,9 @@ class Issue
299
488
 
300
489
  new_change = BlockedStalledChange.new(
301
490
  flagged: flag,
491
+ flag_reason: flag_reason,
302
492
  status: blocking_status,
303
- status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
493
+ status_is_blocking: blocking_status.nil? || blocking_is_blocked,
304
494
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
305
495
  time: change.time
306
496
  )
@@ -319,6 +509,7 @@ class Issue
319
509
  hack = result.pop
320
510
  result << BlockedStalledChange.new(
321
511
  flagged: hack.flag,
512
+ flag_reason: hack.flag_reason,
322
513
  status: hack.status,
323
514
  status_is_blocking: hack.status_is_blocking,
324
515
  blocking_issue_keys: hack.blocking_issue_keys,
@@ -330,6 +521,28 @@ class Issue
330
521
  result
331
522
  end
332
523
 
524
+ def blocked_stalled_changes_flag_logic change
525
+ flag = change.value
526
+ flag = nil if change.value == ''
527
+ if flag
528
+ # When the user is adding a comment to explain why a flag was set, the flag is set immediately
529
+ # and the comment is inserted after the user hits enter, which means that there is some time
530
+ # gap. If a comment happened shortly after the flag was set, we assume they're linked. This
531
+ # won't always be true and so there will be false positives, but it's a reasonable assumption.
532
+ max_seconds_between_flag_and_comment = 30
533
+ comment_change = changes.find do |c|
534
+ c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
535
+ end
536
+ flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
537
+ # Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
538
+ flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
539
+ flag_reason = nil if flag_reason&.empty?
540
+ else
541
+ flag_reason = nil
542
+ end
543
+ [flag, flag_reason]
544
+ end
545
+
333
546
  def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
334
547
  stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
335
548
 
@@ -362,6 +575,45 @@ class Issue
362
575
  inserted_stalled
363
576
  end
364
577
 
578
+ # return [number of active seconds, total seconds] that this issue had up to the end_time.
579
+ # It does not include data before issue start or after issue end
580
+ def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
581
+ issue_start, issue_stop = started_stopped_times
582
+ return [0.0, 0.0] if !issue_start || issue_start > end_time
583
+
584
+ value_add_time = 0.0
585
+ end_time = issue_stop if issue_stop && issue_stop < end_time
586
+
587
+ active_start = nil
588
+ blocked_stalled_changes(end_time: end_time, settings: settings).each_with_index do |change, index|
589
+ break if change.time > end_time
590
+
591
+ if index.zero?
592
+ active_start = change.time if change.active?
593
+ next
594
+ end
595
+
596
+ # Already active and we just got another active.
597
+ next if active_start && change.active?
598
+
599
+ if change.active?
600
+ active_start = change.time
601
+ elsif active_start && change.time >= issue_start
602
+ # Not active now but we have been. Record the active time.
603
+ change_delta = change.time - [issue_start, active_start].max
604
+ value_add_time += change_delta
605
+ active_start = nil
606
+ end
607
+ end
608
+
609
+ if active_start
610
+ change_delta = end_time - [issue_start, active_start].max
611
+ value_add_time += change_delta if change_delta.positive?
612
+ end
613
+
614
+ [value_add_time, end_time - issue_start]
615
+ end
616
+
365
617
  def all_subtask_activity_times
366
618
  subtask_activity_times = []
367
619
  @subtasks.each do |subtask|
@@ -371,8 +623,6 @@ class Issue
371
623
  end
372
624
 
373
625
  def expedited?
374
- return false unless @board&.project_config
375
-
376
626
  names = @board.project_config.settings['expedited_priority_names']
377
627
  return false unless names
378
628
 
@@ -489,85 +739,205 @@ class Issue
489
739
  /(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
490
740
  /(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
491
741
  comparison = project_code1 <=> project_code2
492
- comparison = id1 <=> id2 if comparison.zero?
742
+ comparison = id1.to_i <=> id2.to_i if comparison.zero?
493
743
  comparison
494
744
  end
495
745
 
496
- def dump
497
- result = +''
498
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
499
-
500
- assignee = raw['fields']['assignee']
501
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
502
-
503
- raw['fields']['issuelinks'].each do |link|
504
- result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
505
- result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
746
+ def discard_changes_before cutoff_time
747
+ rejected_any = false
748
+ @changes.reject! do |change|
749
+ reject = change.status? && change.time <= cutoff_time && change.artificial? == false
750
+ if reject
751
+ (@discarded_changes ||= []) << change
752
+ rejected_any = true
753
+ end
754
+ reject
506
755
  end
507
- changes.each do |change|
508
- value = change.value
509
- old_value = change.old_value
510
756
 
511
- message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
512
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
513
- message << compact_text(value).inspect
514
- message << " (#{change.author})"
515
- message << ' <<artificial entry>>' if change.artificial?
516
- result << message << "\n"
517
- end
518
- result
757
+ (@discarded_change_times ||= []) << cutoff_time if rejected_any
758
+ end
759
+
760
+ def dump
761
+ IssuePrinter.new(self).to_s
519
762
  end
520
763
 
521
764
  def done?
522
765
  if artificial? || board.cycletime.nil?
523
766
  # This was probably loaded as a linked issue, which means we don't know what board it really
524
- # belonged to. The best we can do is look at the status category. This case should be rare but
525
- # it can happen.
526
- status.category_name == 'Done'
767
+ # belonged to. The best we can do is look at the status key
768
+ status.category.done?
527
769
  else
528
770
  board.cycletime.done? self
529
771
  end
530
772
  end
531
773
 
774
+ def started_stopped_times
775
+ board.cycletime.started_stopped_times(self)
776
+ end
777
+
778
+ def started_stopped_dates
779
+ board.cycletime.started_stopped_dates(self)
780
+ end
781
+
782
+ def status_changes
783
+ @changes.select { |change| change.status? }
784
+ end
785
+
786
+ def status_resolution_at_done
787
+ done_time = started_stopped_times.last
788
+ return [nil, nil] if done_time.nil?
789
+
790
+ status_change = nil
791
+ resolution = nil
792
+
793
+ @changes.each do |change|
794
+ break if change.time > done_time
795
+
796
+ status_change = change if change.status?
797
+ resolution = change.value if change.resolution?
798
+ end
799
+
800
+ status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
801
+ [status, resolution]
802
+ end
803
+
804
+ def sprints
805
+ sprint_ids = []
806
+
807
+ changes.each do |change|
808
+ next unless change.sprint?
809
+
810
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
811
+ end
812
+ sprint_ids.flatten!
813
+
814
+ board.sprints.select { |s| sprint_ids.include? s.id }
815
+ end
816
+
817
+ def started_sprints
818
+ sprints.reject { |sprint| sprint.future? }
819
+ end
820
+
821
+ def compact_text text, max: 60
822
+ return '' if text.nil?
823
+
824
+ text = if text.is_a? Hash
825
+ @board.project_config.atlassian_document_format.to_text(text)
826
+ else
827
+ text
828
+ end
829
+ text = text.gsub(/\s+/, ' ').strip
830
+ text = "#{text[0...max]}..." if text.length > max
831
+ text
832
+ end
833
+
532
834
  private
533
835
 
534
- def assemble_author raw
535
- raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
836
+ # Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
837
+ # Skips sprints that were removed before they activated.
838
+ def sprint_entry_events
839
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
840
+ events = []
841
+ in_sprint = []
842
+
843
+ @changes.each do |change|
844
+ next unless change.sprint?
845
+
846
+ (change.value_id - change.old_value_id).each do |sprint_id|
847
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
848
+ in_sprint << data_clazz.new(sprint_id, sprint_start, change.time, change) if sprint_start
849
+ end
850
+
851
+ (change.old_value_id - change.value_id).each do |sprint_id|
852
+ data = in_sprint.find { |d| d.sprint_id == sprint_id }
853
+ next unless data
854
+
855
+ in_sprint.delete(data)
856
+ next if data.sprint_start >= change.time # sprint hadn't activated before removal
857
+
858
+ effective_time = [data.add_time, data.sprint_start].max
859
+ events << [effective_time, sprint_change_at(effective_time, data.change)]
860
+ end
861
+ end
862
+
863
+ in_sprint.each do |data|
864
+ effective_time = [data.add_time, data.sprint_start].max
865
+ events << [effective_time, sprint_change_at(effective_time, data.change)]
866
+ end
867
+
868
+ events
869
+ end
870
+
871
+ def sprint_change_at effective_time, change
872
+ return change if effective_time == change.time
873
+
874
+ ChangeItem.new(
875
+ raw: { 'field' => 'Sprint', 'toString' => 'Sprint activated', 'to' => '0', 'from' => nil, 'fromString' => nil },
876
+ author_raw: nil,
877
+ time: effective_time,
878
+ artificial: true
879
+ )
880
+ end
881
+
882
+ def in_active_sprint_at? time
883
+ active_ids = []
884
+ @changes.each do |change|
885
+ break if change.time > time
886
+ next unless change.sprint?
887
+
888
+ (change.value_id - change.old_value_id).each do |sprint_id|
889
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
890
+ active_ids << sprint_id if sprint_start && sprint_start <= time
891
+ end
892
+ (change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
893
+ end
894
+ active_ids.any?
895
+ end
896
+
897
+ def in_visible_status_at? time, visible_status_ids
898
+ last = status_changes.reverse.find { |c| c.time <= time }
899
+ last && visible_status_ids.include?(last.value_id)
536
900
  end
537
901
 
538
902
  def load_history_into_changes
539
903
  @raw['changelog']['histories']&.each do |history|
540
904
  created = parse_time(history['created'])
541
905
 
542
- # It should be impossible to not have an author but we've seen it in production
543
- author = assemble_author history
544
906
  history['items']&.each do |item|
545
- @changes << ChangeItem.new(raw: item, time: created, author: author)
907
+ if item['field'] == 'status' && item['to'].nil?
908
+ to_name = item['toString']
909
+ matches = board.possible_statuses.find_all_by_name(to_name)
910
+ guessed_id, id_note = if matches.length == 1
911
+ [matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
912
+ elsif matches.length > 1
913
+ ['0', "Multiple statuses named #{to_name.inspect} exist (ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
914
+ else
915
+ ['0', "No known status named #{to_name.inspect}. Using id 0."]
916
+ end
917
+ board.project_config.file_system.warning(
918
+ "Issue #{key} has a status change without a 'to' id " \
919
+ "(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
920
+ )
921
+ item = item.merge('to' => guessed_id)
922
+ end
923
+
924
+ @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
546
925
  end
547
926
  end
548
927
  end
549
928
 
550
929
  def load_comments_into_changes
551
930
  @raw['fields']['comment']['comments']&.each do |comment|
552
- raw = {
931
+ raw = comment.merge({
553
932
  'field' => 'comment',
554
933
  'to' => comment['id'],
555
934
  'toString' => comment['body']
556
- }
557
- author = assemble_author comment
935
+ })
558
936
  created = parse_time(comment['created'])
559
- @changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
937
+ @changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
560
938
  end
561
939
  end
562
940
 
563
- def compact_text text, max = 60
564
- return nil if text.nil?
565
-
566
- text = text.gsub(/\s+/, ' ').strip
567
- text = "#{text[0..max]}..." if text.length > max
568
- text
569
- end
570
-
571
941
  def sort_changes!
572
942
  @changes.sort! do |a, b|
573
943
  # It's common that a resolved will happen at the same time as a status change.
@@ -585,6 +955,9 @@ class Issue
585
955
  first_status = nil
586
956
  first_status_id = nil
587
957
 
958
+ # There won't be a created timestamp in cases where this was a linked issue
959
+ return unless @raw['fields']['created']
960
+
588
961
  created_time = parse_time @raw['fields']['created']
589
962
  first_change = @changes.find { |change| change.field == field_name }
590
963
  if first_change.nil?
@@ -598,10 +971,21 @@ class Issue
598
971
  first_status = first_change.old_value
599
972
  first_status_id = first_change.old_value_id
600
973
  end
601
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
974
+
975
+ creator = raw['fields']['creator']
976
+ ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
602
977
  'field' => field_name,
603
978
  'to' => first_status_id,
604
979
  'toString' => first_status
605
980
  }
606
981
  end
982
+
983
+ def find_status_category_ids_by_names category_names
984
+ category_names.filter_map do |name|
985
+ list = board.possible_statuses.find_all_categories_by_name name
986
+ raise "No status categories found for name: #{name}" if list.empty?
987
+
988
+ list
989
+ end.flatten.collect(&:id)
990
+ end
607
991
  end