jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. metadata +167 -0
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'english'
6
+
7
+ class Downloader
8
+ CURRENT_METADATA_VERSION = 4
9
+
10
+ attr_accessor :metadata, :quiet_mode, :logfile, :logfile_name
11
+
12
+ # For testing only
13
+ attr_reader :start_date_in_query
14
+
15
+ def initialize download_config:, json_file_loader: JsonFileLoader.new
16
+ @metadata = {}
17
+ @download_config = download_config
18
+ @target_path = @download_config.project_config.target_path
19
+ @json_file_loader = json_file_loader
20
+ @board_id_to_filter_id = {}
21
+
22
+ @issue_keys_downloaded_in_current_run = []
23
+ @issue_keys_pending_download = []
24
+ end
25
+
26
+ def run
27
+ log '', both: true
28
+ log @download_config.project_config.name, both: true
29
+
30
+ load_jira_config(@download_config.project_config.jira_config)
31
+ load_metadata
32
+
33
+ if @metadata['no-download']
34
+ log ' Skipping download. Found no-download in meta file', both: true
35
+ return
36
+ end
37
+
38
+ # board_ids = @download_config.board_ids
39
+
40
+ remove_old_files
41
+ download_statuses
42
+ find_board_ids.each do |id|
43
+ download_board_configuration board_id: id
44
+ download_issues board_id: id
45
+ end
46
+
47
+ save_metadata
48
+ end
49
+
50
+ def log text, both: false
51
+ @logfile&.puts text
52
+ puts text if both
53
+ end
54
+
55
+ def find_board_ids
56
+ ids = @download_config.project_config.board_configs.collect(&:id)
57
+ if ids.empty?
58
+ deprecated message: 'board_ids in the download block have been deprecated. See https://github.com/mikebowler/jira-export/wiki/Deprecated'
59
+ ids = @download_config.board_ids
60
+ end
61
+ raise 'Board ids must be specified' if ids.empty?
62
+
63
+ ids
64
+ end
65
+
66
+ def load_jira_config jira_config
67
+ @jira_url = jira_config['url']
68
+ @jira_email = jira_config['email']
69
+ @jira_api_token = jira_config['api_token']
70
+ @jira_personal_access_token = jira_config['personal_access_token']
71
+
72
+ raise 'When specifying an api-token, you must also specify email' if @jira_api_token && !@jira_email
73
+
74
+ if @jira_api_token && @jira_personal_access_token
75
+ raise "You can't specify both an api-token and a personal-access-token. They don't work together."
76
+ end
77
+
78
+ @cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
79
+ end
80
+
81
+ def call_command command
82
+ log " #{command.gsub(/\s+/, ' ')}"
83
+ result = `#{command}`
84
+ log result unless $CHILD_STATUS.success?
85
+ return result if $CHILD_STATUS.success?
86
+
87
+ log "Failed call with exit status #{$CHILD_STATUS.exitstatus}. See #{@logfile_name} for details", both: true
88
+ exit $CHILD_STATUS.exitstatus
89
+ end
90
+
91
+ def make_curl_command url:
92
+ command = 'curl'
93
+ command += ' -s'
94
+ command += ' -k' if @download_config.project_config.settings['ignore_ssl_errors']
95
+ command += " --cookie #{@cookies.inspect}" unless @cookies.empty?
96
+ command += " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
97
+ command += " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
98
+ command += ' --request GET'
99
+ command += ' --header "Accept: application/json"'
100
+ command += " --url \"#{url}\""
101
+ command
102
+ end
103
+
104
+ def download_issues board_id:
105
+ log " Downloading primary issues for board #{board_id}", both: true
106
+ path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
107
+ unless Dir.exist?(path)
108
+ log " Creating path #{path}"
109
+ Dir.mkdir(path)
110
+ end
111
+
112
+ filter_id = @board_id_to_filter_id[board_id]
113
+ jql = make_jql(filter_id: filter_id)
114
+ jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
115
+
116
+ log " Downloading linked issues for board #{board_id}", both: true
117
+ loop do
118
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
119
+ break if @issue_keys_pending_download.empty?
120
+
121
+ keys_to_request = @issue_keys_pending_download[0..99]
122
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
123
+ jql = "key in (#{keys_to_request.join(', ')})"
124
+ jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
125
+ end
126
+ end
127
+
128
+ def jira_search_by_jql jql:, initial_query:, board_id:, path:
129
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
130
+ jql = intercept_jql.call jql if intercept_jql
131
+
132
+ log " #{jql}"
133
+ escaped_jql = CGI.escape jql
134
+
135
+ max_results = 100
136
+ start_at = 0
137
+ total = 1
138
+ while start_at < total
139
+ command = make_curl_command url: "#{@jira_url}/rest/api/2/search" \
140
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
141
+
142
+ json = JSON.parse call_command(command)
143
+ exit_if_call_failed json
144
+
145
+ json['issues'].each do |issue_json|
146
+ issue_json['exporter'] = {
147
+ 'in_initial_query' => initial_query
148
+ }
149
+ identify_other_issues_to_be_downloaded issue_json
150
+ file = "#{issue_json['key']}-#{board_id}.json"
151
+ write_json(issue_json, File.join(path, file))
152
+ end
153
+
154
+ total = json['total'].to_i
155
+ max_results = json['maxResults']
156
+
157
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
158
+ log message, both: true
159
+
160
+ start_at += json['issues'].size
161
+ end
162
+ end
163
+
164
+ def identify_other_issues_to_be_downloaded raw_issue
165
+ issue = Issue.new raw: raw_issue, board: nil
166
+ @issue_keys_downloaded_in_current_run << issue.key
167
+
168
+ # Parent
169
+ parent_key = issue.parent_key(project_config: @download_config.project_config)
170
+ @issue_keys_pending_download << parent_key if parent_key
171
+
172
+ # Sub-tasks
173
+ issue.raw['fields']['subtasks'].each do |raw_subtask|
174
+ @issue_keys_pending_download << raw_subtask['key']
175
+ end
176
+
177
+ # Links
178
+ # We shouldn't blindly follow links as some, like cloners, aren't valuable and are just wasting time/effort
179
+ # to download
180
+ # issue.raw['fields']['issuelinks'].each do |raw_link|
181
+ # @issue_keys_pending_download << IssueLink(raw: raw_link).other_issue.key
182
+ # end
183
+ end
184
+
185
+ def exit_if_call_failed json
186
+ # Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
187
+ return unless json['errorMessages'] || json['errorMessage']
188
+
189
+ log "Download failed. See #{@logfile_name} for details.", both: true
190
+ log " #{JSON.pretty_generate(json)}"
191
+ exit 1
192
+ end
193
+
194
+ def download_statuses
195
+ log ' Downloading all statuses', both: true
196
+ command = make_curl_command url: "\"#{@jira_url}/rest/api/2/status\""
197
+ json = JSON.parse call_command(command)
198
+
199
+ write_json json, "#{@target_path}#{@download_config.project_config.file_prefix}_statuses.json"
200
+ end
201
+
202
+ def download_board_configuration board_id:
203
+ log " Downloading board configuration for board #{board_id}", both: true
204
+ command = make_curl_command url: "#{@jira_url}/rest/agile/1.0/board/#{board_id}/configuration"
205
+
206
+ json = JSON.parse call_command(command)
207
+ exit_if_call_failed json
208
+
209
+ @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
210
+ # @board_configuration = json if @download_config.board_ids.size == 1
211
+
212
+ file_prefix = @download_config.project_config.file_prefix
213
+ write_json json, "#{@target_path}#{file_prefix}_board_#{board_id}_configuration.json"
214
+
215
+ download_sprints board_id: board_id if json['type'] == 'scrum'
216
+ end
217
+
218
+ def download_sprints board_id:
219
+ log " Downloading sprints for board #{board_id}", both: true
220
+ file_prefix = @download_config.project_config.file_prefix
221
+ max_results = 100
222
+ start_at = 0
223
+ is_last = false
224
+
225
+ while is_last == false
226
+ command = make_curl_command url: "#{@jira_url}/rest/agile/1.0/board/#{board_id}/sprint?" \
227
+ "maxResults=#{max_results}&startAt=#{start_at}"
228
+ json = JSON.parse call_command(command)
229
+ exit_if_call_failed json
230
+
231
+ write_json json, "#{@target_path}#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json"
232
+ is_last = json['isLast']
233
+ max_results = json['maxResults']
234
+ start_at += json['values'].size
235
+ end
236
+ end
237
+
238
+ def write_json json, filename
239
+ file_path = File.dirname(filename)
240
+ FileUtils.mkdir_p file_path unless File.exist?(file_path)
241
+
242
+ File.write(filename, JSON.pretty_generate(json))
243
+ end
244
+
245
+ def metadata_pathname
246
+ "#{@target_path}#{@download_config.project_config.file_prefix}_meta.json"
247
+ end
248
+
249
+ def load_metadata
250
+ # If we've never done a download before then this file won't be there. That's ok.
251
+ return unless File.exist? metadata_pathname
252
+
253
+ hash = JSON.parse(File.read metadata_pathname)
254
+
255
+ # Only use the saved metadata if the version number is the same one that we're currently using.
256
+ # If the cached data is in an older format then we're going to throw most of it away.
257
+ @cached_data_format_is_current = (hash['version'] || 0) == CURRENT_METADATA_VERSION
258
+ if @cached_data_format_is_current
259
+ hash.each do |key, value|
260
+ value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
261
+ @metadata[key] = value
262
+ end
263
+ end
264
+
265
+ # Even if this is the old format, we want to obey this one tag
266
+ @metadata['no-download'] = hash['no-download'] if hash['no-download']
267
+ end
268
+
269
+ def save_metadata
270
+ @metadata['version'] = CURRENT_METADATA_VERSION
271
+ @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
272
+
273
+ if @download_date_range.nil?
274
+ log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
275
+ today = Date.today
276
+ @download_date_range = (today - 7)..today
277
+ end
278
+
279
+ @metadata['earliest_date_start'] = @download_date_range.begin if @metadata['earliest_date_start'].nil?
280
+
281
+ @metadata['date_start'] = @download_date_range.begin
282
+ @metadata['date_end'] = @download_date_range.end
283
+
284
+ @metadata['jira_url'] = @jira_url
285
+
286
+ write_json @metadata, metadata_pathname
287
+ end
288
+
289
+ def remove_old_files
290
+ file_prefix = @download_config.project_config.file_prefix
291
+ Dir.foreach @target_path do |file|
292
+ next unless file =~ /^#{file_prefix}_\d+\.json$/
293
+
294
+ File.unlink "#{@target_path}#{file}"
295
+ end
296
+
297
+ return if @cached_data_format_is_current
298
+
299
+ # Also throw away all the previously downloaded issues.
300
+ path = File.join @target_path, "#{file_prefix}_issues"
301
+ return unless File.exist? path
302
+
303
+ Dir.foreach path do |file|
304
+ next unless file =~ /\.json$/
305
+
306
+ File.unlink File.join(path, file)
307
+ end
308
+ end
309
+
310
+ def make_jql filter_id:, today: Date.today
311
+ segments = []
312
+ segments << "filter=#{filter_id}"
313
+
314
+ unless @download_config.rolling_date_count.nil?
315
+ @download_date_range = (today.to_date - @download_config.rolling_date_count)..today.to_date
316
+
317
+ # For an incremental download, we want to query from the end of the previous one, not from the
318
+ # beginning of the full range.
319
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
320
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
321
+
322
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
323
+ # had an update during the range.
324
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
325
+
326
+ # Pick up any issues that had a status change in the range
327
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
328
+ end_date_text = today.strftime '%Y-%m-%d'
329
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
330
+ find_in_range = %((updated >= "#{start_date_text} 00:00" AND updated <= "#{end_date_text} 23:59"))
331
+
332
+ segments << "(#{find_in_range} OR #{catch_all})"
333
+ end
334
+
335
+ segments.join ' AND '
336
+ end
337
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Exporter
4
+ def aggregated_project name:, project_names:
5
+ project name: name do
6
+ puts name
7
+ aggregate do
8
+ project_names.each do |project_name|
9
+ include_issues_from project_name
10
+ end
11
+ end
12
+
13
+ file_prefix name
14
+
15
+ file do
16
+ file_suffix '.html'
17
+ issues.reject! do |issue|
18
+ %w[Sub-task Epic].include? issue.type
19
+ end
20
+
21
+ html_report do
22
+ cycletime_scatterplot do
23
+ show_trend_lines
24
+ grouping_rules do |issue, rules|
25
+ rules.label = issue.board.name
26
+ end
27
+ end
28
+ aging_work_in_progress_chart
29
+ aging_work_table do
30
+ age_cutoff 21
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Exporter
4
+ def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {}, default_board: nil
5
+ project name: name do
6
+ puts name
7
+
8
+ settings['blocked_link_text'] = ['is blocked by']
9
+ file_prefix file_prefix
10
+ download do
11
+ rolling_date_count 90
12
+ end
13
+
14
+ boards.each_key do |board_id|
15
+ block = boards[board_id]
16
+ if block == :default
17
+ block = lambda do |_|
18
+ start_at first_time_in_status_category('In Progress')
19
+ stop_at still_in_status_category('Done')
20
+ end
21
+ end
22
+ board id: board_id do
23
+ cycletime(&block)
24
+ expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
25
+ end
26
+ end
27
+
28
+ file do
29
+ file_suffix '.html'
30
+ issues.reject! do |issue|
31
+ %w[Sub-task Epic].include? issue.type
32
+ end
33
+
34
+ issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
35
+
36
+ html_report do
37
+ board_id default_board if default_board
38
+
39
+ html "<H1>#{file_prefix}</H1>", type: :header
40
+ boards.each_key do |id|
41
+ board = find_board id
42
+ html "<div><a href='#{board.url}'>#{id} #{board.name}</a></div>",
43
+ type: :header
44
+ end
45
+
46
+ discard_changes_before status_becomes: (starting_status || :backlog)
47
+
48
+ cycletime_scatterplot do
49
+ show_trend_lines
50
+ end
51
+ cycletime_scatterplot do # Epics
52
+ header_text 'Parents only'
53
+ filter_issues { |i| i.parent }
54
+ end
55
+ cycletime_histogram
56
+ cycletime_histogram do
57
+ grouping_rules do |issue, rules|
58
+ rules.label = issue.board.cycletime.stopped_time(issue).to_date.strftime('%b %Y')
59
+ end
60
+ end
61
+
62
+ throughput_chart do
63
+ description_text '<h2>Number of items completed, grouped by issue type</h2>'
64
+ end
65
+ throughput_chart do
66
+ header_text nil
67
+ description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
68
+ grouping_rules do |issue, rules|
69
+ if issue.resolution
70
+ rules.label = "#{issue.status.name}:#{issue.resolution}"
71
+ else
72
+ rules.label = issue.status.name
73
+ end
74
+ end
75
+ end
76
+
77
+ aging_work_in_progress_chart
78
+ aging_work_bar_chart
79
+ aging_work_table
80
+ daily_wip_by_age_chart
81
+ daily_wip_by_blocked_stalled_chart
82
+ expedited_chart
83
+ sprint_burndown
84
+ story_point_accuracy_chart
85
+ # story_point_accuracy_chart do
86
+ # header_text nil
87
+ # description_text nil
88
+ # y_axis(sort_order: %w[Story Task Defect], label: 'TShirt Sizes') { |issue, _started_time| issue.type }
89
+ # end
90
+
91
+ dependency_chart do
92
+ link_rules do |link, rules|
93
+ case link.name
94
+ when 'Cloners'
95
+ rules.ignore
96
+ when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
97
+ rules.merge_bidirectional keep: 'outward'
98
+ rules.merge_bidirectional keep: 'outward'
99
+ when 'Sync'
100
+ rules.use_bidirectional_arrows
101
+ # rules.line_color = 'red'
102
+ else
103
+ puts "name=#{link.name}, label=#{link.label}"
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class ExpeditedChart < ChartBase
6
+ EXPEDITED_SEGMENT = Object.new.tap do |segment|
7
+ def segment.to_json *_args
8
+ <<~SNIPPET
9
+ {
10
+ borderColor: ctx => expedited(ctx, 'red') || notExpedited(ctx, 'gray'),
11
+ borderDash: ctx => notExpedited(ctx, [6, 6])
12
+ }
13
+ SNIPPET
14
+ end
15
+ end
16
+
17
+ attr_accessor :issues, :cycletime, :possible_statuses, :date_range
18
+ attr_reader :expedited_label
19
+
20
+ def initialize
21
+ super()
22
+
23
+ header_text 'Expedited work'
24
+ description_text <<-HTML
25
+ <p>
26
+ This chart only shows issues that have been expedited at some point. We care about these as
27
+ any form of expedited work will affect the entire system and will slow down non-expedited work.
28
+ Refer to this article on
29
+ <a href="https://improvingflow.com/2021/06/16/classes-of-service.html">classes of service</a>
30
+ for a longer explanation on why we want to avoid expedited work.
31
+ </p>
32
+ <p>
33
+ The lines indicate time that this issue was expedited. When the line is red then the issue was
34
+ expedited at that time. When it's gray then it wasn't. Orange dots indicate the date the work
35
+ was started and green dots represent the completion date. Lastly, the vertical height of the
36
+ lines/dots indicates how long it's been since this issue was created.
37
+ </p>
38
+ HTML
39
+ end
40
+
41
+ def run
42
+ data_sets = find_expedited_issues.collect do |issue|
43
+ make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
44
+ end.compact
45
+
46
+ if data_sets.empty?
47
+ '<h1>Expedited work</h1>There is no expedited work in this time period.'
48
+ else
49
+ wrap_and_render(binding, __FILE__)
50
+ end
51
+ end
52
+
53
+ def prepare_expedite_data issue
54
+ expedite_start = nil
55
+ result = []
56
+ expedited_priority_names = issue.board.expedited_priority_names
57
+
58
+ issue.changes.each do |change|
59
+ next unless change.priority?
60
+
61
+ if expedited_priority_names.include? change.value
62
+ expedite_start = change.time
63
+ elsif expedite_start
64
+ start_date = expedite_start.to_date
65
+ stop_date = change.time.to_date
66
+
67
+ if date_range.include?(start_date) || date_range.include?(stop_date) ||
68
+ (start_date < date_range.begin && stop_date > date_range.end)
69
+
70
+ result << [expedite_start, :expedite_start]
71
+ result << [change.time, :expedite_stop]
72
+ end
73
+ expedite_start = nil
74
+ end
75
+ end
76
+
77
+ # If expedite_start is still set then we never ended.
78
+ result << [expedite_start, :expedite_start] if expedite_start
79
+ result
80
+ end
81
+
82
+ def find_expedited_issues
83
+ expedited_issues = @issues.reject do |issue|
84
+ prepare_expedite_data(issue).empty?
85
+ end
86
+
87
+ expedited_issues.sort { |a, b| a.key_as_i <=> b.key_as_i }
88
+ end
89
+
90
+ def later_date date1, date2
91
+ return date1 if date2.nil?
92
+ return date2 if date1.nil?
93
+
94
+ [date1, date2].max
95
+ end
96
+
97
+ def make_point issue:, time:, label:, expedited:
98
+ {
99
+ y: (time.to_date - issue.created.to_date).to_i + 1,
100
+ x: time.to_date.to_s,
101
+ title: ["#{issue.key} #{label} : #{issue.summary}"],
102
+ expedited: (expedited ? 1 : 0)
103
+ }
104
+ end
105
+
106
+ def make_expedite_lines_data_set issue:, expedite_data:
107
+ cycletime = issue.board.cycletime
108
+ started_time = cycletime.started_time(issue)
109
+ stopped_time = cycletime.stopped_time(issue)
110
+
111
+ expedite_data << [started_time, :issue_started] if started_time
112
+ expedite_data << [stopped_time, :issue_stopped] if stopped_time
113
+ expedite_data.sort! { |a, b| a[0] <=> b[0] }
114
+
115
+ # If none of the data would be visible on the chart then skip it.
116
+ return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
117
+
118
+ data = []
119
+ dot_colors = []
120
+ point_styles = []
121
+ expedited = false
122
+
123
+ expedite_data.each do |time, action|
124
+ case action
125
+ when :issue_started
126
+ data << make_point(issue: issue, time: time, label: 'Started', expedited: expedited)
127
+ dot_colors << 'orange'
128
+ point_styles << 'rect'
129
+ when :issue_stopped
130
+ data << make_point(issue: issue, time: time, label: 'Completed', expedited: expedited)
131
+ dot_colors << 'green'
132
+ point_styles << 'rect'
133
+ when :expedite_start
134
+ data << make_point(issue: issue, time: time, label: 'Expedited', expedited: true)
135
+ dot_colors << 'red'
136
+ point_styles << 'circle'
137
+ expedited = true
138
+ when :expedite_stop
139
+ data << make_point(issue: issue, time: time, label: 'Not expedited', expedited: false)
140
+ dot_colors << 'gray'
141
+ point_styles << 'circle'
142
+ expedited = false
143
+ else
144
+ raise "Unexpected action: #{action}"
145
+ end
146
+ end
147
+
148
+ unless expedite_data.empty?
149
+ last_change_time = expedite_data[-1][0].to_date
150
+ if last_change_time && last_change_time <= date_range.end && stopped_time.nil?
151
+ data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
152
+ dot_colors << 'blue' # It won't be visible so it doesn't matter
153
+ point_styles << 'dash'
154
+ end
155
+ end
156
+
157
+ {
158
+ type: 'line',
159
+ label: issue.key,
160
+ data: data,
161
+ fill: false,
162
+ showLine: true,
163
+ backgroundColor: dot_colors,
164
+ pointBorderColor: 'black',
165
+ pointStyle: point_styles,
166
+ segment: EXPEDITED_SEGMENT
167
+ }
168
+ end
169
+ end