jirametrics 2.6 → 2.7.1

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