jirametrics 2.31 → 3.0
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/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +19 -10
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +2 -2
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +73 -32
- data/lib/jirametrics.rb +6 -2
- metadata +13 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8411199c48209ed18008a509523bcd28c7d22c1145b492137ac5219972f35245
|
|
4
|
+
data.tar.gz: 3074f95dfc4b401408e615d71b957f5e5626514a6172de5855ec80a15a9bc82a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b7dc5f5abea1401da94464b80cb8b346f503055d5df92bac596af7c48b3021d4deb641af804710186a964f4d750b6d03a542cb9236c852b12e174887ba2def9
|
|
7
|
+
data.tar.gz: d9b6de864972518ae2a3ab91c92fc4eba3d411232b252afb31c041ea9575cfa1cebee4085211d0d1eb091335240da5a13038622765dc0ef3fd8c73f874ee4222
|
|
@@ -39,43 +39,18 @@ class AggregateConfig
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def include_issues_from project_name
|
|
42
|
-
project =
|
|
43
|
-
if project.nil?
|
|
44
|
-
file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
|
|
45
|
-
"project #{project_name.inspect} but it can't be found. Is it disabled?"
|
|
46
|
-
return
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
@project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
|
|
50
|
-
unless @project_config.jira_url == project.jira_url
|
|
51
|
-
raise 'Not allowed to aggregate projects from different Jira instances: ' \
|
|
52
|
-
"#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}. For project #{project_name}"
|
|
53
|
-
end
|
|
42
|
+
project = find_included_project project_name
|
|
43
|
+
return if project.nil?
|
|
54
44
|
|
|
55
45
|
@included_projects << project
|
|
56
|
-
|
|
57
|
-
issues = project.issues
|
|
58
|
-
else
|
|
59
|
-
issues = project.file_configs.first.issues
|
|
60
|
-
if project.file_configs.size > 1
|
|
61
|
-
log 'More than one file section is defined. For the aggregated view, we always use ' \
|
|
62
|
-
'the first file section'
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
46
|
+
issues = issues_for project
|
|
66
47
|
if issues.nil?
|
|
67
48
|
file_system.warning "No issues found for #{project_name}"
|
|
68
49
|
return
|
|
69
50
|
end
|
|
70
51
|
|
|
71
52
|
@project_config.add_issues issues
|
|
72
|
-
|
|
73
|
-
# Bring fix versions over
|
|
74
|
-
project.fix_versions.each do |fix_version|
|
|
75
|
-
unless @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
|
|
76
|
-
@project_config.fix_versions << fix_version
|
|
77
|
-
end
|
|
78
|
-
end
|
|
53
|
+
merge_fix_versions_from project
|
|
79
54
|
end
|
|
80
55
|
|
|
81
56
|
def find_time_range projects:
|
|
@@ -94,6 +69,44 @@ class AggregateConfig
|
|
|
94
69
|
|
|
95
70
|
private
|
|
96
71
|
|
|
72
|
+
# Resolve the named project and confirm it can be aggregated. Returns nil (with a warning) when the
|
|
73
|
+
# project can't be found; raises when it lives on a different Jira instance than the ones already
|
|
74
|
+
# included. The first included project's URL seeds the aggregate's URL.
|
|
75
|
+
def find_included_project project_name
|
|
76
|
+
project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
|
|
77
|
+
if project.nil?
|
|
78
|
+
file_system.warning "Aggregated project #{@project_config.name.inspect} is attempting to load " \
|
|
79
|
+
"project #{project_name.inspect} but it can't be found. Is it disabled?"
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
|
|
84
|
+
unless @project_config.jira_url == project.jira_url
|
|
85
|
+
raise 'Not allowed to aggregate projects from different Jira instances: ' \
|
|
86
|
+
"#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}. For project #{project_name}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
project
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def issues_for project
|
|
93
|
+
return project.issues if project.file_configs.empty?
|
|
94
|
+
|
|
95
|
+
if project.file_configs.size > 1
|
|
96
|
+
file_system.warning 'More than one file section is defined. For the aggregated view, we always use ' \
|
|
97
|
+
'the first file section'
|
|
98
|
+
end
|
|
99
|
+
project.file_configs.first.issues
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def merge_fix_versions_from project
|
|
103
|
+
project.fix_versions.each do |fix_version|
|
|
104
|
+
next if @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
|
|
105
|
+
|
|
106
|
+
@project_config.fix_versions << fix_version
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
97
110
|
def file_system
|
|
98
111
|
@project_config.exporter.file_system
|
|
99
112
|
end
|
|
@@ -102,10 +102,10 @@ class AgingWorkBarChart < ChartBase
|
|
|
102
102
|
|
|
103
103
|
def select_aging_issues issues:
|
|
104
104
|
issues.select do |issue|
|
|
105
|
-
|
|
106
|
-
next false unless
|
|
105
|
+
cycletime = issue.board.cycletime
|
|
106
|
+
next false unless cycletime.in_progress?(issue)
|
|
107
107
|
|
|
108
|
-
age = (date_range.end
|
|
108
|
+
age = cycletime.age(issue, today: date_range.end)
|
|
109
109
|
!(@age_cutoff && @age_cutoff >= age)
|
|
110
110
|
end
|
|
111
111
|
end
|
|
@@ -200,20 +200,21 @@ class AgingWorkBarChart < ChartBase
|
|
|
200
200
|
next
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
if change.time >= issue_start_time
|
|
204
|
-
color = settings['blocked_color'] || '--blocked-color'
|
|
205
|
-
color = settings['stalled_color'] || '--stalled-color' if starting_change.stalled?
|
|
206
|
-
|
|
207
|
-
results << BarChartRange.new(
|
|
208
|
-
start: starting_change.time, stop: change.time, color: CssVariable[color], title: starting_change.reasons
|
|
209
|
-
)
|
|
210
|
-
end
|
|
211
|
-
|
|
203
|
+
results << blocked_stalled_range(starting_change, change) if change.time >= issue_start_time
|
|
212
204
|
starting_change = change
|
|
213
205
|
end
|
|
214
206
|
results
|
|
215
207
|
end
|
|
216
208
|
|
|
209
|
+
def blocked_stalled_range starting_change, change
|
|
210
|
+
color = '--blocked-color'
|
|
211
|
+
color = '--stalled-color' if starting_change.stalled?
|
|
212
|
+
|
|
213
|
+
BarChartRange.new(
|
|
214
|
+
start: starting_change.time, stop: change.time, color: CssVariable[color], title: starting_change.reasons
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
217
218
|
def collect_priority_ranges issue:
|
|
218
219
|
expedited_priority_names = settings['expedited_priority_names']
|
|
219
220
|
|
|
@@ -251,44 +252,55 @@ class AgingWorkBarChart < ChartBase
|
|
|
251
252
|
issue.changes.each do |change|
|
|
252
253
|
next unless change.sprint?
|
|
253
254
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
removed_sprint_ids.each do |id|
|
|
258
|
-
data = open_sprints.delete(id)
|
|
259
|
-
next unless data
|
|
260
|
-
|
|
261
|
-
completed = data[:sprint].completed_time
|
|
262
|
-
stop = completed ? [change.time, completed].min : change.time
|
|
263
|
-
results << BarChartRange.new(
|
|
264
|
-
start: data[:start_time], stop: stop,
|
|
265
|
-
color: CssVariable['--sprint-color'], title: data[:sprint].name
|
|
266
|
-
)
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
added_sprint_ids.each do |id|
|
|
270
|
-
sprint = issue.board.sprints.find { |s| s.id == id }
|
|
271
|
-
next unless sprint
|
|
272
|
-
next if sprint.future?
|
|
273
|
-
|
|
274
|
-
start_time = [sprint.start_time, change.time].max
|
|
275
|
-
open_sprints[id] = { start_time: start_time, sprint: sprint }
|
|
276
|
-
end
|
|
255
|
+
close_removed_sprints(change, open_sprints, results)
|
|
256
|
+
open_added_sprints(change, issue, open_sprints)
|
|
277
257
|
end
|
|
278
258
|
|
|
259
|
+
# Anything still open at the end runs to the sprint's completion, or the end of the range if it's
|
|
260
|
+
# still active.
|
|
279
261
|
open_sprints.each_value do |data|
|
|
280
262
|
next if data[:sprint].future?
|
|
281
263
|
|
|
282
|
-
|
|
283
|
-
results << BarChartRange.new(
|
|
284
|
-
start: data[:start_time], stop: stop,
|
|
285
|
-
color: CssVariable['--sprint-color'], title: data[:sprint].name
|
|
286
|
-
)
|
|
264
|
+
results << sprint_range(data, data[:sprint].completed_time || time_range.end)
|
|
287
265
|
end
|
|
288
266
|
|
|
289
267
|
results
|
|
290
268
|
end
|
|
291
269
|
|
|
270
|
+
# For each sprint this change leaves, close its open range. The range stops when the issue left, or
|
|
271
|
+
# when the sprint completed if that came first.
|
|
272
|
+
def close_removed_sprints change, open_sprints, results
|
|
273
|
+
removed_sprint_ids = change.old_value_id - change.value_id
|
|
274
|
+
removed_sprint_ids.each do |id|
|
|
275
|
+
data = open_sprints.delete(id)
|
|
276
|
+
next unless data
|
|
277
|
+
|
|
278
|
+
completed = data[:sprint].completed_time
|
|
279
|
+
stop = completed ? [change.time, completed].min : change.time
|
|
280
|
+
results << sprint_range(data, stop)
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# For each sprint this change joins (that exists and has already started), open a range. It starts
|
|
285
|
+
# when the issue joined, or when the sprint began if the issue was added beforehand.
|
|
286
|
+
def open_added_sprints change, issue, open_sprints
|
|
287
|
+
added_sprint_ids = change.value_id - change.old_value_id
|
|
288
|
+
added_sprint_ids.each do |id|
|
|
289
|
+
sprint = issue.board.sprints.find { |s| s.id == id }
|
|
290
|
+
next unless sprint
|
|
291
|
+
next if sprint.future?
|
|
292
|
+
|
|
293
|
+
start_time = [sprint.start_time, change.time].max
|
|
294
|
+
open_sprints[id] = { start_time: start_time, sprint: sprint }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def sprint_range data, stop
|
|
299
|
+
BarChartRange.new(
|
|
300
|
+
start: data[:start_time], stop: stop, color: CssVariable['--sprint-color'], title: data[:sprint].name
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
292
304
|
def create_range_for_priority previous_change:, stop_time:, expedited_priority_names:
|
|
293
305
|
expedited = expedited_priority_names.include?(previous_change.value)
|
|
294
306
|
title = "Priority: #{previous_change.value}"
|
|
@@ -65,6 +65,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
65
65
|
wrap_and_render(binding, __FILE__)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Boolean positional is the config-block DSL's public API (show_all_columns false).
|
|
68
69
|
def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
|
|
69
70
|
@show_all_columns = show
|
|
70
71
|
end
|
|
@@ -83,60 +84,58 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
def make_data_sets
|
|
86
|
-
aging_issues = @issues.select
|
|
87
|
-
board = issue.board
|
|
88
|
-
board.id == @board_id && board.cycletime.in_progress?(issue)
|
|
89
|
-
end
|
|
87
|
+
aging_issues = @issues.select { |issue| aging_issue_on_board? issue }
|
|
90
88
|
|
|
91
|
-
@max_age = 20
|
|
92
89
|
rules_to_issues = group_issues aging_issues
|
|
93
|
-
data_sets = rules_to_issues.keys.collect
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'label' => rules.label,
|
|
97
|
-
'data' => rules_to_issues[rules].filter_map do |issue|
|
|
98
|
-
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
99
|
-
column = column_for issue: issue
|
|
100
|
-
next if column.nil?
|
|
101
|
-
|
|
102
|
-
@max_age = age if age > @max_age
|
|
103
|
-
|
|
104
|
-
{
|
|
105
|
-
'y' => age,
|
|
106
|
-
'x' => column.name,
|
|
107
|
-
'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
|
|
108
|
-
}
|
|
109
|
-
end,
|
|
110
|
-
'fill' => false,
|
|
111
|
-
'showLine' => false,
|
|
112
|
-
'backgroundColor' => rules.color
|
|
113
|
-
}
|
|
114
|
-
end
|
|
90
|
+
data_sets = rules_to_issues.keys.collect { |rules| line_data_set(rules, rules_to_issues[rules]) }
|
|
91
|
+
# The y-axis is scaled to the oldest item, but never shorter than 20 days.
|
|
92
|
+
@max_age = data_sets.flat_map { |set| set['data'].collect { |point| point['y'] } }.push(20).max
|
|
115
93
|
|
|
116
94
|
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
117
|
-
|
|
118
95
|
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
119
|
-
|
|
120
96
|
@row_index_offset = data_sets.size
|
|
121
97
|
|
|
98
|
+
append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
99
|
+
data_sets
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def aging_issue_on_board? issue
|
|
103
|
+
issue.board.id == @board_id && issue.board.cycletime.in_progress?(issue)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def line_data_set rules, issues
|
|
107
|
+
{
|
|
108
|
+
'type' => 'line',
|
|
109
|
+
'label' => rules.label,
|
|
110
|
+
'data' => aging_datapoints(issues),
|
|
111
|
+
'fill' => false,
|
|
112
|
+
'showLine' => false,
|
|
113
|
+
'backgroundColor' => rules.color
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One {x: column, y: age, title} point per issue that's currently visible on the board.
|
|
118
|
+
def aging_datapoints issues
|
|
119
|
+
issues.filter_map do |issue|
|
|
120
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
121
|
+
column = column_for issue: issue
|
|
122
|
+
next if column.nil?
|
|
123
|
+
|
|
124
|
+
{ 'y' => age, 'x' => column.name, 'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"] }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
122
129
|
bar_data = []
|
|
123
130
|
calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
|
|
124
131
|
column_indexes_to_remove.reverse_each { |index| data.delete_at index }
|
|
125
|
-
color = @percentiles[percentage]
|
|
126
|
-
|
|
127
132
|
data_sets << {
|
|
128
|
-
'type' => 'bar',
|
|
129
|
-
'
|
|
130
|
-
'barPercentage' => 1.0,
|
|
131
|
-
'categoryPercentage' => 1.0,
|
|
132
|
-
'backgroundColor' => color,
|
|
133
|
-
'data' => data
|
|
133
|
+
'type' => 'bar', 'label' => "#{percentage}%", 'barPercentage' => 1.0, 'categoryPercentage' => 1.0,
|
|
134
|
+
'backgroundColor' => @percentiles[percentage], 'data' => data
|
|
134
135
|
}
|
|
135
136
|
bar_data << data
|
|
136
137
|
end
|
|
137
138
|
@bar_data = adjust_bar_data bar_data
|
|
138
|
-
|
|
139
|
-
data_sets
|
|
140
139
|
end
|
|
141
140
|
|
|
142
141
|
def adjust_bar_data input
|
|
@@ -174,29 +173,13 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
174
173
|
def trim_board_columns data_sets:, calculator:
|
|
175
174
|
return [] if @show_all_columns
|
|
176
175
|
|
|
177
|
-
columns_with_aging_items = data_sets.flat_map do |set|
|
|
178
|
-
set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
|
|
179
|
-
end.uniq
|
|
180
|
-
|
|
181
176
|
# @fake_column is always the last element and is handled separately.
|
|
182
177
|
real_column_count = @board_columns.size - 1
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
age_data = calculator.age_data_for(percentage: 100)
|
|
189
|
-
last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
|
|
190
|
-
|
|
191
|
-
in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
|
|
192
|
-
first_aging = (0...real_column_count).find(&in_current)
|
|
193
|
-
last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
|
|
194
|
-
|
|
195
|
-
# Combine: include any column with age_data (up to but not including the last visible
|
|
196
|
-
# column) and any column with current aging items.
|
|
197
|
-
first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
|
|
198
|
-
left_bound = [first_data, first_aging].compact.min
|
|
199
|
-
right_bound = [last_data, last_aging].compact.max
|
|
178
|
+
left_bound, right_bound = column_trim_bounds(
|
|
179
|
+
aging_columns: aging_column_names(data_sets),
|
|
180
|
+
age_data: calculator.age_data_for(percentage: 100),
|
|
181
|
+
real_column_count: real_column_count
|
|
182
|
+
)
|
|
200
183
|
|
|
201
184
|
indexes_to_remove =
|
|
202
185
|
if left_bound && right_bound
|
|
@@ -209,6 +192,28 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
209
192
|
indexes_to_remove
|
|
210
193
|
end
|
|
211
194
|
|
|
195
|
+
# The distinct column names that currently have aging items on the chart.
|
|
196
|
+
def aging_column_names data_sets
|
|
197
|
+
data_sets.flat_map do |set|
|
|
198
|
+
set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
|
|
199
|
+
end.uniq
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The [left, right] column indexes to keep, combining columns with current aging items and columns
|
|
203
|
+
# with historical age data. The last visible column is excluded from the right-boundary history search
|
|
204
|
+
# because ages_of_issues_when_leaving_column inflates its age_data (it uses `today` as the end date
|
|
205
|
+
# when there's no next column), so it's kept only when it has a current aging item.
|
|
206
|
+
def column_trim_bounds aging_columns:, age_data:, real_column_count:
|
|
207
|
+
in_current = ->(i) { aging_columns.include?(@board_columns[i].name) }
|
|
208
|
+
first_aging = (0...real_column_count).find(&in_current)
|
|
209
|
+
last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
|
|
210
|
+
|
|
211
|
+
first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
|
|
212
|
+
last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
|
|
213
|
+
|
|
214
|
+
[[first_data, first_aging].compact.min, [last_data, last_aging].compact.max]
|
|
215
|
+
end
|
|
216
|
+
|
|
212
217
|
def column_for issue:
|
|
213
218
|
@board_columns.find do |board_column|
|
|
214
219
|
board_column.status_ids.include? issue.status.id
|
|
@@ -58,16 +58,19 @@ class AgingWorkTable < ChartBase
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def select_aging_issues
|
|
61
|
-
aging_issues = @issues.select
|
|
62
|
-
started, stopped = issue.started_stopped_times
|
|
63
|
-
next false if started.nil? || stopped
|
|
64
|
-
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
65
|
-
|
|
66
|
-
age = (@today - started.to_date).to_i + 1
|
|
67
|
-
age > @age_cutoff
|
|
68
|
-
end
|
|
61
|
+
aging_issues = @issues.select { |issue| aging_issue? issue }
|
|
69
62
|
@any_scrum_boards = aging_issues.any? { |issue| issue.board.scrum? }
|
|
70
|
-
aging_issues.
|
|
63
|
+
aging_issues.sort_by { |issue| -issue.board.cycletime.age(issue, today: @today) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An issue is "aging" if it's in progress and either flagged as needing attention (blocked or
|
|
67
|
+
# expedited) or older than the configured cutoff.
|
|
68
|
+
def aging_issue? issue
|
|
69
|
+
cycletime = issue.board.cycletime
|
|
70
|
+
return false unless cycletime.in_progress?(issue)
|
|
71
|
+
return true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
72
|
+
|
|
73
|
+
cycletime.age(issue, today: @today) > @age_cutoff
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def expedited_text issue
|
|
@@ -121,36 +124,36 @@ class AgingWorkTable < ChartBase
|
|
|
121
124
|
end
|
|
122
125
|
|
|
123
126
|
def dates_text issue
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
days_remaining, error = @calculators[issue.board.id].forecasted_days_remaining_and_message(
|
|
128
|
+
issue: issue, today: @today
|
|
129
|
+
)
|
|
126
130
|
message = nil
|
|
131
|
+
message, error = due_date_status(issue, days_remaining, error) unless error
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
133
|
+
forecast_line days_remaining, error, message
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Returns [message, error] describing where the due date sits relative to today and the forecast:
|
|
137
|
+
# overdue, due today, or a future date that may be at risk. Only called when the forecast succeeded.
|
|
138
|
+
def due_date_status issue, days_remaining, error
|
|
139
|
+
due = issue.due_date
|
|
140
|
+
return [nil, error] unless due
|
|
141
|
+
|
|
142
|
+
date = date_range.end
|
|
143
|
+
if due < date
|
|
144
|
+
["Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)", 'Overdue']
|
|
145
|
+
elsif due == date
|
|
146
|
+
['Due: <b>today</b>', error]
|
|
147
|
+
else
|
|
148
|
+
at_risk = date_range.end + days_remaining > due
|
|
149
|
+
["Due: <b>#{due}</b> (#{label_days (due - @today).to_i})", at_risk ? 'Due date at risk' : error]
|
|
145
150
|
end
|
|
151
|
+
end
|
|
146
152
|
|
|
153
|
+
def forecast_line days_remaining, error, message
|
|
147
154
|
text = +''
|
|
148
155
|
text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
|
|
149
|
-
|
|
150
|
-
text << "#{label_days days_remaining} left"
|
|
151
|
-
else
|
|
152
|
-
text << 'Unable to forecast'
|
|
153
|
-
end
|
|
156
|
+
text << (days_remaining ? "#{label_days days_remaining} left" : 'Unable to forecast')
|
|
154
157
|
text << ' | ' << message if message
|
|
155
158
|
text
|
|
156
159
|
end
|
|
@@ -19,7 +19,10 @@ class Anonymizer < ChartBase
|
|
|
19
19
|
def run
|
|
20
20
|
anonymize_issue_keys_and_titles
|
|
21
21
|
anonymize_column_names
|
|
22
|
-
#
|
|
22
|
+
# Status names are deliberately left alone. They show up throughout the reports (hover text, aging
|
|
23
|
+
# tables, the board columns themselves), so scrubbing them to tokens would make those displays
|
|
24
|
+
# useless -- you'd lose the workflow story that makes the report worth reading. If a client's status
|
|
25
|
+
# vocabulary is itself confidential, rename the statuses in Jira rather than anonymizing here.
|
|
23
26
|
anonymize_board_names
|
|
24
27
|
anonymize_labels_and_components
|
|
25
28
|
anonymize_sprints
|
|
@@ -35,31 +38,19 @@ class Anonymizer < ChartBase
|
|
|
35
38
|
# cautious and try five times.
|
|
36
39
|
5.times do |i|
|
|
37
40
|
return RandomWord.phrases.next.tr('_', ' ')
|
|
38
|
-
rescue # rubocop:disable Style/RescueStandardError
|
|
41
|
+
rescue # rubocop:disable Style/RescueStandardError
|
|
39
42
|
@file_system.log "Random word blew up on attempt #{i + 1}"
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
46
|
def anonymize_issue_keys_and_titles issues: @issues
|
|
44
47
|
counter = 0
|
|
45
|
-
seen_author_raws = {}
|
|
48
|
+
seen_author_raws = {}.compare_by_identity
|
|
46
49
|
issues.each do |issue|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
issue.raw['
|
|
50
|
-
issue
|
|
51
|
-
issue.raw['fields']['description'] = nil
|
|
52
|
-
issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
|
|
53
|
-
|
|
54
|
-
anonymize_author_raw(issue.raw['fields']['creator'], seen_author_raws)
|
|
55
|
-
|
|
56
|
-
issue.changes.each do |change|
|
|
57
|
-
anonymize_author_raw(change.author_raw, seen_author_raws)
|
|
58
|
-
if change.comment? || change.description?
|
|
59
|
-
change.value = nil
|
|
60
|
-
change.old_value = nil
|
|
61
|
-
end
|
|
62
|
-
end
|
|
50
|
+
issue.raw['key'] = "ANON-#{counter += 1}"
|
|
51
|
+
anonymize_issue_summary_fields issue
|
|
52
|
+
anonymize_author_raw issue.raw['fields']['creator'], seen_author_raws
|
|
53
|
+
anonymize_change_content issue, seen_author_raws
|
|
63
54
|
|
|
64
55
|
issue.issue_links.each do |link|
|
|
65
56
|
other_issue = link.other_issue
|
|
@@ -71,6 +62,24 @@ class Anonymizer < ChartBase
|
|
|
71
62
|
end
|
|
72
63
|
end
|
|
73
64
|
|
|
65
|
+
def anonymize_issue_summary_fields issue
|
|
66
|
+
issue.raw['fields']['summary'] = random_phrase
|
|
67
|
+
issue.raw['fields']['description'] = nil
|
|
68
|
+
return if issue.raw['fields']['assignee'].nil?
|
|
69
|
+
|
|
70
|
+
issue.raw['fields']['assignee']['displayName'] = random_name
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def anonymize_change_content issue, seen_author_raws
|
|
74
|
+
issue.changes.each do |change|
|
|
75
|
+
anonymize_author_raw change.author_raw, seen_author_raws
|
|
76
|
+
next unless change.comment? || change.description?
|
|
77
|
+
|
|
78
|
+
change.value = nil
|
|
79
|
+
change.old_value = nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
74
83
|
def anonymize_labels_and_components
|
|
75
84
|
@issues.each do |issue|
|
|
76
85
|
issue.raw['fields']['labels'] = []
|
|
@@ -110,7 +119,7 @@ class Anonymizer < ChartBase
|
|
|
110
119
|
|
|
111
120
|
def anonymize_server_url
|
|
112
121
|
@all_boards.each_value do |board|
|
|
113
|
-
board.raw['self'] = board.raw['self']&.sub(
|
|
122
|
+
board.raw['self'] = board.raw['self']&.sub(%r{^https?://[^/]+}, 'https://anon.example.com')
|
|
114
123
|
end
|
|
115
124
|
end
|
|
116
125
|
|
|
@@ -126,70 +135,6 @@ class Anonymizer < ChartBase
|
|
|
126
135
|
end
|
|
127
136
|
end
|
|
128
137
|
|
|
129
|
-
def build_status_name_hash
|
|
130
|
-
next_status = 'a'
|
|
131
|
-
status_name_hash = {}
|
|
132
|
-
@issues.each do |issue|
|
|
133
|
-
issue.changes.each do |change|
|
|
134
|
-
next unless change.status?
|
|
135
|
-
|
|
136
|
-
# TODO: Do old value too
|
|
137
|
-
status_key = change.value
|
|
138
|
-
if status_name_hash[status_key].nil?
|
|
139
|
-
status_name_hash[status_key] = "status-#{next_status}"
|
|
140
|
-
next_status = next_status.next
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
@possible_statuses.each do |status|
|
|
146
|
-
status_key = status.name
|
|
147
|
-
if status_name_hash[status_key].nil?
|
|
148
|
-
status_name_hash[status_key] = "status-#{next_status}"
|
|
149
|
-
next_status = next_status.next
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
status_name_hash
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def anonymize_issue_statuses
|
|
157
|
-
@file_system.log 'Anonymizing issue statuses and status categories'
|
|
158
|
-
status_name_hash = build_status_name_hash
|
|
159
|
-
|
|
160
|
-
@issues.each do |issue|
|
|
161
|
-
# This is where we create URL's from
|
|
162
|
-
issue.raw['self'] = nil
|
|
163
|
-
|
|
164
|
-
issue.changes.each do |change|
|
|
165
|
-
next unless change.status?
|
|
166
|
-
|
|
167
|
-
status_key = change.value
|
|
168
|
-
anonymized_value = status_name_hash[status_key]
|
|
169
|
-
raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
|
|
170
|
-
|
|
171
|
-
change.value = anonymized_value
|
|
172
|
-
|
|
173
|
-
next if change.old_value.nil?
|
|
174
|
-
|
|
175
|
-
status_key = change.old_value
|
|
176
|
-
anonymized_value = status_name_hash[status_key]
|
|
177
|
-
raise "status_name_hash[#{status_key.inspect} is nil" if anonymized_value.nil?
|
|
178
|
-
|
|
179
|
-
change.old_value = anonymized_value
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
@possible_statuses.each do |status|
|
|
184
|
-
status_key = status.name
|
|
185
|
-
if status_name_hash[status_key].nil?
|
|
186
|
-
raise "Can't find status_key #{status_key.inspect} in #{status_name_hash.inspect}"
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
status.name = status_name_hash[status_key] unless status_name_hash[status_key].nil?
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
138
|
def shift_all_dates date_adjustment: @date_adjustment
|
|
194
139
|
adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
|
|
195
140
|
@file_system.log "Shifting all dates by #{label_days date_adjustment}"
|
|
@@ -250,9 +195,9 @@ class Anonymizer < ChartBase
|
|
|
250
195
|
|
|
251
196
|
def anonymize_author_raw author_raw, seen
|
|
252
197
|
return unless author_raw
|
|
253
|
-
return if seen[author_raw
|
|
198
|
+
return if seen[author_raw]
|
|
254
199
|
|
|
255
|
-
seen[author_raw
|
|
200
|
+
seen[author_raw] = true
|
|
256
201
|
name = random_name
|
|
257
202
|
author_raw['displayName'] = name
|
|
258
203
|
author_raw['name'] = name
|