jirametrics 1.0.0

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 (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