jirametrics 2.6 → 2.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +6 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +6 -6
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
  5. data/lib/jirametrics/aging_work_table.rb +4 -5
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +14 -12
  8. data/lib/jirametrics/chart_base.rb +16 -10
  9. data/lib/jirametrics/cycletime_config.rb +26 -7
  10. data/lib/jirametrics/cycletime_histogram.rb +1 -1
  11. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  12. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  13. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  14. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  15. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  16. data/lib/jirametrics/data_quality_report.rb +166 -7
  17. data/lib/jirametrics/dependency_chart.rb +3 -4
  18. data/lib/jirametrics/discard_changes_before.rb +1 -1
  19. data/lib/jirametrics/downloader.rb +14 -13
  20. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  21. data/lib/jirametrics/examples/aggregated_project.rb +1 -3
  22. data/lib/jirametrics/examples/standard_project.rb +10 -9
  23. data/lib/jirametrics/expedited_chart.rb +1 -2
  24. data/lib/jirametrics/exporter.rb +25 -0
  25. data/lib/jirametrics/file_system.rb +1 -1
  26. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  27. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  28. data/lib/jirametrics/html/index.css +10 -3
  29. data/lib/jirametrics/html_report_config.rb +2 -1
  30. data/lib/jirametrics/issue.rb +63 -11
  31. data/lib/jirametrics/jira_gateway.rb +1 -1
  32. data/lib/jirametrics/project_config.rb +27 -19
  33. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  34. data/lib/jirametrics/sprint_burndown.rb +2 -2
  35. data/lib/jirametrics/status.rb +1 -1
  36. data/lib/jirametrics/status_collection.rb +4 -0
  37. data/lib/jirametrics/throughput_chart.rb +1 -1
  38. data/lib/jirametrics.rb +15 -5
  39. metadata +8 -7
  40. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -41,8 +41,8 @@ class Downloader
41
41
  remove_old_files
42
42
  download_statuses
43
43
  find_board_ids.each do |id|
44
- download_board_configuration board_id: id
45
- download_issues board_id: id
44
+ board = download_board_configuration board_id: id
45
+ download_issues board: board
46
46
  end
47
47
 
48
48
  save_metadata
@@ -64,19 +64,19 @@ class Downloader
64
64
  ids
65
65
  end
66
66
 
67
- def download_issues board_id:
68
- log " Downloading primary issues for board #{board_id}", both: true
67
+ def download_issues board:
68
+ log " Downloading primary issues for board #{board.id}", both: true
69
69
  path = "#{@target_path}#{@download_config.project_config.file_prefix}_issues/"
70
70
  unless Dir.exist?(path)
71
71
  log " Creating path #{path}"
72
72
  Dir.mkdir(path)
73
73
  end
74
74
 
75
- filter_id = @board_id_to_filter_id[board_id]
75
+ filter_id = @board_id_to_filter_id[board.id]
76
76
  jql = make_jql(filter_id: filter_id)
77
- jira_search_by_jql(jql: jql, initial_query: true, board_id: board_id, path: path)
77
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
78
78
 
79
- log " Downloading linked issues for board #{board_id}", both: true
79
+ log " Downloading linked issues for board #{board.id}", both: true
80
80
  loop do
81
81
  @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
82
82
  break if @issue_keys_pending_download.empty?
@@ -84,11 +84,11 @@ class Downloader
84
84
  keys_to_request = @issue_keys_pending_download[0..99]
85
85
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
86
86
  jql = "key in (#{keys_to_request.join(', ')})"
87
- jira_search_by_jql(jql: jql, initial_query: false, board_id: board_id, path: path)
87
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
88
88
  end
89
89
  end
90
90
 
91
- def jira_search_by_jql jql:, initial_query:, board_id:, path:
91
+ def jira_search_by_jql jql:, initial_query:, board:, path:
92
92
  intercept_jql = @download_config.project_config.settings['intercept_jql']
93
93
  jql = intercept_jql.call jql if intercept_jql
94
94
 
@@ -108,8 +108,8 @@ class Downloader
108
108
  issue_json['exporter'] = {
109
109
  'in_initial_query' => initial_query
110
110
  }
111
- identify_other_issues_to_be_downloaded issue_json
112
- file = "#{issue_json['key']}-#{board_id}.json"
111
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
112
+ file = "#{issue_json['key']}-#{board.id}.json"
113
113
 
114
114
  @file_system.save_json(json: issue_json, filename: File.join(path, file))
115
115
  end
@@ -124,8 +124,8 @@ class Downloader
124
124
  end
125
125
  end
126
126
 
127
- def identify_other_issues_to_be_downloaded raw_issue
128
- issue = Issue.new raw: raw_issue, board: nil
127
+ def identify_other_issues_to_be_downloaded raw_issue:, board:
128
+ issue = Issue.new raw: raw_issue, board: board
129
129
  @issue_keys_downloaded_in_current_run << issue.key
130
130
 
131
131
  # Parent
@@ -171,6 +171,7 @@ class Downloader
171
171
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
172
172
 
173
173
  download_sprints board_id: board_id if json['type'] == 'scrum'
174
+ Board.new raw: json
174
175
  end
175
176
 
176
177
  def download_sprints board_id:
@@ -83,8 +83,7 @@ class EstimateAccuracyChart < ChartBase
83
83
 
84
84
  issues.each do |issue|
85
85
  cycletime = issue.board.cycletime
86
- start_time = cycletime.started_time(issue)
87
- stop_time = cycletime.stopped_time(issue)
86
+ start_time, stop_time = cycletime.started_stopped_times(issue)
88
87
 
89
88
  next unless start_time
90
89
 
@@ -3,8 +3,6 @@
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
5
  #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
7
- #
8
6
  # The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
9
7
  # S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
10
8
  # single team. For that reason, we look at slightly different things that we would on a single team board.
@@ -33,7 +31,7 @@ class Exporter
33
31
  html '<h1>Boards included in this report</h1><ul>', type: :header
34
32
  board_lines = []
35
33
  included_projects.each do |project|
36
- project.all_boards.values.each do |board|
34
+ project.all_boards.each_value do |board|
37
35
  board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
38
36
  end
39
37
  end
@@ -2,12 +2,11 @@
2
2
 
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
- #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
7
5
  class Exporter
8
6
  def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
9
7
  default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
10
- rolling_date_count: 90, no_earlier_than: nil
8
+ rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
+ show_experimental_charts: false
11
10
 
12
11
  project name: name do
13
12
  puts name
@@ -38,11 +37,14 @@ class Exporter
38
37
  end
39
38
  end
40
39
 
40
+ issues.reject! do |issue|
41
+ ignore_types.include? issue.type
42
+ end
43
+
44
+ discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
45
+
41
46
  file do
42
47
  file_suffix '.html'
43
- issues.reject! do |issue|
44
- %w[Sub-task Epic].include? issue.type
45
- end
46
48
 
47
49
  issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
48
50
 
@@ -52,12 +54,10 @@ class Exporter
52
54
  html "<H1>#{name}</H1>", type: :header
53
55
  boards.each_key do |id|
54
56
  board = find_board id
55
- html "<div><a href='#{board.url}'>#{id} #{board.name}</a></div>",
57
+ html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
56
58
  type: :header
57
59
  end
58
60
 
59
- discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
60
-
61
61
  cycletime_scatterplot do
62
62
  show_trend_lines
63
63
  end
@@ -84,6 +84,7 @@ class Exporter
84
84
  daily_wip_by_age_chart
85
85
  daily_wip_by_blocked_stalled_chart
86
86
  daily_wip_by_parent_chart
87
+ flow_efficiency_scatterplot if show_experimental_charts
87
88
  expedited_chart
88
89
  sprint_burndown
89
90
  estimate_accuracy_chart
@@ -109,8 +109,7 @@ class ExpeditedChart < ChartBase
109
109
 
110
110
  def make_expedite_lines_data_set issue:, expedite_data:
111
111
  cycletime = issue.board.cycletime
112
- started_time = cycletime.started_time(issue)
113
- stopped_time = cycletime.stopped_time(issue)
112
+ started_time, stopped_time = cycletime.started_stopped_times(issue)
114
113
 
115
114
  expedite_data << [started_time, :issue_started] if started_time
116
115
  expedite_data << [stopped_time, :issue_stopped] if stopped_time
@@ -72,6 +72,31 @@ class Exporter
72
72
  puts "Full output from downloader in #{file_system.logfile_name}"
73
73
  end
74
74
 
75
+ def info keys, name_filter:
76
+ selected = []
77
+ each_project_config(name_filter: name_filter) do |project|
78
+ project.evaluate_next_level
79
+ # next if project.aggregated_project?
80
+
81
+ project.run load_only: true
82
+ project.board_configs.each do |board_config|
83
+ board_config.run
84
+ end
85
+ project.issues.each do |issue|
86
+ selected << [project, issue] if keys.include? issue.key
87
+ end
88
+ rescue => e # rubocop:disable Style/RescueStandardError
89
+ # This happens when we're attempting to load an aggregated project because it hasn't been
90
+ # properly initialized. Since we don't care about aggregated projects, we just ignore it.
91
+ raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
92
+ end
93
+
94
+ selected.each do |project, issue|
95
+ puts "\nProject #{project.name}"
96
+ puts issue.dump
97
+ end
98
+ end
99
+
75
100
  def each_project_config name_filter:
76
101
  @project_configs.each do |project|
77
102
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -29,7 +29,7 @@ class FileSystem
29
29
 
30
30
  def log message, also_write_to_stderr: false
31
31
  logfile.puts message
32
- $stderr.puts message if also_write_to_stderr
32
+ $stderr.puts message if also_write_to_stderr # rubocop:disable Style/StderrPuts
33
33
  end
34
34
 
35
35
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class FlowEfficiencyScatterplot < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ attr_accessor :possible_statuses
9
+
10
+ def initialize block
11
+ super()
12
+
13
+ header_text 'Flow Efficiency'
14
+ description_text <<-HTML
15
+ <div class="p">
16
+ This chart shows the active time against the the total time spent on a ticket.
17
+ <a href="https://improvingflow.com/2024/07/06/flow-efficiency.html">Flow efficiency</a> is the ratio
18
+ between these two numbers.
19
+ </div>
20
+ <div class="p">
21
+ <math>
22
+ <mn>Flow efficiency (%)</mn>
23
+ <mo>=</mo>
24
+ <mfrac>
25
+ <mrow><mn>Time adding value</mn></mrow>
26
+ <mrow><mn>Total time</mn></mrow>
27
+ </mfrac>
28
+ </math>
29
+ </div>
30
+ <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
31
+ blocked or stalled state the moment we stop working on it, and most teams don't do that.
32
+ So be aware that your team may have to change their behaviours if you want this chart to be useful.
33
+ </div>
34
+ HTML
35
+
36
+ init_configuration_block block do
37
+ grouping_rules do |issue, rule|
38
+ active_time, total_time = issue.flow_efficiency_numbers end_time: time_range.end
39
+ flow_efficiency = active_time * 100.0 / total_time
40
+
41
+ if flow_efficiency > 99.0
42
+ rule.label = '~100%'
43
+ rule.color = 'green'
44
+ elsif flow_efficiency < 30.0
45
+ rule.label = '< 30%'
46
+ rule.color = 'orange'
47
+ else
48
+ rule.label = 'The rest'
49
+ rule.color = 'black'
50
+ end
51
+ end
52
+ end
53
+
54
+ @percentage_lines = []
55
+ @highest_cycletime = 0
56
+ end
57
+
58
+ def run
59
+ data_sets = group_issues(completed_issues_in_range include_unstarted: false).filter_map do |rules, issues|
60
+ create_dataset(issues: issues, label: rules.label, color: rules.color)
61
+ end
62
+
63
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
64
+
65
+ wrap_and_render(binding, __FILE__)
66
+ end
67
+
68
+ def to_days seconds
69
+ seconds / 60 / 60 / 24
70
+ end
71
+
72
+ def create_dataset issues:, label:, color:
73
+ return nil if issues.empty?
74
+
75
+ data = issues.filter_map do |issue|
76
+ active_time, total_time = issue.flow_efficiency_numbers(
77
+ end_time: time_range.end, settings: settings
78
+ )
79
+
80
+ active_days = to_days(active_time)
81
+ total_days = to_days(total_time)
82
+ flow_efficiency = active_time * 100.0 / total_time
83
+
84
+ if flow_efficiency.nan?
85
+ # If this happens then something is probably misconfigured. We've seen it in production though
86
+ # so we have to handle it.
87
+ file_system.log(
88
+ "Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
89
+ )
90
+ flow_efficiency = 0.0
91
+ end
92
+
93
+ {
94
+ y: active_days,
95
+ x: total_days,
96
+ title: [
97
+ "#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
98
+ " total: #{total_days.round(1)} days," \
99
+ " active: #{active_days.round(1)} days"
100
+ ]
101
+ }
102
+ end
103
+ {
104
+ label: label,
105
+ data: data,
106
+ fill: false,
107
+ showLine: false,
108
+ backgroundColor: color
109
+ }
110
+ end
111
+ end
@@ -0,0 +1,85 @@
1
+ <div class="chart">
2
+ <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
3
+ </div>
4
+ <script>
5
+ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
6
+ type: 'scatter',
7
+ data: {
8
+ datasets: <%= JSON.generate(data_sets) %>
9
+ },
10
+ options: {
11
+ title: {
12
+ display: true,
13
+ text: "Cycletime Scatterplot"
14
+ },
15
+ responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
16
+ scales: {
17
+ x: {
18
+ scaleLabel: {
19
+ display: true,
20
+ labelString: 'Days'
21
+ },
22
+ title: {
23
+ display: true,
24
+ text: 'Total time (days)'
25
+ },
26
+ grid: {
27
+ color: <%= CssVariable['--grid-line-color'].to_json %>
28
+ },
29
+
30
+ },
31
+ y: {
32
+ scaleLabel: {
33
+ display: true,
34
+ labelString: 'Percentage',
35
+ min: 0,
36
+ max: <%= @highest_cycletime %>
37
+ },
38
+ title: {
39
+ display: true,
40
+ text: 'Time adding value (days)'
41
+ },
42
+ grid: {
43
+ color: <%= CssVariable['--grid-line-color'].to_json %>
44
+ },
45
+ }
46
+ },
47
+ plugins: {
48
+ tooltip: {
49
+ callbacks: {
50
+ label: function(context) {
51
+ return context.dataset.data[context.dataIndex].title
52
+ }
53
+ }
54
+ },
55
+ autocolors: false,
56
+ legend: {
57
+ onClick: (evt, legendItem, legend) => {
58
+ // Find the datasetMeta that corresponds to the item clicked
59
+ var i = 0
60
+ while(legendItem.text != legend.chart.getDatasetMeta(i).label) {
61
+ i++;
62
+ }
63
+ nextVisibility = !!legend.chart.getDatasetMeta(i).hidden;
64
+
65
+ // Hide/show the 85% line for that dataset
66
+ legend.chart.options.plugins.annotation.annotations["line"+(i/2)].display = nextVisibility;
67
+
68
+ // Hide/show the trendline for this dataset, if they were enabled. The trendline is always
69
+ // there but not always visible.
70
+ legend.chart.setDatasetVisibility(i+1, <%= !!@show_trend_lines %> && nextVisibility);
71
+
72
+ // Still run the default behaviour
73
+ Chart.defaults.plugins.legend.onClick(evt, legendItem, legend);
74
+ },
75
+ labels: {
76
+ filter: function(item, chart) {
77
+ // Logic to remove a particular legend item goes here
78
+ return !item.text.includes('Trendline');
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ });
85
+ </script>
@@ -99,9 +99,6 @@ table.standard {
99
99
  background-color: #eee;
100
100
  }
101
101
  }
102
- .quality_note_bullet {
103
- color: red;
104
- }
105
102
 
106
103
  .chart {
107
104
  background-color: white;
@@ -119,6 +116,16 @@ div.color_block {
119
116
  border: 1px solid black;
120
117
  }
121
118
 
119
+ ul.quality_report {
120
+ list-style-type: '⮕';
121
+ ::marker {
122
+ color: red;
123
+ }
124
+ li {
125
+ padding: 0.2em;
126
+ }
127
+ }
128
+
122
129
  @media screen and (prefers-color-scheme: dark) {
123
130
  :root {
124
131
  --non-working-days-color: #2f2f2f;
@@ -33,6 +33,7 @@ class HtmlReportConfig
33
33
  define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
34
34
  define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
35
35
  define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
36
+ define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
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'
@@ -145,7 +146,7 @@ class HtmlReportConfig
145
146
 
146
147
  @original_issue_times = {}
147
148
  issues_cutoff_times.each do |issue, cutoff_time|
148
- started = issue.board.cycletime.started_time(issue)
149
+ started = issue.board.cycletime.started_stopped_times(issue).first
149
150
  if started && started <= cutoff_time
150
151
  # We only need to log this if data was discarded
151
152
  @original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
@@ -13,6 +13,7 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ raise "No board for issue #{key}" if board.nil?
16
17
  return unless @raw['changelog']
17
18
 
18
19
  load_history_into_changes
@@ -140,7 +141,7 @@ class Issue
140
141
  @board.project_config.file_system.log(
141
142
  "Warning: Status name #{name.inspect} for issue #{key} not found in" \
142
143
  " #{board.possible_statuses.collect(&:name).inspect}" \
143
- "\n See Q1 in the FAQ for more details: https://github.com/mikebowler/jirametrics/wiki/FAQ\n",
144
+ "\n See https://jirametrics.org/faq/#q1\n",
144
145
  also_write_to_stderr: true
145
146
  )
146
147
  status = Status.new(name: name, category_name: 'In Progress')
@@ -259,7 +260,7 @@ class Issue
259
260
 
260
261
  blocked_link_texts = settings['blocked_link_text']
261
262
  stalled_threshold = settings['stalled_threshold_days']
262
- flagged_means_blocked = !!settings['flagged_means_blocked']
263
+ flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
263
264
 
264
265
  blocking_issue_keys = []
265
266
 
@@ -373,12 +374,11 @@ class Issue
373
374
 
374
375
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
375
376
  # It does not include data before issue start or after issue end
376
- def flow_efficiency_numbers end_time:, settings: {}
377
- issue_start = @board.cycletime.started_time(self)
377
+ def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
378
+ issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
378
379
  return [0.0, 0.0] if !issue_start || issue_start > end_time
379
380
 
380
381
  value_add_time = 0.0
381
- issue_stop = @board.cycletime.stopped_time(self)
382
382
  end_time = issue_stop if issue_stop && issue_stop < end_time
383
383
 
384
384
  active_start = nil
@@ -542,6 +542,20 @@ class Issue
542
542
  comparison
543
543
  end
544
544
 
545
+ def discard_changes_before cutoff_time
546
+ rejected_any = false
547
+ @changes.reject! do |change|
548
+ reject = change.status? && change.time <= cutoff_time && change.artificial? == false
549
+ if reject
550
+ (@discarded_changes ||= []) << change
551
+ rejected_any = true
552
+ end
553
+ reject
554
+ end
555
+
556
+ (@discarded_change_times ||= []) << cutoff_time if rejected_any
557
+ end
558
+
545
559
  def dump
546
560
  result = +''
547
561
  result << "#{key} (#{type}): #{compact_text summary, 200}\n"
@@ -549,21 +563,59 @@ class Issue
549
563
  assignee = raw['fields']['assignee']
550
564
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
551
565
 
552
- raw['fields']['issuelinks'].each do |link|
566
+ raw['fields']['issuelinks']&.each do |link|
553
567
  result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
554
568
  result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
555
569
  end
556
- changes.each do |change|
570
+ history = [] # time, type, detail
571
+
572
+ started_at, stopped_at = board.cycletime.started_stopped_times(self)
573
+ history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
574
+ history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
575
+
576
+ @discarded_change_times&.each do |time|
577
+ history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
578
+ end
579
+
580
+ (changes + (@discarded_changes || [])).each do |change|
557
581
  value = change.value
558
582
  old_value = change.old_value
559
583
 
560
- message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
584
+ message = +''
561
585
  message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
562
586
  message << compact_text(value).inspect
563
- message << " (#{change.author})"
564
- message << ' <<artificial entry>>' if change.artificial?
565
- result << message << "\n"
587
+ if change.artificial?
588
+ message << ' (Artificial entry)' if change.artificial?
589
+ else
590
+ message << " (Author: #{change.author})"
591
+ end
592
+ history << [change.time, change.field, message, change.artificial?]
593
+ end
594
+
595
+ result << " History:\n"
596
+ type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
597
+ history.sort! do |a, b|
598
+ if a[0] == b[0]
599
+ if a[1].nil?
600
+ 1
601
+ elsif b[1].nil?
602
+ -1
603
+ else
604
+ a[1] <=> b[1]
605
+ end
606
+ else
607
+ a[0] <=> b[0]
608
+ end
566
609
  end
610
+ history.each do |time, type, detail, _artificial|
611
+ if type.nil?
612
+ type = '-' * type_width
613
+ else
614
+ type = (' ' * (type_width - type.length)) << type
615
+ end
616
+ result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
617
+ end
618
+
567
619
  result
568
620
  end
569
621
 
@@ -16,7 +16,7 @@ class JiraGateway
16
16
  result = call_command command
17
17
  JSON.parse result
18
18
  rescue => e # rubocop:disable Style/RescueStandardError
19
- puts "Error #{e.inspect} when parsing result: #{result.inspect}"
19
+ raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
20
20
  end
21
21
 
22
22
  def call_command command