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,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
require 'jirametrics/groupable_issue_chart'
|
|
5
|
+
require 'jirametrics/board_movement_calculator'
|
|
5
6
|
|
|
6
7
|
class AgingWorkInProgressChart < ChartBase
|
|
7
8
|
include GroupableIssueChart
|
|
9
|
+
|
|
8
10
|
attr_accessor :possible_statuses, :board_id
|
|
9
11
|
attr_reader :board_columns
|
|
10
12
|
|
|
@@ -16,13 +18,33 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
16
18
|
This chart shows only work items that have started but not completed, grouped by the column
|
|
17
19
|
they're currently in. Hovering over a dot will show you the ID of that work item.
|
|
18
20
|
</p>
|
|
19
|
-
<
|
|
20
|
-
The
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
<p>
|
|
22
|
+
The shaded areas indicate what percentage of the work has passed that column within that time.
|
|
23
|
+
Notes:
|
|
24
|
+
<ul>
|
|
25
|
+
<li>It only shows columns that are considered "in progress". If you see a column that wouldn't normally
|
|
26
|
+
be thought of that way, then likely issues were moving backwards or continued to progress after hitting
|
|
27
|
+
that column.</li>
|
|
28
|
+
<li>If you see a colour group that drops as it moves to the right, that generally indicates that
|
|
29
|
+
a different number of data points is being included in each column. Probably because tickets moved
|
|
30
|
+
backwards athough it could also indicate that a ticket jumped over columns as it moved to the right.
|
|
31
|
+
</li>
|
|
32
|
+
</ul>
|
|
33
|
+
</p>
|
|
34
|
+
<div style="border: 1px solid gray; padding: 0.2em">
|
|
35
|
+
<% @percentiles.keys.sort.reverse.each do |percent| %>
|
|
36
|
+
<span style="padding-left: 0.5em; padding-right: 0.5em; vertical-align: middle;"><%= color_block @percentiles[percent] %> <%= percent %>%</span>
|
|
37
|
+
<% end %>
|
|
24
38
|
</div>
|
|
25
39
|
HTML
|
|
40
|
+
percentiles(
|
|
41
|
+
50 => '--aging-work-in-progress-chart-shading-50-color',
|
|
42
|
+
85 => '--aging-work-in-progress-chart-shading-85-color',
|
|
43
|
+
98 => '--aging-work-in-progress-chart-shading-98-color',
|
|
44
|
+
100 => '--aging-work-in-progress-chart-shading-100-color'
|
|
45
|
+
)
|
|
46
|
+
show_all_columns false
|
|
47
|
+
|
|
26
48
|
init_configuration_block(block) do
|
|
27
49
|
grouping_rules do |issue, rule|
|
|
28
50
|
rule.label = issue.type
|
|
@@ -34,15 +56,19 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
34
56
|
def run
|
|
35
57
|
determine_board_columns
|
|
36
58
|
|
|
37
|
-
@header_text += " on board: #{
|
|
59
|
+
@header_text += " on board: #{current_board.name}"
|
|
38
60
|
data_sets = make_data_sets
|
|
39
|
-
column_headings = @board_columns.collect(&:name)
|
|
40
61
|
|
|
41
|
-
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
62
|
+
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
63
|
+
adjust_chart_height
|
|
42
64
|
|
|
43
65
|
wrap_and_render(binding, __FILE__)
|
|
44
66
|
end
|
|
45
67
|
|
|
68
|
+
def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
|
|
69
|
+
@show_all_columns = show
|
|
70
|
+
end
|
|
71
|
+
|
|
46
72
|
def determine_board_columns
|
|
47
73
|
unmapped_statuses = current_board.possible_statuses.collect(&:id)
|
|
48
74
|
|
|
@@ -62,7 +88,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
62
88
|
board.id == @board_id && board.cycletime.in_progress?(issue)
|
|
63
89
|
end
|
|
64
90
|
|
|
65
|
-
|
|
91
|
+
@max_age = 20
|
|
66
92
|
rules_to_issues = group_issues aging_issues
|
|
67
93
|
data_sets = rules_to_issues.keys.collect do |rules|
|
|
68
94
|
{
|
|
@@ -73,7 +99,10 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
73
99
|
column = column_for issue: issue
|
|
74
100
|
next if column.nil?
|
|
75
101
|
|
|
76
|
-
|
|
102
|
+
@max_age = age if age > @max_age
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
'y' => age,
|
|
77
106
|
'x' => column.name,
|
|
78
107
|
'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
|
|
79
108
|
}
|
|
@@ -83,45 +112,101 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
83
112
|
'backgroundColor' => rules.color
|
|
84
113
|
}
|
|
85
114
|
end
|
|
86
|
-
data_sets << {
|
|
87
|
-
'type' => 'bar',
|
|
88
|
-
'label' => "#{percentage}%",
|
|
89
|
-
'barPercentage' => 1.0,
|
|
90
|
-
'categoryPercentage' => 1.0,
|
|
91
|
-
'backgroundColor' => CssVariable['--aging-work-in-progress-chart-shading-color'],
|
|
92
|
-
'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
|
|
93
|
-
}
|
|
94
|
-
end
|
|
95
115
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
116
|
+
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
117
|
+
|
|
118
|
+
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
119
|
+
|
|
120
|
+
@row_index_offset = data_sets.size
|
|
121
|
+
|
|
122
|
+
bar_data = []
|
|
123
|
+
calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
|
|
124
|
+
column_indexes_to_remove.reverse_each { |index| data.delete_at index }
|
|
125
|
+
color = @percentiles[percentage]
|
|
126
|
+
|
|
127
|
+
data_sets << {
|
|
128
|
+
'type' => 'bar',
|
|
129
|
+
'label' => "#{percentage}%",
|
|
130
|
+
'barPercentage' => 1.0,
|
|
131
|
+
'categoryPercentage' => 1.0,
|
|
132
|
+
'backgroundColor' => color,
|
|
133
|
+
'data' => data
|
|
134
|
+
}
|
|
135
|
+
bar_data << data
|
|
101
136
|
end
|
|
137
|
+
@bar_data = adjust_bar_data bar_data
|
|
138
|
+
|
|
139
|
+
data_sets
|
|
102
140
|
end
|
|
103
141
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
@board_columns.reverse.filter_map do |column|
|
|
107
|
-
next if column == @fake_column
|
|
142
|
+
def adjust_bar_data input
|
|
143
|
+
return [] if input.empty?
|
|
108
144
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
145
|
+
row_size = input.first.size
|
|
146
|
+
|
|
147
|
+
output = []
|
|
148
|
+
output << input.first
|
|
149
|
+
input.drop(1).each do |row|
|
|
150
|
+
previous_row = output.last
|
|
151
|
+
output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
output
|
|
112
155
|
end
|
|
113
156
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
157
|
+
def indexes_of_leading_and_trailing_zeros list
|
|
158
|
+
result = []
|
|
159
|
+
0.upto(list.size - 1) do |index|
|
|
160
|
+
break unless list[index].zero?
|
|
118
161
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
next if stop < start
|
|
162
|
+
result << index
|
|
163
|
+
end
|
|
122
164
|
|
|
123
|
-
|
|
165
|
+
stop_at = result.empty? ? 0 : (result.last + 1)
|
|
166
|
+
(list.size - 1).downto(stop_at).each do |index|
|
|
167
|
+
break unless list[index].zero?
|
|
168
|
+
|
|
169
|
+
result << index if list[index].zero?
|
|
124
170
|
end
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def trim_board_columns data_sets:, calculator:
|
|
175
|
+
return [] if @show_all_columns
|
|
176
|
+
|
|
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
|
+
# @fake_column is always the last element and is handled separately.
|
|
182
|
+
real_column_count = @board_columns.size - 1
|
|
183
|
+
|
|
184
|
+
# The last visible column always has artificially inflated age_data because
|
|
185
|
+
# ages_of_issues_when_leaving_column uses `today` as end_date when there is no
|
|
186
|
+
# next column. Exclude it from the right-boundary search so it is only kept when
|
|
187
|
+
# it has current aging items (handled by the last_aging fallback below).
|
|
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
|
|
200
|
+
|
|
201
|
+
indexes_to_remove =
|
|
202
|
+
if left_bound && right_bound
|
|
203
|
+
(0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
|
|
204
|
+
else
|
|
205
|
+
(0...real_column_count).to_a
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
|
|
209
|
+
indexes_to_remove
|
|
125
210
|
end
|
|
126
211
|
|
|
127
212
|
def column_for issue:
|
|
@@ -130,7 +215,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
130
215
|
end
|
|
131
216
|
end
|
|
132
217
|
|
|
133
|
-
def adjust_visibility_of_unmapped_status_column data_sets
|
|
218
|
+
def adjust_visibility_of_unmapped_status_column data_sets:
|
|
134
219
|
column_name = @fake_column.name
|
|
135
220
|
|
|
136
221
|
has_unmapped = data_sets.any? do |set|
|
|
@@ -139,12 +224,23 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
139
224
|
end
|
|
140
225
|
end
|
|
141
226
|
|
|
142
|
-
if has_unmapped
|
|
227
|
+
if has_unmapped && @description_text
|
|
143
228
|
@description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
|
|
144
229
|
'board but are still active. Most likely everyone has forgotten about them.</p>'
|
|
145
230
|
else
|
|
146
|
-
column_headings.pop
|
|
231
|
+
# @column_headings.pop
|
|
147
232
|
@board_columns.pop
|
|
148
233
|
end
|
|
149
234
|
end
|
|
235
|
+
|
|
236
|
+
def percentiles percentile_color_hash
|
|
237
|
+
@percentiles = percentile_color_hash.transform_values { |value| CssVariable[value] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def adjust_chart_height
|
|
241
|
+
min_height = @max_age * 5
|
|
242
|
+
|
|
243
|
+
@canvas_height = min_height if min_height > @canvas_height
|
|
244
|
+
@canvas_height = 400 if min_height > 400
|
|
245
|
+
end
|
|
150
246
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class AgingWorkTable < ChartBase
|
|
6
|
-
attr_accessor :today
|
|
6
|
+
attr_accessor :today
|
|
7
7
|
attr_reader :any_scrum_boards
|
|
8
8
|
|
|
9
9
|
def initialize block
|
|
@@ -22,30 +22,44 @@ class AgingWorkTable < ChartBase
|
|
|
22
22
|
If there are expedited items that haven't yet started then they're at the bottom of the table.
|
|
23
23
|
By the very definition of expedited, if we haven't started them already, we'd better get on that.
|
|
24
24
|
</p>
|
|
25
|
+
<p>
|
|
26
|
+
Legend:
|
|
27
|
+
<ul>
|
|
28
|
+
<li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
|
|
29
|
+
<li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
|
|
30
|
+
<li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</p>
|
|
25
33
|
TEXT
|
|
26
34
|
|
|
27
35
|
instance_eval(&block)
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
def run
|
|
31
|
-
|
|
39
|
+
initialize_calculator
|
|
32
40
|
aging_issues = select_aging_issues + expedited_but_not_started
|
|
33
41
|
|
|
34
42
|
wrap_and_render(binding, __FILE__)
|
|
35
43
|
end
|
|
36
44
|
|
|
45
|
+
# This is its own method simply so the tests can initialize the calculator without doing a full run.
|
|
46
|
+
def initialize_calculator
|
|
47
|
+
@today = date_range.end
|
|
48
|
+
@calculators = @all_boards.transform_values do |board|
|
|
49
|
+
BoardMovementCalculator.new board: board, issues: issues, today: @today
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
37
53
|
def expedited_but_not_started
|
|
38
54
|
@issues.select do |issue|
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
started_time, stopped_time = issue.started_stopped_times
|
|
56
|
+
started_time.nil? && stopped_time.nil? && issue.expedited?
|
|
41
57
|
end.sort_by(&:created)
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
def select_aging_issues
|
|
45
61
|
aging_issues = @issues.select do |issue|
|
|
46
|
-
|
|
47
|
-
started = cycletime.started_time(issue)
|
|
48
|
-
stopped = cycletime.stopped_time(issue)
|
|
62
|
+
started, stopped = issue.started_stopped_times
|
|
49
63
|
next false if started.nil? || stopped
|
|
50
64
|
next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
|
|
51
65
|
|
|
@@ -64,7 +78,7 @@ class AgingWorkTable < ChartBase
|
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
def blocked_text issue
|
|
67
|
-
started_time = issue.
|
|
81
|
+
started_time, _stopped_time = issue.started_stopped_times
|
|
68
82
|
return nil if started_time.nil?
|
|
69
83
|
|
|
70
84
|
current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
|
|
@@ -95,26 +109,52 @@ class AgingWorkTable < ChartBase
|
|
|
95
109
|
end
|
|
96
110
|
|
|
97
111
|
def sprints_text issue
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
issue.changes.each do |change|
|
|
101
|
-
next unless change.sprint?
|
|
102
|
-
|
|
103
|
-
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
|
104
|
-
end
|
|
105
|
-
sprint_ids.flatten!
|
|
106
|
-
|
|
107
|
-
issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
|
|
112
|
+
issue.sprints.collect do |sprint|
|
|
108
113
|
icon_text = nil
|
|
109
114
|
if sprint.active?
|
|
110
115
|
icon_text = icon_span title: 'Active sprint', icon: '➡️'
|
|
111
|
-
|
|
116
|
+
elsif sprint.closed?
|
|
112
117
|
icon_text = icon_span title: 'Sprint closed', icon: '✅'
|
|
113
118
|
end
|
|
114
119
|
"#{sprint.name} #{icon_text}"
|
|
115
120
|
end.join('<br />')
|
|
116
121
|
end
|
|
117
122
|
|
|
123
|
+
def dates_text issue
|
|
124
|
+
date = date_range.end
|
|
125
|
+
due = issue.due_date
|
|
126
|
+
message = nil
|
|
127
|
+
|
|
128
|
+
calculator = @calculators[issue.board.id]
|
|
129
|
+
days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
|
|
130
|
+
|
|
131
|
+
unless error
|
|
132
|
+
if due
|
|
133
|
+
if due < date
|
|
134
|
+
message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
|
|
135
|
+
error = 'Overdue'
|
|
136
|
+
elsif due == date
|
|
137
|
+
message = 'Due: <b>today</b>'
|
|
138
|
+
else
|
|
139
|
+
error = 'Due date at risk' if date_range.end + days_remaining > due
|
|
140
|
+
message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
"#{label_days days_remaining} left."
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
text = +''
|
|
148
|
+
text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
|
|
149
|
+
if days_remaining
|
|
150
|
+
text << "#{label_days days_remaining} left"
|
|
151
|
+
else
|
|
152
|
+
text << 'Unable to forecast'
|
|
153
|
+
end
|
|
154
|
+
text << ' | ' << message if message
|
|
155
|
+
text
|
|
156
|
+
end
|
|
157
|
+
|
|
118
158
|
def age_cutoff age = nil
|
|
119
159
|
@age_cutoff = age.to_i if age
|
|
120
160
|
@age_cutoff
|
|
@@ -134,4 +174,8 @@ class AgingWorkTable < ChartBase
|
|
|
134
174
|
|
|
135
175
|
result.reverse
|
|
136
176
|
end
|
|
177
|
+
|
|
178
|
+
def priority_text issue
|
|
179
|
+
"<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' style='max-width: 1em;'/>"
|
|
180
|
+
end
|
|
137
181
|
end
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'random-word'
|
|
4
4
|
|
|
5
|
-
class Anonymizer
|
|
5
|
+
class Anonymizer < ChartBase
|
|
6
6
|
# needed for testing
|
|
7
7
|
attr_reader :project_config, :issues
|
|
8
8
|
|
|
9
9
|
def initialize project_config:, date_adjustment: -200
|
|
10
|
+
super()
|
|
10
11
|
@project_config = project_config
|
|
11
12
|
@issues = @project_config.issues
|
|
12
13
|
@all_boards = @project_config.all_boards
|
|
@@ -20,6 +21,10 @@ class Anonymizer
|
|
|
20
21
|
anonymize_column_names
|
|
21
22
|
# anonymize_issue_statuses
|
|
22
23
|
anonymize_board_names
|
|
24
|
+
anonymize_labels_and_components
|
|
25
|
+
anonymize_sprints
|
|
26
|
+
anonymize_fix_versions
|
|
27
|
+
anonymize_server_url
|
|
23
28
|
shift_all_dates unless @date_adjustment.zero?
|
|
24
29
|
@file_system.log 'Anonymize done'
|
|
25
30
|
end
|
|
@@ -37,13 +42,25 @@ class Anonymizer
|
|
|
37
42
|
|
|
38
43
|
def anonymize_issue_keys_and_titles issues: @issues
|
|
39
44
|
counter = 0
|
|
45
|
+
seen_author_raws = {}
|
|
40
46
|
issues.each do |issue|
|
|
41
47
|
new_key = "ANON-#{counter += 1}"
|
|
42
48
|
|
|
43
49
|
issue.raw['key'] = new_key
|
|
44
50
|
issue.raw['fields']['summary'] = random_phrase
|
|
51
|
+
issue.raw['fields']['description'] = nil
|
|
45
52
|
issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
|
|
46
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
|
|
63
|
+
|
|
47
64
|
issue.issue_links.each do |link|
|
|
48
65
|
other_issue = link.other_issue
|
|
49
66
|
next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
|
|
@@ -54,6 +71,49 @@ class Anonymizer
|
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
73
|
|
|
74
|
+
def anonymize_labels_and_components
|
|
75
|
+
@issues.each do |issue|
|
|
76
|
+
issue.raw['fields']['labels'] = []
|
|
77
|
+
issue.raw['fields']['components'] = []
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def anonymize_sprints
|
|
82
|
+
sprint_counter = 0
|
|
83
|
+
sprint_name_map = {}
|
|
84
|
+
@all_boards.each_value do |board|
|
|
85
|
+
board.sprints.each do |sprint|
|
|
86
|
+
name = sprint.raw['name']
|
|
87
|
+
unless sprint_name_map[name]
|
|
88
|
+
sprint_counter += 1
|
|
89
|
+
sprint_name_map[name] = "Sprint-#{sprint_counter}"
|
|
90
|
+
end
|
|
91
|
+
sprint.raw['name'] = sprint_name_map[name]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def anonymize_fix_versions
|
|
97
|
+
version_counter = 0
|
|
98
|
+
version_name_map = {}
|
|
99
|
+
@issues.each do |issue|
|
|
100
|
+
issue.raw['fields']['fixVersions']&.each do |fix_version|
|
|
101
|
+
name = fix_version['name']
|
|
102
|
+
unless version_name_map[name]
|
|
103
|
+
version_counter += 1
|
|
104
|
+
version_name_map[name] = "Version-#{version_counter}"
|
|
105
|
+
end
|
|
106
|
+
fix_version['name'] = version_name_map[name]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def anonymize_server_url
|
|
112
|
+
@all_boards.each_value do |board|
|
|
113
|
+
board.raw['self'] = board.raw['self']&.sub(/^https?:\/\/[^\/]+/, 'https://anon.example.com')
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
57
117
|
def anonymize_column_names
|
|
58
118
|
@all_boards.each_key do |board_id|
|
|
59
119
|
@file_system.log "Anonymizing column names for board #{board_id}"
|
|
@@ -130,18 +190,19 @@ class Anonymizer
|
|
|
130
190
|
end
|
|
131
191
|
end
|
|
132
192
|
|
|
133
|
-
def shift_all_dates
|
|
134
|
-
|
|
193
|
+
def shift_all_dates date_adjustment: @date_adjustment
|
|
194
|
+
adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
|
|
195
|
+
@file_system.log "Shifting all dates by #{label_days date_adjustment}"
|
|
135
196
|
@issues.each do |issue|
|
|
136
197
|
issue.changes.each do |change|
|
|
137
|
-
change.time = change.time +
|
|
198
|
+
change.time = change.time + adjustment_in_seconds
|
|
138
199
|
end
|
|
139
200
|
|
|
140
|
-
issue.raw['fields']['updated'] = (issue.updated +
|
|
201
|
+
issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
|
|
141
202
|
end
|
|
142
203
|
|
|
143
204
|
range = @project_config.time_range
|
|
144
|
-
@project_config.time_range = (range.begin +
|
|
205
|
+
@project_config.time_range = (range.begin + adjustment_in_seconds)..(range.end + adjustment_in_seconds)
|
|
145
206
|
end
|
|
146
207
|
|
|
147
208
|
def random_name
|
|
@@ -184,4 +245,18 @@ class Anonymizer
|
|
|
184
245
|
board.raw['name'] = "#{random_phrase} board"
|
|
185
246
|
end
|
|
186
247
|
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def anonymize_author_raw author_raw, seen
|
|
252
|
+
return unless author_raw
|
|
253
|
+
return if seen[author_raw.object_id]
|
|
254
|
+
|
|
255
|
+
seen[author_raw.object_id] = true
|
|
256
|
+
name = random_name
|
|
257
|
+
author_raw['displayName'] = name
|
|
258
|
+
author_raw['name'] = name
|
|
259
|
+
author_raw.delete('emailAddress')
|
|
260
|
+
author_raw.delete('avatarUrls')
|
|
261
|
+
end
|
|
187
262
|
end
|