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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +73 -20
- data/lib/jirametrics/board_config.rb +10 -2
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +8 -6
- data/lib/jirametrics/download_config.rb +17 -2
- data/lib/jirametrics/downloader.rb +177 -108
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +5 -8
- data/lib/jirametrics/examples/standard_project.rb +54 -38
- data/lib/jirametrics/expedited_chart.rb +10 -9
- data/lib/jirametrics/exporter.rb +51 -16
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +83 -76
- data/lib/jirametrics/issue.rb +481 -97
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +7 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
58
|
+
html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
|
|
56
59
|
type: :header
|
|
57
60
|
end
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
stopped_time = cycletime.stopped_time(issue)
|
|
114
|
+
started_date, stopped_date = cycletime.started_stopped_dates(issue)
|
|
114
115
|
|
|
115
|
-
expedite_data << [
|
|
116
|
-
expedite_data << [
|
|
117
|
-
expedite_data.sort_by!
|
|
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 &&
|
|
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'
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|