jirametrics 2.10 → 2.13

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  3. data/lib/jirametrics/aging_work_table.rb +56 -13
  4. data/lib/jirametrics/atlassian_document_format.rb +156 -0
  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 +38 -16
  9. data/lib/jirametrics/chart_base.rb +7 -5
  10. data/lib/jirametrics/css_variable.rb +1 -1
  11. data/lib/jirametrics/cycletime_config.rb +1 -1
  12. data/lib/jirametrics/daily_view.rb +274 -0
  13. data/lib/jirametrics/downloader.rb +61 -21
  14. data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
  15. data/lib/jirametrics/estimation_configuration.rb +25 -0
  16. data/lib/jirametrics/examples/standard_project.rb +2 -0
  17. data/lib/jirametrics/exporter.rb +2 -2
  18. data/lib/jirametrics/file_config.rb +1 -1
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  21. data/lib/jirametrics/html/aging_work_table.erb +7 -3
  22. data/lib/jirametrics/html/index.css +82 -2
  23. data/lib/jirametrics/html/index.erb +25 -1
  24. data/lib/jirametrics/html_report_config.rb +2 -0
  25. data/lib/jirametrics/issue.rb +69 -28
  26. data/lib/jirametrics/issue_collection.rb +33 -0
  27. data/lib/jirametrics/jira_gateway.rb +8 -1
  28. data/lib/jirametrics/project_config.rb +24 -7
  29. data/lib/jirametrics/settings.json +2 -1
  30. data/lib/jirametrics/sprint.rb +1 -0
  31. data/lib/jirametrics/sprint_burndown.rb +35 -33
  32. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  33. data/lib/jirametrics/status.rb +3 -0
  34. data/lib/jirametrics/status_collection.rb +7 -0
  35. data/lib/jirametrics/user.rb +12 -0
  36. data/lib/jirametrics.rb +5 -0
  37. metadata +8 -2
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
22
22
  </div>
23
23
  HTML
24
24
 
25
- @y_axis_label = 'Story Point Estimates'
26
25
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
27
  @y_axis_sort_order = nil
29
28
 
30
29
  instance_eval(&configuration_block)
31
30
  end
32
31
 
33
32
  def run
33
+ if @y_axis_label.nil?
34
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
+ @y_axis_label = "Estimated #{text}"
36
+ end
34
37
  data_sets = scan_issues
35
38
 
36
39
  return '' if data_sets.empty?
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
41
44
  def scan_issues
42
45
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
46
 
47
+ estimation_units = current_board.estimation_configuration.units
44
48
  @has_aging_data = !aging_hash.empty?
45
49
 
46
50
  [
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
53
57
  # We sort so that the smaller circles are in front of the bigger circles.
54
58
  data = hash.sort(&hash_sorter).collect do |key, values|
55
59
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
60
+
61
+ title = [
62
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
63
+ "Cycletime: #{label_days(cycle_time)}, " \
64
+ "#{values.size} issues"
65
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
66
+
59
67
  {
60
68
  'x' => cycle_time,
61
69
  'y' => estimate,
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
77
85
  end
78
86
  end
79
87
 
88
+ def estimate_label estimate:, estimation_units:
89
+ if @y_axis_type == 'linear'
90
+ if estimation_units == :story_points
91
+ estimate_label = "#{estimate}pts"
92
+ elsif estimation_units == :seconds
93
+ estimate_label = label_days estimate
94
+ end
95
+ end
96
+ estimate_label = estimate.to_s if estimate_label.nil?
97
+ estimate_label
98
+ end
99
+
80
100
  def split_into_completed_and_aging issues:
81
101
  aging_hash = {}
82
102
  completed_hash = {}
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
126
146
  end
127
147
  end
128
148
 
129
- def story_points_at issue:, start_time:
130
- story_points = nil
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
131
152
  issue.changes.each do |change|
132
- return story_points if change.time >= start_time
153
+ return estimate if change.time >= start_time
133
154
 
134
- story_points = change.value if change.story_points?
155
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
156
+ estimate = change.value
157
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
158
+ end
135
159
  end
136
- story_points
160
+ estimate
137
161
  end
138
162
 
139
163
  def y_axis label:, sort_order: nil, &block
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -58,6 +58,8 @@ class Exporter
58
58
  type: :header
59
59
  end
60
60
 
61
+ daily_view
62
+
61
63
  cycletime_scatterplot do
62
64
  show_trend_lines
63
65
  end
@@ -79,8 +79,8 @@ class Exporter
79
79
  file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
80
  else
81
81
  selected.each do |project, issue|
82
- file_system.log "\nProject #{project.name}"
83
- file_system.log issue.dump
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
84
84
  end
85
85
  end
86
86
  end
@@ -13,7 +13,7 @@ class FileConfig
13
13
  end
14
14
 
15
15
  def run
16
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
17
17
  instance_eval(&@block)
18
18
 
19
19
  if @columns
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
27
27
  </mfrac>
28
28
  </math>
29
29
  </div>
30
- <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
30
+ <div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
31
31
  blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
32
  So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
33
  </div>
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
6
6
  {
7
7
  type: 'bar',
8
8
  data: {
9
- labels: [<%= column_headings.collect(&:inspect).join(',') %>],
9
+ labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
10
10
  datasets: <%= JSON.generate(data_sets) %>
11
11
  },
12
12
  options: {
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
22
22
  labelString: 'Date Completed'
23
23
  },
24
24
  grid: {
25
- color: <%= CssVariable['--grid-line-color'].to_json %>
25
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
26
+ z: 1 // draw the grid lines on top of the bars
26
27
  },
28
+ stacked: true
27
29
  },
28
30
  y: {
29
31
  scaleLabel: {
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
35
37
  text: 'Age in days'
36
38
  },
37
39
  grid: {
38
- color: <%= CssVariable['--grid-line-color'].to_json %>
40
+ color: <%= CssVariable['--grid-line-color'].to_json %>,
41
+ z: 1 // draw the grid lines on top of the bars
39
42
  },
43
+ stacked: true,
44
+ max: <%= (@max_age * 1.1).to_i %>
40
45
  }
41
46
  },
42
47
  plugins: {
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
44
49
  callbacks: {
45
50
  label: function(context) {
46
51
  if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
47
- return "85% of the issues, leave this column in "+context.dataset.data[context.dataIndex]+" days";
52
+ let full_data = <%= @bar_data.inspect %>;
53
+ let columnIndex = context.dataIndex;
54
+ let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
55
+ return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
48
56
  }
49
57
  else {
50
- return context.dataset.data[context.dataIndex].title
58
+ return context.dataset.data[context.dataIndex].title;
51
59
  }
52
60
  }
53
61
  }
62
+ },
63
+ legend: {
64
+ labels: {
65
+ filter: function(item, chart) {
66
+ // Logic to remove a particular legend item goes here
67
+ return !item.text.includes('%');
68
+ }
69
+ }
54
70
  }
71
+
55
72
  }
56
73
  }
57
74
  });
@@ -1,11 +1,13 @@
1
1
  <table class='standard'>
2
2
  <thead>
3
3
  <tr>
4
- <th>Age (days)</th>
5
- <th>E</th>
6
- <th>B</th>
4
+ <th title="Age in days">Age</th>
5
+ <th title="Expedited">E</th>
6
+ <th title="Blocked / Stalled">B/S</th>
7
+ <th title="Priority">P</th>
7
8
  <th>Issue</th>
8
9
  <th>Status</th>
10
+ <th>Forecast</th>
9
11
  <th>Fix versions</th>
10
12
  <% if any_scrum_boards %>
11
13
  <th>Sprints</th>
@@ -28,6 +30,7 @@
28
30
  <td style="text-align: right;"><%= issue_age || 'Not started' %></td>
29
31
  <td><%= expedited_text(issue) %></td>
30
32
  <td><%= blocked_text(issue) %></td>
33
+ <td><%= priority_text(issue) %></td>
31
34
  <td>
32
35
  <% parent_hierarchy(issue).each_with_index do |parent, index| %>
33
36
  <% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
@@ -41,6 +44,7 @@
41
44
  <% end %>
42
45
  </td>
43
46
  <td><%= format_status issue.status, board: issue.board %></td>
47
+ <td><%= dates_text(issue) %></td>
44
48
  <td><%= fix_versions_text(issue) %></td>
45
49
  <% if any_scrum_boards %>
46
50
  <td><%= sprints_text(issue) %></td>
@@ -2,6 +2,7 @@
2
2
  --body-background: white;
3
3
  --default-text-color: black;
4
4
  --grid-line-color: lightgray;
5
+ --warning-banner: yellow;
5
6
 
6
7
  --cycletime-scatterplot-overall-trendline-color: gray;
7
8
 
@@ -27,8 +28,16 @@
27
28
  --throughput_chart_total_line_color: gray;
28
29
 
29
30
  --aging-work-in-progress-chart-shading-color: lightgray;
31
+ --aging-work-in-progress-chart-shading-50-color: #2E8BC0; // green;
32
+ --aging-work-in-progress-chart-shading-85-color: #ADD8E6; // yellow;
33
+ --aging-work-in-progress-chart-shading-98-color: #FF8A8A; // orange;
34
+ --aging-work-in-progress-chart-shading-100-color: #FF2E2E; // red;
35
+
30
36
  --aging-work-in-progress-by-age-trend-line-color: gray;
31
37
 
38
+ --aging-work-table-date-in-jeopardy: yellow;
39
+ --aging-work-table-date-overdue: red;
40
+
32
41
  --hierarchy-table-inactive-item-text-color: gray;
33
42
 
34
43
  --wip-chart-completed-color: #00ff00;
@@ -58,6 +67,9 @@
58
67
  --sprint-burndown-sprint-color-4: red;
59
68
  --sprint-burndown-sprint-color-5: brown;
60
69
 
70
+ --daily-view-selected-issue-background: lightgray;
71
+ --daily-view-issue-border: green;
72
+ --daily-view-selected-issue-border: red;
61
73
 
62
74
  }
63
75
 
@@ -133,8 +145,68 @@ ul.quality_report {
133
145
  border-top: 1px solid gray;
134
146
  }
135
147
 
148
+ div.daily_issue:hover {
149
+ background: var(--daily-view-selected-issue-background);
150
+ border-color: var(--daily-view-selected-issue-border);
151
+ }
152
+
153
+ div.daily_issue {
154
+ border: 1px solid var(--daily-view-issue-border);
155
+ padding: 0.5em;
156
+ .heading {
157
+ vertical-align: middle;
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ column-gap: 0.5em;
161
+ align-items: center;
162
+ }
163
+ table {
164
+ margin-left: 1em;
165
+ td {
166
+ vertical-align: top;
167
+ }
168
+ .time {
169
+ white-space: nowrap;
170
+ font-size: 0.8em;
171
+ }
172
+ }
173
+ .icon {
174
+ width: 1em;
175
+ height: 1em;
176
+ }
177
+ .account_id {
178
+ font-weight: bold;
179
+ }
180
+ .field {
181
+ border: 1px solid black;
182
+ color: white;
183
+ background: black;
184
+ padding-left: 0.2em;
185
+ padding-right: 0.2em;
186
+ border-radius: 0.2em;
187
+ }
188
+ .label {
189
+ border: 1px solid black;
190
+ padding-left: 0.2em;
191
+ padding-right: 0.2em;
192
+ border-radius: 0.2em;
193
+ }
194
+ margin-bottom: 0.5em;
195
+ }
196
+ div.child_issue:hover {
197
+ background: var(--body-background);
198
+ }
199
+ div.child_issue {
200
+ border: 1px dashed green;
201
+ margin: 0.2em;
202
+ margin-left: 1.5em;
203
+ padding: 0.5em;
204
+ }
205
+
136
206
  @media screen and (prefers-color-scheme: dark) {
137
207
  :root {
208
+ --warning-banner: #9F2B00;
209
+
138
210
  --non-working-days-color: #2f2f2f;
139
211
  --type-story-color: #6fb86f;
140
212
  --type-task-color: #0021b3;
@@ -150,8 +222,6 @@ ul.quality_report {
150
222
  --dead-color: black;
151
223
  --wip-chart-active-color: #2551c1;
152
224
 
153
- --aging-work-in-progress-chart-shading-color: #b4b4b4;
154
-
155
225
  --status-category-inprogress-color: #1c49bb;
156
226
 
157
227
  --cycletime-scatterplot-overall-trendline-color: gray;
@@ -165,6 +235,8 @@ ul.quality_report {
165
235
  --wip-chart-duration-two-weeks-or-less-color: #cf9400;
166
236
  --wip-chart-duration-four-weeks-or-less-color: #c25e00;
167
237
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
238
+
239
+ --daily-view-selected-issue-background: #474747;
168
240
  }
169
241
 
170
242
  h1 {
@@ -197,4 +269,12 @@ ul.quality_report {
197
269
  div.color_block {
198
270
  border: 1px solid lightgray;
199
271
  }
272
+
273
+ div.daily_issue {
274
+ .field {
275
+ color: var(--default-text-color);
276
+ }
277
+ }
278
+ }
279
+
200
280
  }
@@ -18,18 +18,42 @@
18
18
  document.getElementById(issues_id).style.display = 'none'
19
19
  }
20
20
  }
21
+
22
+ function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
23
+ let open_link = document.getElementById(open_link_id)
24
+ let close_link = document.getElementById(close_link_id)
25
+ let toggleable_element = document.getElementById(toggleable_id)
26
+
27
+ if(open_link.style.display == 'none') {
28
+ open_link.style.display = 'block'
29
+ close_link.style.display = 'none'
30
+ toggleable_element.style.display = 'none'
31
+ }
32
+ else {
33
+ open_link.style.display = 'none'
34
+ close_link.style.display = 'block'
35
+ toggleable_element.style.display = 'block'
36
+ }
37
+ }
21
38
  // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
22
39
  // in the other colour scheme.
23
40
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
24
41
  location.reload()
25
42
  })
26
-
27
43
  </script>
28
44
  <style>
29
45
  <%= css %>
30
46
  </style>
47
+ <script type="text/javascript">
48
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--default-text-color');
49
+ </script>
31
50
  </head>
32
51
  <body>
52
+ <noscript>
53
+ <div style="padding: 1em; background: red; color: white; font-size: 2em;">
54
+ Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
55
+ </div>
56
+ </noscript>
33
57
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
34
58
  <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
35
59
  </body>
@@ -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(
@@ -319,7 +345,7 @@ class Issue
319
345
  end
320
346
  elsif change.link?
321
347
  # Example: "This issue is satisfied by ANON-30465"
322
- unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
348
+ unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
323
349
  puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
324
350
  next
325
351
  end
@@ -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