jirametrics 2.0 → 2.12.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +19 -26
  3. data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
  5. data/lib/jirametrics/aging_work_table.rb +84 -54
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +51 -23
  9. data/lib/jirametrics/board_config.rb +9 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  11. data/lib/jirametrics/change_item.rb +56 -21
  12. data/lib/jirametrics/chart_base.rb +101 -61
  13. data/lib/jirametrics/columns_config.rb +4 -0
  14. data/lib/jirametrics/css_variable.rb +33 -0
  15. data/lib/jirametrics/cycletime_config.rb +59 -8
  16. data/lib/jirametrics/cycletime_histogram.rb +69 -4
  17. data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
  18. data/lib/jirametrics/daily_view.rb +277 -0
  19. data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
  20. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  21. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  22. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  23. data/lib/jirametrics/data_quality_report.rb +222 -41
  24. data/lib/jirametrics/dependency_chart.rb +54 -23
  25. data/lib/jirametrics/download_config.rb +12 -0
  26. data/lib/jirametrics/downloader.rb +86 -56
  27. data/lib/jirametrics/estimate_accuracy_chart.rb +173 -0
  28. data/lib/jirametrics/estimation_configuration.rb +25 -0
  29. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  30. data/lib/jirametrics/examples/standard_project.rb +26 -48
  31. data/lib/jirametrics/expedited_chart.rb +28 -25
  32. data/lib/jirametrics/exporter.rb +59 -32
  33. data/lib/jirametrics/file_config.rb +35 -14
  34. data/lib/jirametrics/file_system.rb +48 -3
  35. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  36. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  37. data/lib/jirametrics/grouping_rules.rb +7 -1
  38. data/lib/jirametrics/hierarchy_table.rb +4 -4
  39. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  40. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  41. data/lib/jirametrics/html/aging_work_table.erb +21 -25
  42. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  43. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  44. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  45. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  46. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  47. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  48. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  49. data/lib/jirametrics/html/index.css +280 -0
  50. data/lib/jirametrics/html/index.erb +33 -39
  51. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  52. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  53. data/lib/jirametrics/html_report_config.rb +110 -86
  54. data/lib/jirametrics/issue.rb +390 -109
  55. data/lib/jirametrics/issue_collection.rb +33 -0
  56. data/lib/jirametrics/jira_gateway.rb +33 -12
  57. data/lib/jirametrics/project_config.rb +276 -147
  58. data/lib/jirametrics/rules.rb +2 -2
  59. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  60. data/lib/jirametrics/settings.json +11 -0
  61. data/lib/jirametrics/sprint.rb +1 -0
  62. data/lib/jirametrics/sprint_burndown.rb +59 -40
  63. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  64. data/lib/jirametrics/status.rb +84 -19
  65. data/lib/jirametrics/status_collection.rb +86 -39
  66. data/lib/jirametrics/throughput_chart.rb +12 -4
  67. data/lib/jirametrics/user.rb +12 -0
  68. data/lib/jirametrics/value_equality.rb +2 -2
  69. data/lib/jirametrics.rb +29 -7
  70. metadata +20 -17
  71. data/lib/jirametrics/discard_changes_before.rb +0 -37
  72. data/lib/jirametrics/experimental/generator.rb +0 -210
  73. data/lib/jirametrics/experimental/info.rb +0 -77
  74. data/lib/jirametrics/html/data_quality_report.erb +0 -126
  75. data/lib/jirametrics/story_point_accuracy_chart.rb +0 -134
@@ -2,41 +2,49 @@
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
- default_board: nil, anonymize: false
7
+ default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
8
+ rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
+ show_experimental_charts: false
10
10
 
11
11
  project name: name do
12
12
  puts name
13
+ file_prefix file_prefix
14
+
13
15
  self.anonymize if anonymize
16
+ self.settings.merge! settings
17
+
18
+ status_category_mappings.each do |status, category|
19
+ status_category_mapping status: status, category: category
20
+ end
14
21
 
15
- settings['blocked_link_text'] = ['is blocked by']
16
- file_prefix file_prefix
17
22
  download do
18
- rolling_date_count 90
23
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
24
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
19
25
  end
20
26
 
21
27
  boards.each_key do |board_id|
22
28
  block = boards[board_id]
23
29
  if block == :default
24
30
  block = lambda do |_|
25
- start_at first_time_in_status_category('In Progress')
26
- stop_at still_in_status_category('Done')
31
+ start_at first_time_in_status_category(:indeterminate)
32
+ stop_at still_in_status_category(:done)
27
33
  end
28
34
  end
29
35
  board id: board_id do
30
36
  cycletime(&block)
31
- expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
32
37
  end
33
38
  end
34
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
+
35
46
  file do
36
47
  file_suffix '.html'
37
- issues.reject! do |issue|
38
- %w[Sub-task Epic].include? issue.type
39
- end
40
48
 
41
49
  issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
42
50
 
@@ -46,11 +54,11 @@ class Exporter
46
54
  html "<H1>#{name}</H1>", type: :header
47
55
  boards.each_key do |id|
48
56
  board = find_board id
49
- 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>",
50
58
  type: :header
51
59
  end
52
60
 
53
- discard_changes_before status_becomes: (starting_status || :backlog)
61
+ daily_view
54
62
 
55
63
  cycletime_scatterplot do
56
64
  show_trend_lines
@@ -77,42 +85,12 @@ class Exporter
77
85
  aging_work_table
78
86
  daily_wip_by_age_chart
79
87
  daily_wip_by_blocked_stalled_chart
80
- daily_wip_chart do
81
- header_text 'Daily WIP by Parent'
82
- description_text <<-TEXT
83
- How much work is in progress, grouped by the parent of the issue. This will give us an
84
- indication of how focused we are on higher level objectives. If there are many parent
85
- tickets in progress at the same time, either this team has their focus scattered or we
86
- aren't doing a good job of
87
- <a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
88
- tickets</a>. Neither of those is desirable.
89
- TEXT
90
- grouping_rules do |issue, rules|
91
- rules.label = issue.parent&.key || 'No parent'
92
- rules.color = 'white' if rules.label == 'No parent'
93
- end
94
- end
88
+ daily_wip_by_parent_chart
89
+ flow_efficiency_scatterplot if show_experimental_charts
95
90
  expedited_chart
96
91
  sprint_burndown
97
- story_point_accuracy_chart
98
-
99
- dependency_chart do
100
- link_rules do |link, rules|
101
- case link.name
102
- when 'Cloners'
103
- rules.ignore
104
- when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
105
- rules.merge_bidirectional keep: 'outward'
106
- rules.merge_bidirectional keep: 'outward'
107
- when 'Sync'
108
- rules.use_bidirectional_arrows
109
- else
110
- # This is a link type that we don't recognized. Dump it to standard out to draw attention
111
- # to it.
112
- puts "name=#{link.name}, label=#{link.label}"
113
- end
114
- end
115
- end
92
+ estimate_accuracy_chart
93
+ dependency_chart
116
94
  end
117
95
  end
118
96
  end
@@ -3,11 +3,14 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class ExpeditedChart < ChartBase
6
- EXPEDITED_SEGMENT = Object.new.tap do |segment|
6
+ EXPEDITED_SEGMENT = ChartBase.new.tap do |segment|
7
7
  def segment.to_json *_args
8
+ expedited = CssVariable.new('--expedited-color').to_json
9
+ not_expedited = CssVariable.new('--expedited-chart-no-longer-expedited').to_json
10
+
8
11
  <<~SNIPPET
9
12
  {
10
- borderColor: ctx => expedited(ctx, 'red') || notExpedited(ctx, 'gray'),
13
+ borderColor: ctx => expedited(ctx, #{expedited}) || notExpedited(ctx, #{not_expedited}),
11
14
  borderDash: ctx => notExpedited(ctx, [6, 6])
12
15
  }
13
16
  SNIPPET
@@ -17,25 +20,26 @@ class ExpeditedChart < ChartBase
17
20
  attr_accessor :issues, :cycletime, :possible_statuses, :date_range
18
21
  attr_reader :expedited_label
19
22
 
20
- def initialize
23
+ def initialize block
21
24
  super()
22
25
 
23
26
  header_text 'Expedited work'
24
27
  description_text <<-HTML
25
- <p>
28
+ <div class="p">
26
29
  This chart only shows issues that have been expedited at some point. We care about these as
27
30
  any form of expedited work will affect the entire system and will slow down non-expedited work.
28
31
  Refer to this article on
29
32
  <a href="https://improvingflow.com/2021/06/16/classes-of-service.html">classes of service</a>
30
33
  for a longer explanation on why we want to avoid expedited work.
31
- </p>
32
- <p>
33
- The lines indicate time that this issue was expedited. When the line is red then the issue was
34
- expedited at that time. When it's gray then it wasn't. Orange dots indicate the date the work
35
- was started and green dots represent the completion date. Lastly, the vertical height of the
36
- lines/dots indicates how long it's been since this issue was created.
37
- </p>
34
+ </div>
35
+ <div class="p">
36
+ The colour of the line indicates time that this issue was #{color_block '--expedited-color'} expedited
37
+ or #{color_block '--expedited-chart-no-longer-expedited'} not expedited.
38
+ </div>
39
+ #{describe_non_working_days}
38
40
  HTML
41
+
42
+ instance_eval(&block)
39
43
  end
40
44
 
41
45
  def run
@@ -53,13 +57,13 @@ class ExpeditedChart < ChartBase
53
57
  def prepare_expedite_data issue
54
58
  expedite_start = nil
55
59
  result = []
56
- expedited_priority_names = issue.board.expedited_priority_names
60
+ expedited_priority_names = issue.board.project_config.settings['expedited_priority_names']
57
61
 
58
62
  issue.changes.each do |change|
59
63
  next unless change.priority?
60
64
 
61
65
  if expedited_priority_names.include? change.value
62
- expedite_start = change.time
66
+ expedite_start = change.time.to_date
63
67
  elsif expedite_start
64
68
  start_date = expedite_start.to_date
65
69
  stop_date = change.time.to_date
@@ -68,7 +72,7 @@ class ExpeditedChart < ChartBase
68
72
  (start_date < date_range.begin && stop_date > date_range.end)
69
73
 
70
74
  result << [expedite_start, :expedite_start]
71
- result << [change.time, :expedite_stop]
75
+ result << [change.time.to_date, :expedite_stop]
72
76
  end
73
77
  expedite_start = nil
74
78
  end
@@ -105,12 +109,11 @@ class ExpeditedChart < ChartBase
105
109
 
106
110
  def make_expedite_lines_data_set issue:, expedite_data:
107
111
  cycletime = issue.board.cycletime
108
- started_time = cycletime.started_time(issue)
109
- stopped_time = cycletime.stopped_time(issue)
112
+ started_date, stopped_date = cycletime.started_stopped_dates(issue)
110
113
 
111
- expedite_data << [started_time, :issue_started] if started_time
112
- expedite_data << [stopped_time, :issue_stopped] if stopped_time
113
- expedite_data.sort_by! { |a| a[0] }
114
+ expedite_data << [started_date, :issue_started] if started_date
115
+ expedite_data << [stopped_date, :issue_stopped] if stopped_date
116
+ expedite_data.sort_by!(&:first)
114
117
 
115
118
  # If none of the data would be visible on the chart then skip it.
116
119
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -124,20 +127,20 @@ class ExpeditedChart < ChartBase
124
127
  case action
125
128
  when :issue_started
126
129
  data << make_point(issue: issue, time: time, label: 'Started', expedited: expedited)
127
- dot_colors << 'orange'
130
+ dot_colors << CssVariable['--expedited-chart-dot-issue-started-color']
128
131
  point_styles << 'rect'
129
132
  when :issue_stopped
130
133
  data << make_point(issue: issue, time: time, label: 'Completed', expedited: expedited)
131
- dot_colors << 'green'
134
+ dot_colors << CssVariable['--expedited-chart-dot-issue-stopped-color']
132
135
  point_styles << 'rect'
133
136
  when :expedite_start
134
137
  data << make_point(issue: issue, time: time, label: 'Expedited', expedited: true)
135
- dot_colors << 'red'
138
+ dot_colors << CssVariable['--expedited-chart-dot-expedite-started-color']
136
139
  point_styles << 'circle'
137
140
  expedited = true
138
141
  when :expedite_stop
139
142
  data << make_point(issue: issue, time: time, label: 'Not expedited', expedited: false)
140
- dot_colors << 'gray'
143
+ dot_colors << CssVariable['--expedited-chart-dot-expedite-stopped-color']
141
144
  point_styles << 'circle'
142
145
  expedited = false
143
146
  else
@@ -147,9 +150,9 @@ class ExpeditedChart < ChartBase
147
150
 
148
151
  unless expedite_data.empty?
149
152
  last_change_time = expedite_data[-1][0].to_date
150
- if last_change_time && last_change_time <= date_range.end && stopped_time.nil?
153
+ if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
151
154
  data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
152
- dot_colors << 'blue' # It won't be visible so it doesn't matter
155
+ dot_colors << '' # It won't be visible so it doesn't matter
153
156
  point_styles << 'dash'
154
157
  end
155
158
  end
@@ -2,21 +2,19 @@
2
2
 
3
3
  require 'fileutils'
4
4
 
5
- class Object
6
- def deprecated message:
7
- text = +''
8
- text << 'Deprecated:'
9
- text << message
10
- text << "\n-> Called from #{caller(1..1).first}"
11
- warn text
12
- end
13
- end
14
-
15
5
  class Exporter
16
- attr_reader :project_configs, :file_system
6
+ attr_reader :project_configs
7
+ attr_accessor :file_system
17
8
 
18
9
  def self.configure &block
19
- exporter = Exporter.new
10
+ logfile_name = 'jirametrics.log'
11
+ logfile = File.open logfile_name, 'w'
12
+ file_system = FileSystem.new
13
+ file_system.logfile = logfile
14
+ file_system.logfile_name = logfile_name
15
+
16
+ exporter = Exporter.new file_system: file_system
17
+
20
18
  exporter.instance_eval(&block)
21
19
  @@instance = exporter
22
20
  end
@@ -25,11 +23,12 @@ class Exporter
25
23
 
26
24
  def initialize file_system: FileSystem.new
27
25
  @project_configs = []
28
- @timezone_offset = '+00:00'
29
26
  @target_path = '.'
30
27
  @holiday_dates = []
31
28
  @downloading = false
32
29
  @file_system = file_system
30
+
31
+ timezone_offset '+00:00'
33
32
  end
34
33
 
35
34
  def export name_filter:
@@ -41,25 +40,49 @@ class Exporter
41
40
 
42
41
  def download name_filter:
43
42
  @downloading = true
44
- logfile_name = 'downloader.log'
45
- File.open logfile_name, 'w' do |logfile|
46
- file_system.logfile = logfile
47
- file_system.logfile_name = logfile_name
48
-
49
- each_project_config(name_filter: name_filter) do |project|
50
- project.evaluate_next_level
51
- next if project.aggregated_project?
52
-
53
- project.download_config.run
54
- downloader = Downloader.new(
55
- download_config: project.download_config,
56
- file_system: file_system,
57
- jira_gateway: JiraGateway.new(file_system: file_system)
58
- )
59
- downloader.run
43
+ each_project_config(name_filter: name_filter) do |project|
44
+ project.evaluate_next_level
45
+ next if project.aggregated_project?
46
+
47
+ unless project.download_config
48
+ raise "Project #{project.name.inspect} is missing a download section in the config. " \
49
+ 'That is required in order to download'
50
+ end
51
+
52
+ project.download_config.run
53
+ downloader = Downloader.new(
54
+ download_config: project.download_config,
55
+ file_system: file_system,
56
+ jira_gateway: JiraGateway.new(file_system: file_system)
57
+ )
58
+ downloader.run
59
+ end
60
+ puts "Full output from downloader in #{file_system.logfile_name}"
61
+ end
62
+
63
+ def info keys, name_filter:
64
+ selected = []
65
+ each_project_config(name_filter: name_filter) do |project|
66
+ project.evaluate_next_level
67
+
68
+ project.run load_only: true
69
+ project.issues.each do |issue|
70
+ selected << [project, issue] if keys.include? issue.key
71
+ end
72
+ rescue => e # rubocop:disable Style/RescueStandardError
73
+ # This happens when we're attempting to load an aggregated project because it hasn't been
74
+ # properly initialized. Since we don't care about aggregated projects, we just ignore it.
75
+ raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
76
+ end
77
+
78
+ if selected.empty?
79
+ file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
80
+ else
81
+ selected.each do |project, issue|
82
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
83
+ file_system.log issue.dump, also_write_to_stderr: true
60
84
  end
61
85
  end
62
- puts "Full output from downloader in #{logfile_name}"
63
86
  end
64
87
 
65
88
  def each_project_config name_filter:
@@ -73,7 +96,6 @@ class Exporter
73
96
  end
74
97
 
75
98
  def project name: nil, &block
76
- raise 'target_path was never set!' if @target_path.nil?
77
99
  raise 'jira_config not set' if @jira_config.nil?
78
100
 
79
101
  @project_configs << ProjectConfig.new(
@@ -93,7 +115,12 @@ class Exporter
93
115
  end
94
116
 
95
117
  def jira_config filename = nil
96
- @jira_config = file_system.load_json(filename) unless filename.nil?
118
+ if filename
119
+ @jira_config = file_system.load_json(filename, fail_on_error: false)
120
+ raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
121
+
122
+ @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
123
+ end
97
124
  @jira_config
98
125
  end
99
126
 
@@ -5,24 +5,22 @@ require 'csv'
5
5
  class FileConfig
6
6
  attr_reader :project_config, :issues
7
7
 
8
- def initialize project_config:, block:
8
+ def initialize project_config:, block:, today: Date.today
9
9
  @project_config = project_config
10
10
  @block = block
11
11
  @columns = nil
12
+ @today = today
12
13
  end
13
14
 
14
15
  def run
15
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
16
17
  instance_eval(&@block)
17
18
 
18
19
  if @columns
19
20
  all_lines = prepare_grid
20
21
 
21
- File.open(output_filename, 'w') do |file|
22
- all_lines.each do |output_line|
23
- file.puts CSV.generate_line(output_line)
24
- end
25
- end
22
+ content = all_lines.collect { |line| CSV.generate_line line }.join
23
+ project_config.exporter.file_system.save_file content: content, filename: output_filename
26
24
  elsif @html_report
27
25
  @html_report.run
28
26
  else
@@ -58,8 +56,8 @@ class FileConfig
58
56
  def output_filename
59
57
  segments = []
60
58
  segments << project_config.target_path
61
- segments << project_config.file_prefix
62
- segments << (@file_suffix || "-#{Date.today}.csv")
59
+ segments << project_config.get_file_prefix
60
+ segments << (@file_suffix || "-#{@today}.csv")
63
61
  segments.join
64
62
  end
65
63
 
@@ -68,13 +66,20 @@ class FileConfig
68
66
  # is that all empty values in the first column should be at the bottom.
69
67
  def sort_output all_lines
70
68
  all_lines.sort do |a, b|
71
- if a[0].nil?
72
- 1
69
+ result = nil
70
+ if a[0] == b[0]
71
+ result = a[1..] <=> b[1..]
72
+ elsif a[0].nil?
73
+ result = 1
73
74
  elsif b[0].nil?
74
- -1
75
+ result = -1
75
76
  else
76
- a[0] <=> b[0]
77
+ result = a[0] <=> b[0]
77
78
  end
79
+
80
+ # This will only happen if one of the objects isn't comparable. Seen in production.
81
+ result = -1 if result.nil?
82
+ result
78
83
  end
79
84
  end
80
85
 
@@ -85,6 +90,11 @@ class FileConfig
85
90
 
86
91
  def html_report &block
87
92
  assert_only_one_filetype_config_set
93
+ if block.nil?
94
+ project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
95
+ block = ->(_) {}
96
+ end
97
+
88
98
  @html_report = HtmlReportConfig.new file_config: self, block: block
89
99
  end
90
100
 
@@ -103,7 +113,7 @@ class FileConfig
103
113
  def to_datetime object
104
114
  return nil if object.nil?
105
115
 
106
- object = object.to_datetime
116
+ object = object.to_time.to_datetime
107
117
  object = object.new_offset(@timezone_offset) if @timezone_offset
108
118
  object
109
119
  end
@@ -112,8 +122,19 @@ class FileConfig
112
122
  object.to_s
113
123
  end
114
124
 
125
+ def to_integer object
126
+ object.to_i
127
+ end
128
+
115
129
  def file_suffix suffix = nil
116
130
  @file_suffix = suffix unless suffix.nil?
117
131
  @file_suffix
118
132
  end
133
+
134
+ def children
135
+ result = []
136
+ result << @columns if @columns
137
+ result << @html_report if @html_report
138
+ result
139
+ end
119
140
  end
@@ -5,21 +5,48 @@ require 'json'
5
5
  class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
+ # Effectively the same as File.read except it forces the encoding to UTF-8
9
+ def load filename, supress_deprecation: false
10
+ if filename.end_with?('.json') && !supress_deprecation
11
+ deprecated(message: 'call load_json instead', date: '2024-11-13')
12
+ end
13
+
14
+ File.read filename, encoding: 'UTF-8'
15
+ end
16
+
8
17
  def load_json filename, fail_on_error: true
9
18
  return nil if fail_on_error == false && File.exist?(filename) == false
10
19
 
11
- JSON.parse File.read(filename)
20
+ JSON.parse load(filename, supress_deprecation: true)
12
21
  end
13
22
 
14
23
  def save_json json:, filename:
24
+ save_file content: JSON.pretty_generate(compress json), filename: filename
25
+ end
26
+
27
+ def save_file content:, filename:
15
28
  file_path = File.dirname(filename)
16
29
  FileUtils.mkdir_p file_path unless File.exist?(file_path)
17
30
 
18
- File.write(filename, JSON.pretty_generate(compress json))
31
+ File.write(filename, content)
32
+ end
33
+
34
+ def warning message, more: nil
35
+ log "Warning: #{message}", more: more, also_write_to_stderr: true
36
+ end
37
+
38
+ def error message, more: nil
39
+ log "Error: #{message}", more: more, also_write_to_stderr: true
19
40
  end
20
41
 
21
- def log message
42
+ def log message, more: nil, also_write_to_stderr: false
43
+ message += " See #{logfile_name} for more details about this message." if more
44
+
22
45
  logfile.puts message
46
+ logfile.puts more if more
47
+ return unless also_write_to_stderr
48
+
49
+ $stderr.puts message # rubocop:disable Style/StderrPuts
23
50
  end
24
51
 
25
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -33,4 +60,22 @@ class FileSystem
33
60
  end
34
61
  node
35
62
  end
63
+
64
+ def foreach root, &block
65
+ Dir.foreach root, &block
66
+ end
67
+
68
+ def file_exist? filename
69
+ File.exist? filename
70
+ end
71
+
72
+ def deprecated message:, date:, depth: 2
73
+ text = +''
74
+ text << "Deprecated(#{date}): "
75
+ text << message
76
+ caller(1..depth).each do |line|
77
+ text << "\n-> Called from #{line}"
78
+ end
79
+ log text, also_write_to_stderr: true
80
+ end
36
81
  end
@@ -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: var(--warning-banner)">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