jirametrics 2.5 → 2.30

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +73 -20
  12. data/lib/jirametrics/board_config.rb +10 -2
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +8 -6
  30. data/lib/jirametrics/download_config.rb +17 -2
  31. data/lib/jirametrics/downloader.rb +177 -108
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +5 -8
  37. data/lib/jirametrics/examples/standard_project.rb +54 -38
  38. data/lib/jirametrics/expedited_chart.rb +10 -9
  39. data/lib/jirametrics/exporter.rb +51 -16
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +481 -97
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  80. data/lib/jirametrics/settings.json +7 -1
  81. data/lib/jirametrics/sprint.rb +13 -0
  82. data/lib/jirametrics/sprint_burndown.rb +47 -39
  83. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  84. data/lib/jirametrics/status.rb +84 -19
  85. data/lib/jirametrics/status_collection.rb +83 -38
  86. data/lib/jirametrics/stitcher.rb +81 -0
  87. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  88. data/lib/jirametrics/throughput_chart.rb +73 -23
  89. data/lib/jirametrics/time_based_histogram.rb +139 -0
  90. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  91. data/lib/jirametrics/user.rb +12 -0
  92. data/lib/jirametrics/value_equality.rb +2 -2
  93. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  94. data/lib/jirametrics.rb +101 -66
  95. metadata +72 -16
  96. data/lib/jirametrics/cycletime_config.rb +0 -69
  97. data/lib/jirametrics/discard_changes_before.rb +0 -37
  98. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  99. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -2,49 +2,52 @@
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
11
-
8
+ rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
+ show_experimental_charts: false, github_repos: nil
10
+ exporter = self
12
11
  project name: name do
13
- puts name
12
+ file_system.log name, also_write_to_stderr: true
13
+ file_prefix file_prefix
14
+
14
15
  self.anonymize if anonymize
16
+ self.settings.merge! stringify_keys(settings)
15
17
 
16
- self.settings.merge! settings
18
+ boards.each_key do |board_id|
19
+ block = boards[board_id]
20
+ if block == :default
21
+ block = lambda do |_|
22
+ start_at first_time_in_status_category(:indeterminate)
23
+ stop_at still_in_status_category(:done)
24
+ end
25
+ end
26
+ board id: board_id do
27
+ cycletime(&block)
28
+ end
29
+ end
17
30
 
18
31
  status_category_mappings.each do |status, category|
19
32
  status_category_mapping status: status, category: category
20
33
  end
21
34
 
22
- file_prefix file_prefix
23
35
  download do
24
36
  self.rolling_date_count(rolling_date_count) if rolling_date_count
25
37
  self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ github_repo *github_repos if github_repos
26
39
  end
27
40
 
28
- boards.each_key do |board_id|
29
- block = boards[board_id]
30
- if block == :default
31
- block = lambda do |_|
32
- start_at first_time_in_status_category('In Progress')
33
- stop_at still_in_status_category('Done')
34
- end
35
- end
36
- board id: board_id do
37
- cycletime(&block)
38
- end
41
+ issues.reject! do |issue|
42
+ ignore_types.include? issue.type
39
43
  end
40
44
 
45
+ exporter.filter_issues issues, ignore_issues
46
+
47
+ discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
48
+
41
49
  file do
42
50
  file_suffix '.html'
43
- issues.reject! do |issue|
44
- %w[Sub-task Epic].include? issue.type
45
- end
46
-
47
- issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
48
51
 
49
52
  html_report do
50
53
  board_id default_board if default_board
@@ -52,39 +55,42 @@ class Exporter
52
55
  html "<H1>#{name}</H1>", type: :header
53
56
  boards.each_key do |id|
54
57
  board = find_board id
55
- html "<div><a href='#{board.url}'>#{id} #{board.name}</a></div>",
58
+ html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
56
59
  type: :header
57
60
  end
58
-
59
- discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
60
-
61
+ daily_view
62
+ cumulative_flow_diagram
61
63
  cycletime_scatterplot do
62
64
  show_trend_lines
63
65
  end
64
66
  cycletime_histogram
65
67
 
66
68
  throughput_chart do
67
- description_text '<h2>Number of items completed, grouped by issue type</h2>'
69
+ description_text <<~TEXT
70
+ <div>Throughput data is very useful for#{' '}
71
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
72
+ to determine when we'll be done. Try it now with the
73
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
74
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
75
+ <%= @not_started_count %> items you currently have in your backlog.
76
+ </div>
77
+ <h2>Number of items completed, grouped by issue type</h2>'
78
+ TEXT
68
79
  end
69
- throughput_chart do
70
- header_text nil
80
+ throughput_by_completed_resolution_chart do
71
81
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
72
- grouping_rules do |issue, rules|
73
- if issue.resolution
74
- rules.label = "#{issue.status.name}:#{issue.resolution}"
75
- else
76
- rules.label = issue.status.name
77
- end
78
- end
79
82
  end
80
83
 
81
84
  aging_work_in_progress_chart
85
+ wip_by_column_chart do
86
+ show_recommendations
87
+ end
82
88
  aging_work_bar_chart
83
89
  aging_work_table
84
90
  daily_wip_by_age_chart
85
91
  daily_wip_by_blocked_stalled_chart
86
92
  daily_wip_by_parent_chart
87
- expedited_chart
93
+ flow_efficiency_scatterplot if show_experimental_charts
88
94
  sprint_burndown
89
95
  estimate_accuracy_chart
90
96
  dependency_chart
@@ -92,4 +98,14 @@ class Exporter
92
98
  end
93
99
  end
94
100
  end
101
+
102
+ # Extracted as a separate method so it can be tested independently, without needing to invoke
103
+ # the full standard_project DSL setup.
104
+ def filter_issues issues, ignore_issues
105
+ return unless ignore_issues
106
+
107
+ issues.reject! do |issue|
108
+ ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
109
+ end
110
+ end
95
111
  end
@@ -38,6 +38,8 @@ class ExpeditedChart < ChartBase
38
38
  </div>
39
39
  #{describe_non_working_days}
40
40
  HTML
41
+ @x_axis_title = 'Date'
42
+ @y_axis_title = 'Age in days'
41
43
 
42
44
  instance_eval(&block)
43
45
  end
@@ -48,7 +50,7 @@ class ExpeditedChart < ChartBase
48
50
  end
49
51
 
50
52
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
53
+ '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
52
54
  else
53
55
  wrap_and_render(binding, __FILE__)
54
56
  end
@@ -63,7 +65,7 @@ class ExpeditedChart < ChartBase
63
65
  next unless change.priority?
64
66
 
65
67
  if expedited_priority_names.include? change.value
66
- expedite_start = change.time
68
+ expedite_start = change.time.to_date
67
69
  elsif expedite_start
68
70
  start_date = expedite_start.to_date
69
71
  stop_date = change.time.to_date
@@ -72,7 +74,7 @@ class ExpeditedChart < ChartBase
72
74
  (start_date < date_range.begin && stop_date > date_range.end)
73
75
 
74
76
  result << [expedite_start, :expedite_start]
75
- result << [change.time, :expedite_stop]
77
+ result << [change.time.to_date, :expedite_stop]
76
78
  end
77
79
  expedite_start = nil
78
80
  end
@@ -109,12 +111,11 @@ class ExpeditedChart < ChartBase
109
111
 
110
112
  def make_expedite_lines_data_set issue:, expedite_data:
111
113
  cycletime = issue.board.cycletime
112
- started_time = cycletime.started_time(issue)
113
- stopped_time = cycletime.stopped_time(issue)
114
+ started_date, stopped_date = cycletime.started_stopped_dates(issue)
114
115
 
115
- expedite_data << [started_time, :issue_started] if started_time
116
- expedite_data << [stopped_time, :issue_stopped] if stopped_time
117
- expedite_data.sort_by! { |a| a[0] }
116
+ expedite_data << [started_date, :issue_started] if started_date
117
+ expedite_data << [stopped_date, :issue_stopped] if stopped_date
118
+ expedite_data.sort_by!(&:first)
118
119
 
119
120
  # If none of the data would be visible on the chart then skip it.
120
121
  return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
@@ -151,7 +152,7 @@ class ExpeditedChart < ChartBase
151
152
 
152
153
  unless expedite_data.empty?
153
154
  last_change_time = expedite_data[-1][0].to_date
154
- if last_change_time && last_change_time <= date_range.end && stopped_time.nil?
155
+ if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
155
156
  data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
156
157
  dot_colors << '' # It won't be visible so it doesn't matter
157
158
  point_styles << 'dash'
@@ -2,25 +2,19 @@
2
2
 
3
3
  require 'fileutils'
4
4
 
5
- class Object
6
- def deprecated message:, date:
7
- text = +''
8
- text << "Deprecated(#{date}): "
9
- text << message
10
- caller(1..2).each do |line|
11
- text << "\n-> Called from #{line}"
12
- end
13
- warn text
14
- end
15
- end
16
-
17
5
  class Exporter
18
6
  attr_reader :project_configs
19
7
  attr_accessor :file_system
20
8
 
21
9
  def self.configure &block
22
10
  logfile_name = 'jirametrics.log'
23
- logfile = File.open logfile_name, 'w'
11
+ logfile = File.open(logfile_name, 'w')
12
+ rescue Errno::EACCES
13
+ # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
+ warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
+ 'Please ensure the current directory is writable.'
16
+ exit 1
17
+ else
24
18
  file_system = FileSystem.new
25
19
  file_system.logfile = logfile
26
20
  file_system.logfile_name = logfile_name
@@ -52,6 +46,7 @@ class Exporter
52
46
 
53
47
  def download name_filter:
54
48
  @downloading = true
49
+ github_pr_cache = {}
55
50
  each_project_config(name_filter: name_filter) do |project|
56
51
  project.evaluate_next_level
57
52
  next if project.aggregated_project?
@@ -62,16 +57,54 @@ class Exporter
62
57
  end
63
58
 
64
59
  project.download_config.run
65
- downloader = Downloader.new(
60
+ gateway = JiraGateway.new(
61
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
62
+ )
63
+ downloader = Downloader.create(
66
64
  download_config: project.download_config,
67
65
  file_system: file_system,
68
- jira_gateway: JiraGateway.new(file_system: file_system)
66
+ jira_gateway: gateway,
67
+ github_pr_cache: github_pr_cache
69
68
  )
70
69
  downloader.run
71
70
  end
72
71
  puts "Full output from downloader in #{file_system.logfile_name}"
73
72
  end
74
73
 
74
+ def info key, name_filter:
75
+ selected = []
76
+ file_system.log_only = true
77
+ each_project_config(name_filter: name_filter) do |project|
78
+ project.evaluate_next_level
79
+
80
+ project.run load_only: true
81
+ project.issues.each do |issue|
82
+ selected << [project, issue] if key == issue.key
83
+ issue.subtasks.each do |subtask|
84
+ selected << [project, subtask] if key == subtask.key
85
+ end
86
+ end
87
+ rescue => e # rubocop:disable Style/RescueStandardError
88
+ # This happens when we're attempting to load an aggregated project because it hasn't been
89
+ # properly initialized. Since we don't care about aggregated projects, we just ignore it.
90
+ raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
91
+ end
92
+ file_system.log_only = false
93
+
94
+ if selected.empty?
95
+ file_system.log "No issues found to match #{key.inspect}"
96
+ else
97
+ selected.each do |project, issue|
98
+ file_system.log "\nProject #{project.name}", also_write_to_stderr: true
99
+ file_system.log issue.dump, also_write_to_stderr: true
100
+ end
101
+ end
102
+ end
103
+
104
+ def stitch stitch_file
105
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
+ end
107
+
75
108
  def each_project_config name_filter:
76
109
  @project_configs.each do |project|
77
110
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
@@ -103,7 +136,9 @@ class Exporter
103
136
 
104
137
  def jira_config filename = nil
105
138
  if filename
106
- @jira_config = file_system.load_json(filename)
139
+ @jira_config = file_system.load_json(filename, fail_on_error: false)
140
+ raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
141
+
107
142
  @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
108
143
  end
109
144
  @jira_config
@@ -13,7 +13,7 @@ class FileConfig
13
13
  end
14
14
 
15
15
  def run
16
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
17
17
  instance_eval(&@block)
18
18
 
19
19
  if @columns
@@ -56,7 +56,7 @@ class FileConfig
56
56
  def output_filename
57
57
  segments = []
58
58
  segments << project_config.target_path
59
- segments << project_config.file_prefix
59
+ segments << project_config.get_file_prefix
60
60
  segments << (@file_suffix || "-#{@today}.csv")
61
61
  segments.join
62
62
  end
@@ -65,8 +65,8 @@ class FileConfig
65
65
  # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
- all_lines.sort do |a, b|
69
- if a[0] == b[0]
68
+ all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
69
+ result = if a[0] == b[0]
70
70
  a[1..] <=> b[1..]
71
71
  elsif a[0].nil?
72
72
  1
@@ -75,7 +75,10 @@ class FileConfig
75
75
  else
76
76
  a[0] <=> b[0]
77
77
  end
78
- end
78
+
79
+ # When objects aren't comparable, preserve original order for a stable sort.
80
+ result.nil? || result.zero? ? a_idx <=> b_idx : result
81
+ end.map(&:first)
79
82
  end
80
83
 
81
84
  def columns &block
@@ -85,6 +88,11 @@ class FileConfig
85
88
 
86
89
  def html_report &block
87
90
  assert_only_one_filetype_config_set
91
+ if block.nil?
92
+ project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
93
+ block = ->(_) {}
94
+ end
95
+
88
96
  @html_report = HtmlReportConfig.new file_config: self, block: block
89
97
  end
90
98
 
@@ -103,7 +111,7 @@ class FileConfig
103
111
  def to_datetime object
104
112
  return nil if object.nil?
105
113
 
106
- object = object.to_datetime
114
+ object = object.to_time.to_datetime
107
115
  object = object.new_offset(@timezone_offset) if @timezone_offset
108
116
  object
109
117
  end
@@ -120,4 +128,11 @@ class FileConfig
120
128
  @file_suffix = suffix unless suffix.nil?
121
129
  @file_suffix
122
130
  end
131
+
132
+ def children
133
+ result = []
134
+ result << @columns if @columns
135
+ result << @html_report if @html_report
136
+ result
137
+ end
123
138
  end
@@ -3,17 +3,29 @@
3
3
  require 'json'
4
4
 
5
5
  class FileSystem
6
- attr_accessor :logfile, :logfile_name
6
+ attr_accessor :logfile, :logfile_name, :log_only
7
+
8
+ def initialize
9
+ # In almost all cases, this will be immediately replaced in the Exporter
10
+ # but if we fail before we get that far, this will at least let a useful
11
+ # error show up on the console.
12
+ @logfile = $stdout
13
+ @log_only = false
14
+ end
7
15
 
8
16
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
- def load filename
17
+ def load filename, supress_deprecation: false
18
+ if filename.end_with?('.json') && !supress_deprecation
19
+ deprecated(message: 'call load_json instead', date: '2024-11-13')
20
+ end
21
+
10
22
  File.read filename, encoding: 'UTF-8'
11
23
  end
12
24
 
13
25
  def load_json filename, fail_on_error: true
14
26
  return nil if fail_on_error == false && File.exist?(filename) == false
15
27
 
16
- JSON.parse load(filename)
28
+ JSON.parse load(filename, supress_deprecation: true)
17
29
  end
18
30
 
19
31
  def save_json json:, filename:
@@ -27,8 +39,62 @@ class FileSystem
27
39
  File.write(filename, content)
28
40
  end
29
41
 
30
- def log message
42
+ def mkdir path
43
+ FileUtils.mkdir_p path
44
+ end
45
+
46
+ def utime file:, time:
47
+ File.utime time, time, file
48
+ end
49
+
50
+ def warning message, more: nil
51
+ log "Warning: #{message}", more: more, also_write_to_stderr: true
52
+ end
53
+
54
+ def error message, more: nil
55
+ log "Error: #{message}", more: more, also_write_to_stderr: true
56
+ end
57
+
58
+ def log message, more: nil, also_write_to_stderr: false
59
+ message += " See #{logfile_name} for more details about this message." if more
60
+
61
+ logfile.puts message
62
+ logfile.puts more if more
63
+ return if log_only || !also_write_to_stderr
64
+
65
+ # Obscure edge-case where we're trying to log something before logging is even
66
+ # set up. Quick escape here so that we don't dump the error twice.
67
+ return if logfile == $stdout
68
+
69
+ $stderr.puts message # rubocop:disable Style/StderrPuts
70
+ end
71
+
72
+ def log_start message
31
73
  logfile.puts message
74
+ return if log_only || logfile == $stdout
75
+
76
+ $stderr.print message
77
+ $stderr.flush
78
+ end
79
+
80
+ def start_progress
81
+ return if log_only
82
+
83
+ $stderr.print ' '
84
+ $stderr.flush
85
+ end
86
+
87
+ def progress_dot
88
+ return if log_only
89
+
90
+ $stderr.print '.'
91
+ $stderr.flush
92
+ end
93
+
94
+ def end_progress
95
+ return if log_only
96
+
97
+ $stderr.puts '' # rubocop:disable Style/StderrPuts
32
98
  end
33
99
 
34
100
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
@@ -42,4 +108,30 @@ class FileSystem
42
108
  end
43
109
  node
44
110
  end
111
+
112
+ def foreach root, &block
113
+ Dir.foreach root, &block
114
+ end
115
+
116
+ def file_exist? filename
117
+ File.exist?(filename) && File.file?(filename)
118
+ end
119
+
120
+ def dir_exist? path
121
+ File.exist?(path) && File.directory?(path)
122
+ end
123
+
124
+ def unlink filename
125
+ File.unlink filename
126
+ end
127
+
128
+ def deprecated message:, date:, depth: 2
129
+ text = +''
130
+ text << "Deprecated(#{date}): "
131
+ text << message
132
+ caller(1..depth).each do |line|
133
+ text << "\n-> Called from #{line}"
134
+ end
135
+ log text, also_write_to_stderr: true
136
+ end
45
137
  end
@@ -11,11 +11,24 @@ class FixVersion
11
11
  @raw['name']
12
12
  end
13
13
 
14
+ def description
15
+ @raw['description']
16
+ end
17
+
14
18
  def id
15
19
  @raw['id'].to_i
16
20
  end
17
21
 
22
+ def release_date
23
+ text = @raw['releaseDate']
24
+ text.nil? ? nil : Date.parse(text)
25
+ end
26
+
18
27
  def released?
19
28
  @raw['released']
20
29
  end
30
+
31
+ def archived?
32
+ @raw['archived']
33
+ end
21
34
  end
@@ -0,0 +1,115 @@
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
+ @x_axis_title = 'Total time (days)'
36
+ @y_axis_title = 'Time adding value (days)'
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
+ if data_sets.empty?
66
+ return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
67
+ end
68
+
69
+ wrap_and_render(binding, __FILE__)
70
+ end
71
+
72
+ def to_days seconds
73
+ seconds / 60 / 60 / 24
74
+ end
75
+
76
+ def create_dataset issues:, label:, color:
77
+ return nil if issues.empty?
78
+
79
+ data = issues.filter_map do |issue|
80
+ active_time, total_time = issue.flow_efficiency_numbers(
81
+ end_time: time_range.end, settings: settings
82
+ )
83
+
84
+ active_days = to_days(active_time)
85
+ total_days = to_days(total_time)
86
+ flow_efficiency = active_time * 100.0 / total_time
87
+
88
+ if flow_efficiency.nan?
89
+ # If this happens then something is probably misconfigured. We've seen it in production though
90
+ # so we have to handle it.
91
+ file_system.log(
92
+ "Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
93
+ )
94
+ flow_efficiency = 0.0
95
+ end
96
+
97
+ {
98
+ y: active_days,
99
+ x: total_days,
100
+ title: [
101
+ "#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
102
+ " total: #{total_days.round(1)} days," \
103
+ " active: #{active_days.round(1)} days"
104
+ ]
105
+ }
106
+ end
107
+ {
108
+ label: label,
109
+ data: data,
110
+ fill: false,
111
+ showLine: false,
112
+ backgroundColor: color
113
+ }
114
+ end
115
+ end