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
|
@@ -38,7 +38,7 @@ class ChartBase
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def before_run
|
|
41
|
-
@call_before_run_procs&.each
|
|
41
|
+
@call_before_run_procs&.each(&:call)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def aggregated_project?
|
|
@@ -53,12 +53,13 @@ class ChartBase
|
|
|
53
53
|
def render caller_binding, file
|
|
54
54
|
pathname = Pathname.new(File.realpath(file))
|
|
55
55
|
basename = pathname.basename.to_s
|
|
56
|
-
|
|
56
|
+
match = basename.match(/^(?<template_name>.+)\.rb$/)
|
|
57
|
+
raise "Unexpected filename #{basename.inspect}" unless match
|
|
57
58
|
|
|
58
59
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
59
60
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
60
61
|
|
|
61
|
-
erb = ERB.new file_system.load "#{html_directory}/#{
|
|
62
|
+
erb = ERB.new file_system.load "#{html_directory}/#{match[:template_name]}.erb"
|
|
62
63
|
erb.result(caller_binding)
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -85,26 +86,14 @@ class ChartBase
|
|
|
85
86
|
@chart_colors[type] ||= random_color
|
|
86
87
|
end
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
# Defines label_days, label_hours, label_minutes and label_issues - each renders a pluralised count
|
|
90
|
+
# like "3 days" or "1 issue", or 'unknown' for a nil count. Generated because only the unit differs.
|
|
91
|
+
%w[day hour minute issue].each do |unit|
|
|
92
|
+
define_method "label_#{unit}s" do |count|
|
|
93
|
+
return 'unknown' if count.nil?
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def label_hours hours
|
|
95
|
-
return 'unknown' if hours.nil?
|
|
96
|
-
|
|
97
|
-
"#{hours} hour#{'s' unless hours == 1}"
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def label_minutes minutes
|
|
101
|
-
return 'unknown' if minutes.nil?
|
|
102
|
-
|
|
103
|
-
"#{minutes} minute#{'s' unless minutes == 1}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def label_issues count
|
|
107
|
-
"#{count} issue#{'s' unless count == 1}"
|
|
95
|
+
"#{count} #{unit}#{'s' unless count == 1}"
|
|
96
|
+
end
|
|
108
97
|
end
|
|
109
98
|
|
|
110
99
|
def to_human_readable number
|
|
@@ -146,25 +135,17 @@ class ChartBase
|
|
|
146
135
|
erb.result(binding)
|
|
147
136
|
end
|
|
148
137
|
|
|
138
|
+
# Returns the non-working stretches (weekends and holidays) within the range, each as a Date range of
|
|
139
|
+
# consecutive days.
|
|
149
140
|
def holidays date_range: @date_range
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
else
|
|
159
|
-
end_date = date
|
|
160
|
-
end
|
|
161
|
-
elsif start_date
|
|
162
|
-
result << (start_date..(end_date || start_date))
|
|
163
|
-
start_date = nil
|
|
164
|
-
end_date = nil
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
result
|
|
141
|
+
date_range
|
|
142
|
+
.select { |date| non_working_day?(date) }
|
|
143
|
+
.slice_when { |previous_date, date| date != previous_date + 1 }
|
|
144
|
+
.collect { |run| run.first..run.last }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def non_working_day? date
|
|
148
|
+
date.saturday? || date.sunday? || holiday_dates.include?(date)
|
|
168
149
|
end
|
|
169
150
|
|
|
170
151
|
def working_days_annotation
|
|
@@ -287,35 +268,40 @@ class ChartBase
|
|
|
287
268
|
# if it's a ChangeItem then use_old_status will specify whether we're using the new or old
|
|
288
269
|
# Either way, is_category will format the category rather than the status
|
|
289
270
|
def format_status object, board:, is_category: false, use_old_status: false
|
|
290
|
-
status =
|
|
291
|
-
|
|
271
|
+
status, error_message = resolve_status object, board: board, use_old_status: use_old_status
|
|
272
|
+
return "<span style='color: red'>#{error_message}</span>" if error_message
|
|
292
273
|
|
|
274
|
+
color = status_category_color status
|
|
275
|
+
visibility = is_category ? '' : not_visible_icon(status, board)
|
|
276
|
+
text = is_category ? status.category : status
|
|
277
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Resolves the object being formatted into [status, error_message]. A Status is itself; a ChangeItem is
|
|
281
|
+
# looked up by id, yielding an error message (the raw value) when the id isn't a known status.
|
|
282
|
+
def resolve_status object, board:, use_old_status:
|
|
293
283
|
case object
|
|
294
284
|
when ChangeItem
|
|
295
285
|
id = use_old_status ? object.old_value_id : object.value_id
|
|
296
286
|
status = board.possible_statuses.find_by_id(id)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
287
|
+
return [status, nil] unless status.nil?
|
|
288
|
+
|
|
289
|
+
[nil, use_old_status ? object.old_value : object.value]
|
|
300
290
|
when Status
|
|
301
|
-
|
|
291
|
+
[object, nil]
|
|
302
292
|
else
|
|
303
293
|
raise "Unexpected type: #{object.class}"
|
|
304
294
|
end
|
|
295
|
+
end
|
|
305
296
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
297
|
+
# The 👀 marker for a status that isn't mapped to any column, or '' when it is visible.
|
|
298
|
+
def not_visible_icon status, board
|
|
299
|
+
return '' if board.visible_columns.any? { |column| column.status_ids.include? status.id }
|
|
309
300
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
icon: ' 👀'
|
|
315
|
-
)
|
|
316
|
-
end
|
|
317
|
-
text = is_category ? status.category : status
|
|
318
|
-
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
301
|
+
icon_span(
|
|
302
|
+
title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
|
|
303
|
+
icon: ' 👀'
|
|
304
|
+
)
|
|
319
305
|
end
|
|
320
306
|
|
|
321
307
|
def icon_span title:, icon:
|
|
@@ -388,12 +374,12 @@ class ChartBase
|
|
|
388
374
|
@cycletime || issue.board.cycletime
|
|
389
375
|
end
|
|
390
376
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
end
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
377
|
+
# seam_start and seam_end bracket each chart's HTML with a marker comment the stitcher uses to
|
|
378
|
+
# reassemble the report. Generated because only the start/end word differs.
|
|
379
|
+
%w[start end].each do |position|
|
|
380
|
+
define_method "seam_#{position}" do |type = 'chart'|
|
|
381
|
+
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
|
|
382
|
+
end
|
|
397
383
|
end
|
|
398
384
|
|
|
399
385
|
def render_axis_title axis_direction
|
|
@@ -35,7 +35,8 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
35
35
|
end
|
|
36
36
|
private_constant :CfdColumnRules
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Long only because of the inline description_text heredoc and one-time setup; splitting wouldn't help.
|
|
39
|
+
def initialize block # rubocop:disable Metrics/MethodLength
|
|
39
40
|
super()
|
|
40
41
|
header_text 'Cumulative Flow Diagram'
|
|
41
42
|
description_text <<~HTML
|
|
@@ -71,8 +72,14 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
71
72
|
</div>
|
|
72
73
|
<div class="p">
|
|
73
74
|
See also: This article on <a href="https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/">how to read a CFD</a>.
|
|
74
|
-
</div
|
|
75
|
+
</div>#{' '}
|
|
75
76
|
HTML
|
|
77
|
+
# Defaults come from CSS variables (theme handled in CSS). The config block below can still
|
|
78
|
+
# override any of them, or set one to nil to switch off that line/triangle.
|
|
79
|
+
@triangle_color = CssVariable['--cfd-triangle-color']
|
|
80
|
+
@arrival_rate_line_color = CssVariable['--cfd-arrival-rate-line-color']
|
|
81
|
+
@departure_rate_line_color = CssVariable['--cfd-departure-rate-line-color']
|
|
82
|
+
|
|
76
83
|
instance_eval(&block)
|
|
77
84
|
end
|
|
78
85
|
|
|
@@ -93,17 +100,7 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
def run
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
column_rules_list = all_columns.map do |column|
|
|
99
|
-
rules = CfdColumnRules.new
|
|
100
|
-
@column_rules_block&.call(column, rules)
|
|
101
|
-
rules
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
|
|
105
|
-
active_columns = active_pairs.map(&:first)
|
|
106
|
-
active_rules = active_pairs.map(&:last)
|
|
103
|
+
active_columns, active_rules = active_columns_and_rules(current_board.visible_columns)
|
|
107
104
|
|
|
108
105
|
cfd = CfdDataBuilder.new(
|
|
109
106
|
board: current_board,
|
|
@@ -117,36 +114,16 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
117
114
|
correction_windows = cfd[:correction_windows]
|
|
118
115
|
column_count = columns.size
|
|
119
116
|
|
|
120
|
-
|
|
121
|
-
# cumulative[i] = issues that reached column i or further.
|
|
122
|
-
# marginal[i] = cumulative[i] - cumulative[i+1] (last column: marginal = cumulative)
|
|
123
|
-
daily_marginals = daily_counts.transform_values do |cumulative|
|
|
124
|
-
cumulative.each_with_index.map do |count, i|
|
|
125
|
-
i < column_count - 1 ? count - cumulative[i + 1] : count
|
|
126
|
-
end
|
|
127
|
-
end
|
|
117
|
+
daily_marginals = marginal_band_heights(daily_counts, column_count)
|
|
128
118
|
|
|
129
119
|
border_colors = active_rules.map { |rules| rules.color || random_color }
|
|
130
120
|
|
|
131
121
|
fill_colors = active_rules.zip(border_colors).map { |rules, border| fill_color_for(rules, border) }
|
|
132
122
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
|
|
138
|
-
|
|
139
|
-
{
|
|
140
|
-
label: active_rules[col_index].label || name,
|
|
141
|
-
label_hint: active_rules[col_index].label_hint,
|
|
142
|
-
data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
|
|
143
|
-
backgroundColor: fill_colors[col_index],
|
|
144
|
-
borderColor: border_colors[col_index],
|
|
145
|
-
fill: true,
|
|
146
|
-
tension: 0,
|
|
147
|
-
segment: Segment.new(col_windows)
|
|
148
|
-
}
|
|
149
|
-
end.reverse
|
|
123
|
+
data_sets = build_data_sets(
|
|
124
|
+
columns: columns, correction_windows: correction_windows, active_rules: active_rules,
|
|
125
|
+
daily_marginals: daily_marginals, fill_colors: fill_colors, border_colors: border_colors
|
|
126
|
+
)
|
|
150
127
|
|
|
151
128
|
# Correction windows for the afterDraw hatch plugin, with dataset index in
|
|
152
129
|
# Chart.js dataset array (reversed: done column = index 0).
|
|
@@ -160,19 +137,55 @@ class CumulativeFlowDiagram < ChartBase
|
|
|
160
137
|
}
|
|
161
138
|
end
|
|
162
139
|
|
|
163
|
-
@triangle_color = parse_theme_color(['#333333', '#ffffff']) unless instance_variable_defined?(:@triangle_color)
|
|
164
|
-
unless instance_variable_defined?(:@arrival_rate_line_color)
|
|
165
|
-
@arrival_rate_line_color = 'rgba(255,138,101,0.85)'
|
|
166
|
-
end
|
|
167
|
-
unless instance_variable_defined?(:@departure_rate_line_color)
|
|
168
|
-
@departure_rate_line_color = 'rgba(128,203,196,0.85)'
|
|
169
|
-
end
|
|
170
|
-
|
|
171
140
|
wrap_and_render(binding, __FILE__)
|
|
172
141
|
end
|
|
173
142
|
|
|
174
143
|
private
|
|
175
144
|
|
|
145
|
+
# Build each column's rules (through the config block, if one was given) and drop the ignored
|
|
146
|
+
# columns, returning the surviving columns and their rules as parallel arrays.
|
|
147
|
+
def active_columns_and_rules all_columns
|
|
148
|
+
column_rules_list = all_columns.map do |column|
|
|
149
|
+
rules = CfdColumnRules.new
|
|
150
|
+
@column_rules_block&.call(column, rules)
|
|
151
|
+
rules
|
|
152
|
+
end
|
|
153
|
+
active_pairs = all_columns.zip(column_rules_list).reject { |_, rules| rules.ignored? }
|
|
154
|
+
[active_pairs.map(&:first), active_pairs.map(&:last)]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Convert per-date cumulative totals to marginal band heights for Chart.js stacking.
|
|
158
|
+
# cumulative[i] = issues that reached column i or further; marginal[i] = cumulative[i] -
|
|
159
|
+
# cumulative[i + 1] (the last column keeps its cumulative total).
|
|
160
|
+
def marginal_band_heights daily_counts, column_count
|
|
161
|
+
daily_counts.transform_values do |cumulative|
|
|
162
|
+
cumulative.each_with_index.map do |count, i|
|
|
163
|
+
i < column_count - 1 ? count - cumulative[i + 1] : count
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# One Chart.js dataset per active column, in reversed order: rightmost column first (bottom of
|
|
169
|
+
# the stack), leftmost last (top).
|
|
170
|
+
def build_data_sets columns:, correction_windows:, active_rules:, daily_marginals:, fill_colors:, border_colors:
|
|
171
|
+
columns.each_with_index.map do |name, col_index|
|
|
172
|
+
col_windows = correction_windows
|
|
173
|
+
.select { |w| w[:column_index] == col_index }
|
|
174
|
+
.map { |w| { start_date: w[:start_date].to_s, end_date: w[:end_date].to_s } }
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
label: active_rules[col_index].label || name,
|
|
178
|
+
label_hint: active_rules[col_index].label_hint,
|
|
179
|
+
data: date_range.map { |date| { x: date.to_s, y: daily_marginals[date][col_index] } },
|
|
180
|
+
backgroundColor: fill_colors[col_index],
|
|
181
|
+
borderColor: border_colors[col_index],
|
|
182
|
+
fill: true,
|
|
183
|
+
tension: 0,
|
|
184
|
+
segment: Segment.new(col_windows)
|
|
185
|
+
}
|
|
186
|
+
end.reverse
|
|
187
|
+
end
|
|
188
|
+
|
|
176
189
|
def parse_theme_color color
|
|
177
190
|
return color unless color.is_a?(Array)
|
|
178
191
|
|
|
@@ -39,63 +39,52 @@ class CycleTimeConfig
|
|
|
39
39
|
started_stopped_times(issue).last
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def started_time issue
|
|
43
|
-
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
44
|
-
started_stopped_times(issue).first
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def stopped_time issue
|
|
48
|
-
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
49
|
-
started_stopped_times(issue).last
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def fabricate_change_item time
|
|
53
|
-
@file_system.deprecated(
|
|
54
|
-
date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
|
|
55
|
-
)
|
|
56
|
-
raw = {
|
|
57
|
-
'field' => 'Fabricated change',
|
|
58
|
-
'to' => '0',
|
|
59
|
-
'toString' => '',
|
|
60
|
-
'from' => '0',
|
|
61
|
-
'fromString' => ''
|
|
62
|
-
}
|
|
63
|
-
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
64
|
-
end
|
|
65
|
-
|
|
66
42
|
def started_stopped_changes issue
|
|
67
43
|
cache_key = "#{issue.key}:#{issue.board.id}"
|
|
68
44
|
last_result = (@cache ||= {})[cache_key]
|
|
69
45
|
return *last_result if last_result && settings['cache_cycletime_calculations']
|
|
70
46
|
|
|
71
|
-
started = @start_at
|
|
72
|
-
stopped = @stop_at
|
|
73
|
-
|
|
74
|
-
# Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
|
|
75
|
-
# If they are false then explicitly make them nil.
|
|
76
|
-
started ||= nil
|
|
77
|
-
stopped ||= nil
|
|
78
|
-
|
|
79
|
-
# These are only here for backwards compatibility. Hopefully nobody will ever need them.
|
|
80
|
-
started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
|
|
81
|
-
stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
|
|
82
|
-
|
|
83
|
-
# In the case where started and stopped are exactly the same time, we pretend that
|
|
84
|
-
# it just stopped and never started. This allows us to have logic like 'in or right of'
|
|
85
|
-
# for the start and not have it conflict.
|
|
86
|
-
started = nil if started&.time == stopped&.time
|
|
47
|
+
started = resolve_change(@start_at, issue)
|
|
48
|
+
stopped = resolve_change(@stop_at, issue)
|
|
49
|
+
started = collapse_zero_length_start(started, stopped)
|
|
87
50
|
|
|
88
51
|
result = [started, stopped]
|
|
89
|
-
|
|
90
|
-
@file_system.error(
|
|
91
|
-
"Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
|
|
92
|
-
"previous=#{last_result.inspect}"
|
|
93
|
-
)
|
|
94
|
-
end
|
|
52
|
+
warn_on_cache_mismatch(issue: issue, result: result, last_result: last_result)
|
|
95
53
|
@cache[cache_key] = result
|
|
96
54
|
result
|
|
97
55
|
end
|
|
98
56
|
|
|
57
|
+
# start_at/stop_at blocks must return a ChangeItem or nil. A legacy false (meaning "not found") is
|
|
58
|
+
# still tolerated as nil, but a bare Time -- once accepted and wrapped -- is no longer supported.
|
|
59
|
+
def resolve_change block, issue
|
|
60
|
+
change = block.call(issue)
|
|
61
|
+
return nil unless change
|
|
62
|
+
|
|
63
|
+
unless change.is_a?(ChangeItem)
|
|
64
|
+
raise "A start_at/stop_at block must return a ChangeItem or nil but returned a #{change.class}. " \
|
|
65
|
+
'If you were relying on a bare time such as `created`, use the ChangeItem form (e.g. `time_created`).'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
change
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# When start and stop land on the same instant, treat the issue as stopped-but-never-started so a
|
|
72
|
+
# zero-length span doesn't conflict with 'in or right of' start logic.
|
|
73
|
+
def collapse_zero_length_start started, stopped
|
|
74
|
+
return nil if started&.time == stopped&.time
|
|
75
|
+
|
|
76
|
+
started
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def warn_on_cache_mismatch issue:, result:, last_result:
|
|
80
|
+
return unless last_result && result != last_result
|
|
81
|
+
|
|
82
|
+
@file_system.error(
|
|
83
|
+
"Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
|
|
84
|
+
"previous=#{last_result.inspect}"
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
99
88
|
def started_stopped_times issue
|
|
100
89
|
started, stopped = started_stopped_changes(issue)
|
|
101
90
|
[started&.time, stopped&.time]
|
|
@@ -28,6 +28,13 @@ class CycletimeHistogram < TimeBasedHistogram
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Days-only until the working-days cycletime engine grows sub-day resolution. Inherited from
|
|
32
|
+
# TimeBasedChart so the option exists, but the unbuilt path fails loudly rather than mislabelling
|
|
33
|
+
# the axis while the value stays in days. See jirametrics-en5.
|
|
34
|
+
def cycletime_unit unit
|
|
35
|
+
raise NotImplementedError, "#{self.class} only supports :days for now (see jirametrics-en5)" unless unit == :days
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
def all_items
|
|
32
39
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
33
40
|
|
|
@@ -35,6 +35,13 @@ class CycletimeScatterplot < TimeBasedScatterplot
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Days-only until the working-days cycletime engine grows sub-day resolution. Inherited from
|
|
39
|
+
# TimeBasedChart so the option exists, but the unbuilt path fails loudly rather than mislabelling
|
|
40
|
+
# the axis while the value stays in days. See jirametrics-en5.
|
|
41
|
+
def cycletime_unit unit
|
|
42
|
+
raise NotImplementedError, "#{self.class} only supports :days for now (see jirametrics-en5)" unless unit == :days
|
|
43
|
+
end
|
|
44
|
+
|
|
38
45
|
def minimum_y_value
|
|
39
46
|
1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
|
|
40
47
|
end
|
|
@@ -24,7 +24,9 @@ class DailyView < ChartBase
|
|
|
24
24
|
def run
|
|
25
25
|
aging_issues = select_aging_issues
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
if aging_issues.empty?
|
|
28
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>"
|
|
29
|
+
end
|
|
28
30
|
|
|
29
31
|
result = +''
|
|
30
32
|
result << render_top_text(binding)
|
|
@@ -35,10 +37,7 @@ class DailyView < ChartBase
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def select_aging_issues
|
|
38
|
-
aging_issues = issues.select
|
|
39
|
-
started_at, stopped_at = issue.started_stopped_times
|
|
40
|
-
started_at && !stopped_at
|
|
41
|
-
end
|
|
40
|
+
aging_issues = issues.select { |issue| issue.board.cycletime.in_progress?(issue) }
|
|
42
41
|
|
|
43
42
|
today = date_range.end
|
|
44
43
|
aging_issues.collect do |issue|
|
|
@@ -81,30 +80,40 @@ class DailyView < ChartBase
|
|
|
81
80
|
)[today]
|
|
82
81
|
return [] if blocked_stalled.active?
|
|
83
82
|
|
|
84
|
-
lines = []
|
|
85
83
|
if blocked_stalled.blocked?
|
|
86
|
-
|
|
87
|
-
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
88
|
-
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
89
|
-
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
90
|
-
blocking_issue = issues.find_by_key key: key, include_hidden: true
|
|
91
|
-
if blocking_issue
|
|
92
|
-
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
|
|
93
|
-
"#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
|
|
94
|
-
lines << blocking_issue
|
|
95
|
-
lines << '</section>'
|
|
96
|
-
else
|
|
97
|
-
lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
|
|
98
|
-
end
|
|
99
|
-
end
|
|
84
|
+
blocked_lines blocked_stalled
|
|
100
85
|
elsif blocked_stalled.stalled_by_status?
|
|
101
|
-
|
|
86
|
+
[["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]]
|
|
102
87
|
else
|
|
103
|
-
|
|
88
|
+
[["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def blocked_lines blocked_stalled
|
|
93
|
+
marker = color_block '--blocked-color'
|
|
94
|
+
lines = []
|
|
95
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
96
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
97
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
98
|
+
lines.concat blocking_issue_lines(key, marker)
|
|
104
99
|
end
|
|
105
100
|
lines
|
|
106
101
|
end
|
|
107
102
|
|
|
103
|
+
# The lines for one blocking issue: a foldable section embedding the issue when we have it, or a plain
|
|
104
|
+
# "no description found" line when we only know its key.
|
|
105
|
+
def blocking_issue_lines key, marker
|
|
106
|
+
blocking_issue = issues.find_by_key key: key, include_hidden: true
|
|
107
|
+
return [["#{marker} Blocked by issue: #{key} (no description found)"]] unless blocking_issue
|
|
108
|
+
|
|
109
|
+
[
|
|
110
|
+
"<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
|
|
111
|
+
"#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>",
|
|
112
|
+
blocking_issue,
|
|
113
|
+
'</section>'
|
|
114
|
+
]
|
|
115
|
+
end
|
|
116
|
+
|
|
108
117
|
def make_issue_label issue:, done:
|
|
109
118
|
label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
|
|
110
119
|
label << '<s>' if done
|
|
@@ -133,17 +142,8 @@ class DailyView < ChartBase
|
|
|
133
142
|
|
|
134
143
|
def make_stats_lines issue:, done:
|
|
135
144
|
line = []
|
|
136
|
-
|
|
137
145
|
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
138
|
-
|
|
139
|
-
if done
|
|
140
|
-
cycletime = issue.board.cycletime.cycletime(issue)
|
|
141
|
-
|
|
142
|
-
line << "Cycletime: <b>#{label_days cycletime}</b>"
|
|
143
|
-
else
|
|
144
|
-
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
145
|
-
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
146
|
-
end
|
|
146
|
+
line << progress_line(issue, done)
|
|
147
147
|
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
148
148
|
|
|
149
149
|
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
@@ -153,29 +153,47 @@ class DailyView < ChartBase
|
|
|
153
153
|
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
156
|
+
due = due_date_line(issue)
|
|
157
|
+
line << due if due
|
|
158
|
+
line.concat label_lines(issue)
|
|
159
|
+
|
|
160
|
+
[line]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# 'Cycletime: ...' for finished issues, otherwise 'Age: ...' (or '(Not Started)' when it hasn't started).
|
|
164
|
+
def progress_line issue, done
|
|
165
|
+
if done
|
|
166
|
+
"Cycletime: <b>#{label_days issue.board.cycletime.cycletime(issue)}</b>"
|
|
167
|
+
else
|
|
168
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
169
|
+
"Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
167
170
|
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# 'Due: ...' with a relative 'today/in N days/N days ago' hint, highlighted when overdue. nil when
|
|
174
|
+
# the issue has no due date.
|
|
175
|
+
def due_date_line issue
|
|
176
|
+
return nil unless issue.due_date
|
|
168
177
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
days = (issue.due_date - date_range.end).to_i
|
|
179
|
+
relative =
|
|
180
|
+
if days.zero? then 'today'
|
|
181
|
+
elsif days.positive? then "in #{label_days days}"
|
|
182
|
+
else "#{label_days(-days)} ago"
|
|
173
183
|
end
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
content = "#{issue.due_date} (#{relative})"
|
|
185
|
+
content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
|
|
186
|
+
"Due: <b>#{content}</b>"
|
|
187
|
+
end
|
|
177
188
|
|
|
178
|
-
|
|
189
|
+
# One 'Labels: ...' and/or 'Components: ...' line, each listing its non-empty collection as label spans.
|
|
190
|
+
def label_lines issue
|
|
191
|
+
{ 'Labels:' => issue.labels, 'Components:' => issue.component_names }.filter_map do |label, collection|
|
|
192
|
+
next if collection.empty?
|
|
193
|
+
|
|
194
|
+
spans = collection.collect { |item| "<span class='label'>#{item}</span>" }.join(' ')
|
|
195
|
+
"#{label} #{spans}"
|
|
196
|
+
end
|
|
179
197
|
end
|
|
180
198
|
|
|
181
199
|
def make_child_lines issue
|
|
@@ -215,19 +233,10 @@ class DailyView < ChartBase
|
|
|
215
233
|
end
|
|
216
234
|
|
|
217
235
|
def history_text change:, board:
|
|
218
|
-
convertor = ->(value, _id) { value.inspect }
|
|
219
|
-
convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
|
|
220
|
-
|
|
221
236
|
if change.comment? || change.description?
|
|
222
237
|
atlassian_document_format.to_html(change.value)
|
|
223
238
|
elsif %w[status priority assignee duedate issuetype].include?(change.field)
|
|
224
|
-
|
|
225
|
-
if change.old_value
|
|
226
|
-
from = convertor.call(change.old_value, change.old_value_id)
|
|
227
|
-
"Changed from #{from} to #{to}"
|
|
228
|
-
else
|
|
229
|
-
"Set to #{to}"
|
|
230
|
-
end
|
|
239
|
+
field_change_text change, board
|
|
231
240
|
elsif change.flagged?
|
|
232
241
|
change.value == '' ? 'Off' : 'On'
|
|
233
242
|
else
|
|
@@ -235,6 +244,19 @@ class DailyView < ChartBase
|
|
|
235
244
|
end
|
|
236
245
|
end
|
|
237
246
|
|
|
247
|
+
# 'Set to X' or 'Changed from Y to X' for a status/priority/assignee/duedate/issuetype change. Status
|
|
248
|
+
# values are rendered as coloured status labels; everything else is shown inspected.
|
|
249
|
+
def field_change_text change, board
|
|
250
|
+
convertor = ->(value, _id) { value.inspect }
|
|
251
|
+
convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
|
|
252
|
+
|
|
253
|
+
to = convertor.call(change.value, change.value_id)
|
|
254
|
+
return "Set to #{to}" unless change.old_value
|
|
255
|
+
|
|
256
|
+
from = convertor.call(change.old_value, change.old_value_id)
|
|
257
|
+
"Changed from #{from} to #{to}"
|
|
258
|
+
end
|
|
259
|
+
|
|
238
260
|
def make_sprints_lines issue
|
|
239
261
|
return [] unless issue.board.scrum?
|
|
240
262
|
|