jirametrics 2.6 → 2.7

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 (39) 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 +5 -7
  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 +45 -6
  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 -1
  22. data/lib/jirametrics/examples/standard_project.rb +10 -7
  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 +113 -0
  27. data/lib/jirametrics/html/data_quality_report.erb +12 -0
  28. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  29. data/lib/jirametrics/html_report_config.rb +2 -1
  30. data/lib/jirametrics/issue.rb +62 -10
  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 +5 -3
@@ -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,113 @@
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
+ <mo>x</mo>
29
+ <mn>100%</mn>
30
+ </math>
31
+ </div>
32
+ <div style="background: yellow">Note that for this calculation to be accurate, we must be moving items into a
33
+ blocked or stalled state the moment we stop working on it, and most teams don't do that.
34
+ So be aware that your team may have to change their behaviours if you want this chart to be useful.
35
+ </div>
36
+ HTML
37
+
38
+ init_configuration_block block do
39
+ grouping_rules do |issue, rule|
40
+ active_time, total_time = issue.flow_efficiency_numbers end_time: time_range.end
41
+ flow_efficiency = active_time * 100.0 / total_time
42
+
43
+ if flow_efficiency > 99.0
44
+ rule.label = '~100%'
45
+ rule.color = 'green'
46
+ elsif flow_efficiency < 30.0
47
+ rule.label = '< 30%'
48
+ rule.color = 'orange'
49
+ else
50
+ rule.label = 'The rest'
51
+ rule.color = 'black'
52
+ end
53
+ end
54
+ end
55
+
56
+ @percentage_lines = []
57
+ @highest_cycletime = 0
58
+ end
59
+
60
+ def run
61
+ data_sets = group_issues(completed_issues_in_range include_unstarted: false).filter_map do |rules, issues|
62
+ create_dataset(issues: issues, label: rules.label, color: rules.color)
63
+ end
64
+
65
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
66
+
67
+ wrap_and_render(binding, __FILE__)
68
+ end
69
+
70
+ def to_days seconds
71
+ seconds / 60 / 60 / 24
72
+ end
73
+
74
+ def create_dataset issues:, label:, color:
75
+ return nil if issues.empty?
76
+
77
+ data = issues.filter_map do |issue|
78
+ active_time, total_time = issue.flow_efficiency_numbers(
79
+ end_time: time_range.end, settings: settings
80
+ )
81
+
82
+ active_days = to_days(active_time)
83
+ total_days = to_days(total_time)
84
+ flow_efficiency = active_time * 100.0 / total_time
85
+
86
+ if flow_efficiency.nan?
87
+ # If this happens then something is probably misconfigured. We've seen it in production though
88
+ # so we have to handle it.
89
+ file_system.log(
90
+ "Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
91
+ )
92
+ flow_efficiency = 0.0
93
+ end
94
+
95
+ {
96
+ y: active_days,
97
+ x: total_days,
98
+ title: [
99
+ "#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
100
+ " total: #{total_days.round(1)} days," \
101
+ " active: #{active_days.round(1)} days"
102
+ ]
103
+ }
104
+ end
105
+ {
106
+ label: label,
107
+ data: data,
108
+ fill: false,
109
+ showLine: false,
110
+ backgroundColor: color
111
+ }
112
+ end
113
+ end
@@ -113,6 +113,18 @@
113
113
  end
114
114
  %>
115
115
 
116
+ <%
117
+ problems = problems_for :incomplete_subtasks_when_issue_done
118
+ unless problems.empty?
119
+ %>
120
+ <p>
121
+ <span class="quality_note_bullet">⮕</span> <%= label_issues problems.size %> issues were marked as done while subtasks were still not done.
122
+ <%= collapsible_issues_panel problems %>
123
+ </p>
124
+ <%
125
+ end
126
+ %>
127
+
116
128
  <%
117
129
  problems = problems_for :issue_on_multiple_boards
118
130
  unless problems.empty?
@@ -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>
@@ -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
@@ -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
@@ -24,22 +24,30 @@ class ProjectConfig
24
24
  @all_boards = {}
25
25
  @settings = load_settings
26
26
  @id = id
27
+ @has_loaded_data = false
27
28
  end
28
29
 
29
30
  def evaluate_next_level
30
31
  instance_eval(&@block) if @block
31
32
  end
32
33
 
33
- def run
34
- unless aggregated_project?
35
- load_all_boards
36
- @id = guess_project_id
37
- load_status_category_mappings
38
- load_project_metadata
39
- load_sprints
40
- end
34
+ def load_data
35
+ return if @has_loaded_data
36
+
37
+ @has_loaded_data = true
38
+ load_all_boards
39
+ @id = guess_project_id
40
+ load_status_category_mappings
41
+ load_project_metadata
42
+ load_sprints
43
+ end
44
+
45
+ def run load_only: false
46
+ load_data unless aggregated_project?
41
47
  anonymize_data if @anonymizer_needed
42
48
 
49
+ return if load_only
50
+
43
51
  @board_configs.each do |board_config|
44
52
  board_config.run
45
53
  end
@@ -117,7 +125,7 @@ class ProjectConfig
117
125
  board_id = $1.to_i
118
126
  load_board board_id: board_id, filename: "#{@target_path}#{file}"
119
127
  end
120
- raise "No boards found for #{@file_prefix} in #{@target_path.inspect}" if @all_boards.empty?
128
+ raise "No boards found for #{@file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
121
129
  end
122
130
 
123
131
  def load_board board_id:, filename:
@@ -232,7 +240,7 @@ class ProjectConfig
232
240
  end
233
241
 
234
242
  def load_project_metadata
235
- filename = "#{@target_path}/#{file_prefix}_meta.json"
243
+ filename = File.join @target_path, "#{file_prefix}_meta.json"
236
244
  json = JSON.parse(file_system.load(filename))
237
245
 
238
246
  @data_version = json['version'] || 1
@@ -243,7 +251,7 @@ class ProjectConfig
243
251
 
244
252
  @jira_url = json['jira_url']
245
253
  rescue Errno::ENOENT
246
- puts "== Can't load files from the target directory. Did you forget to download first? =="
254
+ file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
247
255
  raise
248
256
  end
249
257
 
@@ -259,7 +267,7 @@ class ProjectConfig
259
267
  unless all_boards&.size == 1
260
268
  message = "If the board_id isn't set then we look for all board configurations in the target" \
261
269
  ' directory. '
262
- if all_boards.nil? || all_boards.empty?
270
+ if all_boards.empty?
263
271
  message += ' In this case, we couldn\'t find any configuration files in the target directory.'
264
272
  else
265
273
  message += 'If there is only one, we use that. In this case we found configurations for' \
@@ -291,21 +299,21 @@ class ProjectConfig
291
299
  end
292
300
 
293
301
  def issues
294
- raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
295
-
296
302
  unless @issues
297
- if @aggregate_config
303
+ if aggregated_project?
298
304
  raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
299
305
  'declaration but none are here. Check your config.'
300
306
  end
307
+ load_data if all_boards.empty?
301
308
 
302
309
  timezone_offset = exporter.timezone_offset
303
310
 
304
- issues_path = "#{@target_path}#{file_prefix}_issues/"
311
+ issues_path = File.join @target_path, "#{file_prefix}_issues"
305
312
  if File.exist?(issues_path) && File.directory?(issues_path)
306
313
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
307
314
  else
308
- puts "Can't find issues in #{path}. Has a download been done?"
315
+ file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
316
+ return []
309
317
  end
310
318
 
311
319
  # Attach related issues
@@ -351,8 +359,8 @@ class ProjectConfig
351
359
  raise "No boards found for project #{name.inspect}" if all_boards.empty?
352
360
 
353
361
  if all_boards.size != 1
354
- puts "Multiple boards are in use for project #{name.inspect}. " \
355
- "Picked #{default_board.name.inspect} to attach issues to."
362
+ file_system.log "Multiple boards are in use for project #{name.inspect}. " \
363
+ "Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
356
364
  end
357
365
  default_board
358
366
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SelfOrIssueDispatcher
4
+ # rubocop:disable Style/ArgumentsForwarding
4
5
  def method_missing method_name, *args, &block
5
6
  raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
6
7
 
@@ -8,6 +9,7 @@ module SelfOrIssueDispatcher
8
9
  issue.__send__ method_name, *args, &block
9
10
  end
10
11
  end
12
+ # rubocop:enable Style/ArgumentsForwarding
11
13
 
12
14
  def respond_to_missing?(method_name, include_all = false)
13
15
  ::Issue.method_defined?(method_name.to_sym) || super
@@ -18,7 +18,7 @@ class SprintBurndown < ChartBase
18
18
  attr_accessor :board_id
19
19
 
20
20
  def initialize
21
- super()
21
+ super
22
22
 
23
23
  @summary_stats = {}
24
24
  header_text 'Sprint burndown'
@@ -126,7 +126,7 @@ class SprintBurndown < ChartBase
126
126
  currently_in_sprint = false
127
127
  change_data = []
128
128
 
129
- issue_completed_time = issue.board.cycletime.stopped_time(issue)
129
+ issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
130
130
  completed_has_been_tracked = false
131
131
 
132
132
  issue.changes.each do |change|
@@ -38,7 +38,7 @@ class Status
38
38
  end
39
39
 
40
40
  def to_s
41
- result = +"Status(name=#{@name.inspect}," \
41
+ result = "Status(name=#{@name.inspect}," \
42
42
  " id=#{@id.inspect}," \
43
43
  " category_name=#{@category_name.inspect}," \
44
44
  " category_id=#{@category_id.inspect}," \
@@ -68,4 +68,8 @@ class StatusCollection
68
68
  def empty? = @list.empty?
69
69
  def clear = @list.clear
70
70
  def delete(object) = @list.delete(object)
71
+
72
+ def inspect
73
+ "StatusCollection(#{@list.collect(&:inspect).join(', ')})"
74
+ end
71
75
  end
@@ -82,7 +82,7 @@ class ThroughputChart < ChartBase
82
82
  def throughput_dataset periods:, completed_issues:
83
83
  periods.collect do |period|
84
84
  closed_issues = completed_issues.filter_map do |issue|
85
- stop_date = issue.board.cycletime.stopped_time(issue)&.to_date
85
+ stop_date = issue.board.cycletime.started_stopped_times(issue).last&.to_date
86
86
  [stop_date, issue] if stop_date && period.include?(stop_date)
87
87
  end
88
88
 
data/lib/jirametrics.rb CHANGED
@@ -3,9 +3,13 @@
3
3
  require 'thor'
4
4
 
5
5
  class JiraMetrics < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
6
10
  option :config
7
11
  option :name
8
- desc 'export only', "Export data into either reports or CSV's as per the configuration"
12
+ desc 'export', "Export data into either reports or CSV's as per the configuration"
9
13
  def export
10
14
  load_config options[:config]
11
15
  Exporter.instance.export(name_filter: options[:name] || '*')
@@ -13,7 +17,7 @@ class JiraMetrics < Thor
13
17
 
14
18
  option :config
15
19
  option :name
16
- desc 'download only', 'Download data from Jira'
20
+ desc 'download', 'Download data from Jira'
17
21
  def download
18
22
  load_config options[:config]
19
23
  Exporter.instance.download(name_filter: options[:name] || '*')
@@ -21,7 +25,7 @@ class JiraMetrics < Thor
21
25
 
22
26
  option :config
23
27
  option :name
24
- desc 'download and export', 'Same as running download, followed by export'
28
+ desc 'go', 'Same as running download, followed by export'
25
29
  def go
26
30
  load_config options[:config]
27
31
  Exporter.instance.download(name_filter: options[:name] || '*')
@@ -30,6 +34,13 @@ class JiraMetrics < Thor
30
34
  Exporter.instance.export(name_filter: options[:name] || '*')
31
35
  end
32
36
 
37
+ option :config
38
+ desc 'info', 'Dump information about one issue'
39
+ def info keys
40
+ load_config options[:config]
41
+ Exporter.instance.info(keys, name_filter: options[:name] || '*')
42
+ end
43
+
33
44
  private
34
45
 
35
46
  def load_config config_file
@@ -69,6 +80,7 @@ class JiraMetrics < Thor
69
80
  require 'jirametrics/daily_wip_by_parent_chart'
70
81
  require 'jirametrics/aging_work_in_progress_chart'
71
82
  require 'jirametrics/cycletime_scatterplot'
83
+ require 'jirametrics/flow_efficiency_scatterplot'
72
84
  require 'jirametrics/sprint_issue_change_data'
73
85
  require 'jirametrics/cycletime_histogram'
74
86
  require 'jirametrics/daily_wip_by_blocked_stalled_chart'
@@ -97,6 +109,4 @@ class JiraMetrics < Thor
97
109
  require 'jirametrics/board'
98
110
  load config_file
99
111
  end
100
-
101
- # Dir.foreach('lib/jirametrics') {|file| puts "require 'jirametrics/#{$1}'" if file =~ /^(.+)\.rb$/}
102
112
  end