jirametrics 2.6 → 2.7

Sign up to get free protection for your applications and to get access to all the features.
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