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
|
@@ -40,20 +40,28 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
42
|
started, stopped = issue.started_stopped_times
|
|
43
|
-
stopped_date = stopped&.to_date
|
|
44
43
|
started_date = started&.to_date
|
|
44
|
+
stopped_date = stopped&.to_date
|
|
45
45
|
|
|
46
46
|
date = rules.current_date
|
|
47
47
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|
|
48
|
-
stopped_today = stopped_date == rules.current_date
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
days
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
assign_group_rule(
|
|
50
|
+
rules, started: started, stopped_today: stopped_date == date, change: change,
|
|
51
|
+
days: days_between(started_date, stopped_date)
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The issue's cycle time when it has finished, its current age when it's still going, or nil when we
|
|
56
|
+
# don't even know when it started.
|
|
57
|
+
def days_between started_date, stopped_date
|
|
58
|
+
return (stopped_date - started_date).to_i + 1 if started_date && stopped_date
|
|
59
|
+
return (time_range.end.to_date - started_date).to_i + 1 if started_date
|
|
56
60
|
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def assign_group_rule rules, started:, stopped_today:, change:, days:
|
|
57
65
|
if stopped_today && started.nil?
|
|
58
66
|
@has_completed_but_not_started = true
|
|
59
67
|
rules.label = 'Completed but not started'
|
|
@@ -69,7 +77,14 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
69
77
|
rules.label = 'Start date unknown'
|
|
70
78
|
rules.color = '--body-background'
|
|
71
79
|
rules.group_priority = 4
|
|
72
|
-
|
|
80
|
+
else
|
|
81
|
+
assign_in_progress_group_rule(rules, change: change, days: days)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A started, not-yet-completed issue is grouped by its blocked/stalled state on the day.
|
|
86
|
+
def assign_in_progress_group_rule rules, change:, days:
|
|
87
|
+
if change&.blocked?
|
|
73
88
|
rules.label = 'Blocked'
|
|
74
89
|
rules.color = '--blocked-color'
|
|
75
90
|
rules.group_priority = 1
|
|
@@ -39,35 +39,47 @@ class DailyWipChart < ChartBase
|
|
|
39
39
|
issue_rules_by_active_date = group_issues_by_active_dates
|
|
40
40
|
possible_rules = select_possible_rules issue_rules_by_active_date
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
data_sets = build_data_sets(possible_rules, issue_rules_by_active_date)
|
|
43
|
+
data_sets = prepend_trend_lines(data_sets) if @trend_lines
|
|
44
|
+
|
|
45
|
+
wrap_and_render(binding, __FILE__)
|
|
46
|
+
end
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
def build_data_sets possible_rules, issue_rules_by_active_date
|
|
49
|
+
labels = conflicting_labels(possible_rules)
|
|
50
|
+
possible_rules.collect do |grouping_rule|
|
|
51
|
+
suffix = labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
|
|
49
52
|
make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
|
|
50
53
|
label_suffix: suffix
|
|
51
54
|
end
|
|
52
|
-
|
|
53
|
-
data_sets = @trend_lines.filter_map do |group_labels, line_color|
|
|
54
|
-
trend_line_data_set(data: data_sets, group_labels: group_labels, color: line_color)
|
|
55
|
-
end + data_sets
|
|
56
|
-
end
|
|
55
|
+
end
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
# Labels that appear both highlighted and un-highlighted; the highlighted variant gets a '*' suffix so
|
|
58
|
+
# the two are distinguishable in the legend.
|
|
59
|
+
def conflicting_labels possible_rules
|
|
60
|
+
possible_rules
|
|
61
|
+
.group_by(&:label)
|
|
62
|
+
.select { |_label, rules| rules.any?(&:highlight) && rules.any? { |rule| !rule.highlight } }
|
|
63
|
+
.keys
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def prepend_trend_lines data_sets
|
|
67
|
+
@trend_lines.filter_map do |group_labels, line_color|
|
|
68
|
+
trend_line_data_set(data: data_sets, group_labels: group_labels, color: line_color)
|
|
69
|
+
end + data_sets
|
|
59
70
|
end
|
|
60
71
|
|
|
61
72
|
def default_header_text = 'Daily WIP'
|
|
62
73
|
def default_description_text = ''
|
|
63
74
|
|
|
64
|
-
def default_grouping_rules issue:, rules:
|
|
75
|
+
def default_grouping_rules issue:, rules:
|
|
65
76
|
raise 'If you use this class directly then you must provide grouping_rules'
|
|
66
77
|
end
|
|
67
78
|
|
|
68
79
|
def select_possible_rules issue_rules_by_active_date
|
|
69
80
|
possible_rules = []
|
|
70
81
|
issue_rules_by_active_date.each_pair do |_date, issues_rules_list|
|
|
82
|
+
# issues_rules_list is an array of [issue, rule] pairs, not a hash, so this isn't each_value.
|
|
71
83
|
issues_rules_list.each do |_issue, rules| # rubocop:disable Style/HashEachMethods
|
|
72
84
|
possible_rules << rules unless possible_rules.any? { |r| r.group == rules.group }
|
|
73
85
|
end
|
|
@@ -77,17 +89,8 @@ class DailyWipChart < ChartBase
|
|
|
77
89
|
|
|
78
90
|
def group_issues_by_active_dates
|
|
79
91
|
hash = {}
|
|
80
|
-
|
|
81
92
|
@issues.each do |issue|
|
|
82
|
-
|
|
83
|
-
next if start.nil? && stop.nil?
|
|
84
|
-
|
|
85
|
-
# If it stopped but never started then assume it started at creation so the data points
|
|
86
|
-
# will be available for the config.
|
|
87
|
-
start = issue.created.to_date if stop && start.nil?
|
|
88
|
-
start = @date_range.begin if start < @date_range.begin
|
|
89
|
-
|
|
90
|
-
start.upto(stop || @date_range.end) do |date|
|
|
93
|
+
active_dates_for(issue)&.each do |date|
|
|
91
94
|
rule = configure_rule issue: issue, date: date
|
|
92
95
|
(hash[date] ||= []) << [issue, rule] unless rule.ignored?
|
|
93
96
|
end
|
|
@@ -95,44 +98,64 @@ class DailyWipChart < ChartBase
|
|
|
95
98
|
hash
|
|
96
99
|
end
|
|
97
100
|
|
|
101
|
+
# The range of dates an issue is "active" on the chart: from when it started - or its creation, if it
|
|
102
|
+
# stopped without a recorded start - through when it stopped, or the end of the range if still open.
|
|
103
|
+
# Returns nil when the issue neither started nor stopped.
|
|
104
|
+
def active_dates_for issue
|
|
105
|
+
start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
|
|
106
|
+
return nil if start.nil? && stop.nil?
|
|
107
|
+
|
|
108
|
+
# Past the guard, a nil start means it only stopped, so treat creation as the start.
|
|
109
|
+
start = issue.created.to_date if start.nil?
|
|
110
|
+
start = @date_range.begin if start < @date_range.begin
|
|
111
|
+
start..(stop || @date_range.end)
|
|
112
|
+
end
|
|
113
|
+
|
|
98
114
|
def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
|
|
99
115
|
positive = grouping_rule.group_priority >= 0
|
|
100
116
|
display_label = "#{grouping_rule.label}#{label_suffix}"
|
|
101
117
|
|
|
102
118
|
data = issue_rules_by_active_date.collect do |date, issue_rules|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
.sort_by { |issue, _rules| issue.key_as_i }
|
|
106
|
-
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
107
|
-
title_label = grouping_rule.label_hint || display_label
|
|
108
|
-
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
x: date,
|
|
112
|
-
y: positive ? issue_strings.size : -issue_strings.size,
|
|
113
|
-
title: title
|
|
114
|
-
}
|
|
119
|
+
datapoint_for date: date, issue_rules: issue_rules, grouping_rule: grouping_rule,
|
|
120
|
+
display_label: display_label, positive: positive
|
|
115
121
|
end
|
|
116
122
|
|
|
117
|
-
color = grouping_rule.color || random_color
|
|
118
|
-
background_color = if grouping_rule.highlight
|
|
119
|
-
RawJavascript.new("createDiagonalPattern(#{color.to_json})")
|
|
120
|
-
else
|
|
121
|
-
color
|
|
122
|
-
end
|
|
123
|
-
|
|
124
123
|
{
|
|
125
124
|
type: 'bar',
|
|
126
125
|
label: display_label,
|
|
127
126
|
label_hint: grouping_rule.label_hint,
|
|
128
127
|
data: data,
|
|
129
|
-
backgroundColor:
|
|
128
|
+
backgroundColor: background_color_for(grouping_rule),
|
|
130
129
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
131
130
|
borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
|
|
132
131
|
borderRadius: positive ? 0 : 5
|
|
133
132
|
}
|
|
134
133
|
end
|
|
135
134
|
|
|
135
|
+
# One {x, y, title} point: the issues active on this date that belong to this rule's group, with a
|
|
136
|
+
# title listing them. y is negated for negative-priority groups so they render below the axis.
|
|
137
|
+
def datapoint_for date:, issue_rules:, grouping_rule:, display_label:, positive:
|
|
138
|
+
issue_strings = issue_strings_for issue_rules, grouping_rule
|
|
139
|
+
title_label = grouping_rule.label_hint || display_label
|
|
140
|
+
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
141
|
+
|
|
142
|
+
{ x: date, y: positive ? issue_strings.size : -issue_strings.size, title: title }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def issue_strings_for issue_rules, grouping_rule
|
|
146
|
+
issue_rules
|
|
147
|
+
.select { |_issue, rules| rules.group == grouping_rule.group }
|
|
148
|
+
.sort_by { |issue, _rules| issue.key_as_i }
|
|
149
|
+
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def background_color_for grouping_rule
|
|
153
|
+
color = grouping_rule.color || random_color
|
|
154
|
+
return color unless grouping_rule.highlight
|
|
155
|
+
|
|
156
|
+
RawJavascript.new("createDiagonalPattern(#{color.to_json})")
|
|
157
|
+
end
|
|
158
|
+
|
|
136
159
|
def configure_rule issue:, date:
|
|
137
160
|
raise "#{self.class}: grouping_rules must be set" if @group_by_block.nil?
|
|
138
161
|
|
|
@@ -150,7 +173,8 @@ class DailyWipChart < ChartBase
|
|
|
150
173
|
(@trend_lines ||= []) << [group_labels, line_color]
|
|
151
174
|
end
|
|
152
175
|
|
|
153
|
-
|
|
176
|
+
# Sums the daily WIP across every dataset whose label is in group_labels, giving { date => total_wip }.
|
|
177
|
+
def daily_wip_totals data, group_labels
|
|
154
178
|
day_wip_hash = {}
|
|
155
179
|
data.each do |top_level|
|
|
156
180
|
next unless group_labels.include? top_level[:label]
|
|
@@ -160,8 +184,11 @@ class DailyWipChart < ChartBase
|
|
|
160
184
|
day_wip_hash[date] = (day_wip_hash[date] || 0) + datapoint[:y]
|
|
161
185
|
end
|
|
162
186
|
end
|
|
187
|
+
day_wip_hash
|
|
188
|
+
end
|
|
163
189
|
|
|
164
|
-
|
|
190
|
+
def trend_line_data_set data:, group_labels:, color:
|
|
191
|
+
points = daily_wip_totals(data, group_labels)
|
|
165
192
|
.collect { |date, wip| [date.jd, wip] }
|
|
166
193
|
.sort_by(&:first)
|
|
167
194
|
|
|
@@ -128,15 +128,7 @@ class DataQualityReport < ChartBase
|
|
|
128
128
|
Entry.new started: started, stopped: stopped, issue: issue
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
@entries.
|
|
132
|
-
a.issue.key =~ /.+-(\d+)$/
|
|
133
|
-
a_id = $1.to_i
|
|
134
|
-
|
|
135
|
-
b.issue.key =~ /.+-(\d+)$/
|
|
136
|
-
b_id = $1.to_i
|
|
137
|
-
|
|
138
|
-
a_id <=> b_id
|
|
139
|
-
end
|
|
131
|
+
@entries.sort_by! { |entry| entry.issue.key_as_i }
|
|
140
132
|
end
|
|
141
133
|
|
|
142
134
|
def scan_for_completed_issues_without_a_start_time entry:
|
|
@@ -174,56 +166,66 @@ class DataQualityReport < ChartBase
|
|
|
174
166
|
end
|
|
175
167
|
|
|
176
168
|
def scan_for_backwards_movement entry:, backlog_statuses:
|
|
177
|
-
issue = entry.issue
|
|
178
|
-
|
|
179
169
|
# Moving backwards through statuses is bad. Moving backwards through status categories is almost always worse.
|
|
180
170
|
last_index = -1
|
|
181
|
-
issue.changes.each do |change|
|
|
171
|
+
entry.issue.changes.each do |change|
|
|
182
172
|
next unless change.status?
|
|
183
173
|
|
|
184
174
|
board = entry.issue.board
|
|
185
|
-
index =
|
|
175
|
+
index = column_index_for(board, change)
|
|
186
176
|
if index.nil?
|
|
187
|
-
|
|
188
|
-
next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
|
|
189
|
-
|
|
190
|
-
detail = "Status #{format_status change, board: board} is not on the board"
|
|
191
|
-
if issue.board.possible_statuses.find_by_id(change.value_id).nil?
|
|
192
|
-
detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
196
|
-
detail = nil if backlog_statuses.any? { |s| s.name == change.value }
|
|
197
|
-
|
|
198
|
-
entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
|
|
177
|
+
report_status_not_on_board(entry: entry, change: change, board: board, backlog_statuses: backlog_statuses)
|
|
199
178
|
elsif change.old_value.nil?
|
|
200
|
-
#
|
|
179
|
+
# A move onto the board with no prior status has nothing to compare against.
|
|
201
180
|
elsif index < last_index
|
|
202
|
-
|
|
203
|
-
old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
|
|
204
|
-
|
|
205
|
-
if new_category == old_category
|
|
206
|
-
entry.report(
|
|
207
|
-
problem_key: :backwords_through_statuses,
|
|
208
|
-
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
209
|
-
" to #{format_status change, board: board}" \
|
|
210
|
-
" on #{change.time.to_date}"
|
|
211
|
-
)
|
|
212
|
-
else
|
|
213
|
-
entry.report(
|
|
214
|
-
problem_key: :backwards_through_status_categories,
|
|
215
|
-
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
216
|
-
" to #{format_status change, board: board}" \
|
|
217
|
-
" on #{change.time.to_date}," \
|
|
218
|
-
" crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
|
|
219
|
-
" to #{format_status change, board: board, is_category: true}."
|
|
220
|
-
)
|
|
221
|
-
end
|
|
181
|
+
report_backwards_movement(entry: entry, change: change, board: board)
|
|
222
182
|
end
|
|
223
183
|
last_index = index || -1
|
|
224
184
|
end
|
|
225
185
|
end
|
|
226
186
|
|
|
187
|
+
def column_index_for board, change
|
|
188
|
+
board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def report_status_not_on_board entry:, change:, board:, backlog_statuses:
|
|
192
|
+
# If it's a backlog status then ignore it. Not supposed to be visible.
|
|
193
|
+
return if board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
|
|
194
|
+
|
|
195
|
+
detail = "Status #{format_status change, board: board} is not on the board"
|
|
196
|
+
if board.possible_statuses.find_by_id(change.value_id).nil?
|
|
197
|
+
detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
201
|
+
detail = nil if backlog_statuses.any? { |s| s.name == change.value }
|
|
202
|
+
|
|
203
|
+
entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def report_backwards_movement entry:, change:, board:
|
|
207
|
+
new_category = board.possible_statuses.find_by_id(change.value_id).category.name
|
|
208
|
+
old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
|
|
209
|
+
|
|
210
|
+
if new_category == old_category
|
|
211
|
+
entry.report(
|
|
212
|
+
problem_key: :backwords_through_statuses,
|
|
213
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
214
|
+
" to #{format_status change, board: board}" \
|
|
215
|
+
" on #{change.time.to_date}"
|
|
216
|
+
)
|
|
217
|
+
else
|
|
218
|
+
entry.report(
|
|
219
|
+
problem_key: :backwards_through_status_categories,
|
|
220
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
221
|
+
" to #{format_status change, board: board}" \
|
|
222
|
+
" on #{change.time.to_date}," \
|
|
223
|
+
" crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
|
|
224
|
+
" to #{format_status change, board: board, is_category: true}."
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
227
229
|
def scan_for_issue_not_in_active_sprint entry:
|
|
228
230
|
issue = entry.issue
|
|
229
231
|
return unless issue.board.scrum?
|
|
@@ -248,7 +250,7 @@ class DataQualityReport < ChartBase
|
|
|
248
250
|
end
|
|
249
251
|
|
|
250
252
|
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
251
|
-
creation_change = entry.issue.changes.find
|
|
253
|
+
creation_change = entry.issue.changes.find(&:status?)
|
|
252
254
|
|
|
253
255
|
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
|
254
256
|
|
|
@@ -325,18 +327,7 @@ class DataQualityReport < ChartBase
|
|
|
325
327
|
def scan_for_incomplete_subtasks_when_issue_done entry:
|
|
326
328
|
return unless entry.stopped
|
|
327
329
|
|
|
328
|
-
subtask_labels = entry.issue.subtasks.filter_map
|
|
329
|
-
subtask_started, subtask_stopped = subtask.started_stopped_times
|
|
330
|
-
|
|
331
|
-
if !subtask_started && !subtask_stopped
|
|
332
|
-
"#{subtask_label subtask} (Not even started)"
|
|
333
|
-
elsif !subtask_stopped
|
|
334
|
-
"#{subtask_label subtask} (Still not done)"
|
|
335
|
-
elsif subtask_stopped > entry.stopped
|
|
336
|
-
"#{subtask_label subtask} (Closed #{time_as_english entry.stopped, subtask_stopped} later)"
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
|
|
330
|
+
subtask_labels = entry.issue.subtasks.filter_map { |subtask| incomplete_subtask_label subtask, entry.stopped }
|
|
340
331
|
return if subtask_labels.empty?
|
|
341
332
|
|
|
342
333
|
entry.report(
|
|
@@ -345,10 +336,17 @@ class DataQualityReport < ChartBase
|
|
|
345
336
|
)
|
|
346
337
|
end
|
|
347
338
|
|
|
348
|
-
|
|
349
|
-
|
|
339
|
+
# The label for one subtask that isn't finished when its parent is done, or nil if it is fine.
|
|
340
|
+
def incomplete_subtask_label subtask, issue_stopped
|
|
341
|
+
subtask_started, subtask_stopped = subtask.started_stopped_times
|
|
350
342
|
|
|
351
|
-
|
|
343
|
+
if !subtask_started && !subtask_stopped
|
|
344
|
+
"#{subtask_label subtask} (Not even started)"
|
|
345
|
+
elsif !subtask_stopped
|
|
346
|
+
"#{subtask_label subtask} (Still not done)"
|
|
347
|
+
elsif subtask_stopped > issue_stopped
|
|
348
|
+
"#{subtask_label subtask} (Closed #{time_as_english issue_stopped, subtask_stopped} later)"
|
|
349
|
+
end
|
|
352
350
|
end
|
|
353
351
|
|
|
354
352
|
def scan_for_discarded_data entry:
|
|
@@ -470,7 +468,7 @@ class DataQualityReport < ChartBase
|
|
|
470
468
|
|
|
471
469
|
def render_incomplete_subtasks_when_issue_done problems
|
|
472
470
|
<<-HTML
|
|
473
|
-
#{label_issues problems.size}
|
|
471
|
+
#{label_issues problems.size} were marked as done while subtasks were still not done.
|
|
474
472
|
HTML
|
|
475
473
|
end
|
|
476
474
|
|
|
@@ -14,6 +14,7 @@ class DependencyChart < ChartBase
|
|
|
14
14
|
@merge_bidirectional = keep.to_sym
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# get_ prefix because the plain name is the config DSL setter (merge_bidirectional keep:).
|
|
17
18
|
def get_merge_bidirectional # rubocop:disable Naming/AccessorMethodName
|
|
18
19
|
@merge_bidirectional
|
|
19
20
|
end
|
|
@@ -135,34 +136,46 @@ class DependencyChart < ChartBase
|
|
|
135
136
|
@link_rules_block.call link, link_rules
|
|
136
137
|
|
|
137
138
|
next if link_rules.ignored?
|
|
138
|
-
|
|
139
|
-
if link_rules.get_merge_bidirectional
|
|
140
|
-
opposite = issue_links.find do |l|
|
|
141
|
-
l.name == link.name && l.origin.key == link.other_issue.key && l.other_issue.key == link.origin.key
|
|
142
|
-
end
|
|
143
|
-
if opposite
|
|
144
|
-
# rubocop:disable Style/GuardClause
|
|
145
|
-
if link_rules.get_merge_bidirectional.to_sym == link.direction
|
|
146
|
-
# We keep this one and discard the opposite
|
|
147
|
-
links_to_ignore << opposite
|
|
148
|
-
else
|
|
149
|
-
# We keep the opposite and discard this one
|
|
150
|
-
next
|
|
151
|
-
end
|
|
152
|
-
# rubocop:enable Style/GuardClause
|
|
153
|
-
end
|
|
154
|
-
end
|
|
139
|
+
next if merge_bidirectional_skip?(link, link_rules, issue_links, links_to_ignore)
|
|
155
140
|
|
|
156
141
|
link_graph << make_dot_link(issue_link: link, link_rules: link_rules)
|
|
157
|
-
|
|
158
142
|
visible_issues[link.origin.key] = link.origin
|
|
159
143
|
visible_issues[link.other_issue.key] = link.other_issue
|
|
160
144
|
end
|
|
161
145
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
146
|
+
return nil if visible_issues.empty?
|
|
147
|
+
|
|
148
|
+
assemble_dot_graph(visible_issues, link_graph)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# For a bidirectional-merge link, collapses the pair into one. When this link is the one to keep, its
|
|
152
|
+
# opposite is added to links_to_ignore; returns true when this link should be skipped in favour of its
|
|
153
|
+
# opposite. Returns false (keep this link) when there's no merge or no matching opposite.
|
|
154
|
+
def merge_bidirectional_skip? link, link_rules, issue_links, links_to_ignore
|
|
155
|
+
merge_direction = link_rules.get_merge_bidirectional
|
|
156
|
+
return false unless merge_direction
|
|
157
|
+
|
|
158
|
+
opposite = find_opposite_link(link, issue_links)
|
|
159
|
+
return false unless opposite
|
|
160
|
+
|
|
161
|
+
if merge_direction.to_sym == link.direction
|
|
162
|
+
links_to_ignore << opposite # keep this one, discard the opposite
|
|
163
|
+
false
|
|
164
|
+
else
|
|
165
|
+
true # keep the opposite, skip this one
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def find_opposite_link link, issue_links
|
|
170
|
+
issue_links.find do |candidate|
|
|
171
|
+
candidate.name == link.name &&
|
|
172
|
+
candidate.origin.key == link.other_issue.key &&
|
|
173
|
+
candidate.other_issue.key == link.origin.key
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def assemble_dot_graph visible_issues, link_graph
|
|
178
|
+
dot_graph = ['digraph mygraph {', 'rankdir=LR', 'bgcolor="transparent"']
|
|
166
179
|
|
|
167
180
|
# Sort the keys so they are proccessed in a deterministic order.
|
|
168
181
|
visible_issues.values.sort_by(&:key_as_i).each do |issue|
|
|
@@ -171,12 +184,7 @@ class DependencyChart < ChartBase
|
|
|
171
184
|
dot_graph << make_dot_issue(issue: issue, issue_rules: rules)
|
|
172
185
|
end
|
|
173
186
|
|
|
174
|
-
dot_graph
|
|
175
|
-
dot_graph << '}'
|
|
176
|
-
|
|
177
|
-
return nil if visible_issues.empty?
|
|
178
|
-
|
|
179
|
-
dot_graph
|
|
187
|
+
dot_graph + link_graph + ['}']
|
|
180
188
|
end
|
|
181
189
|
|
|
182
190
|
def execute_graphviz dot_graph
|
|
@@ -193,9 +201,10 @@ class DependencyChart < ChartBase
|
|
|
193
201
|
|
|
194
202
|
def shrink_svg svg
|
|
195
203
|
scale = 0.8
|
|
196
|
-
svg.sub(/width="([\d.]+)pt" height="([\d.]+)pt"/) do
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
svg.sub(/width="(?<width_pt>[\d.]+)pt" height="(?<height_pt>[\d.]+)pt"/) do
|
|
205
|
+
match = Regexp.last_match
|
|
206
|
+
width = match[:width_pt].to_i * scale
|
|
207
|
+
height = match[:height_pt].to_i * scale
|
|
199
208
|
"width=\"#{width.to_i}pt\" height=\"#{height.to_i}pt\""
|
|
200
209
|
end
|
|
201
210
|
end
|
|
@@ -244,18 +244,19 @@ class Downloader
|
|
|
244
244
|
# Only use the saved metadata if the version number is the same one that we're currently using.
|
|
245
245
|
# If the cached data is in an older format then we're going to throw most of it away.
|
|
246
246
|
@cached_data_format_is_current = (hash['version'] || 0) == CURRENT_METADATA_VERSION
|
|
247
|
-
if @cached_data_format_is_current
|
|
248
|
-
hash.each do |key, value|
|
|
249
|
-
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
250
|
-
@metadata[key] = value
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
end
|
|
247
|
+
load_cached_metadata(hash) if @cached_data_format_is_current
|
|
254
248
|
|
|
255
249
|
# Even if this is the old format, we want to obey this one tag
|
|
256
250
|
@metadata['no-download'] = hash['no-download'] if hash['no-download']
|
|
257
251
|
end
|
|
258
252
|
|
|
253
|
+
def load_cached_metadata hash
|
|
254
|
+
hash.each do |key, value|
|
|
255
|
+
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
256
|
+
@metadata[key] = value
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
259
260
|
def timezone_offset
|
|
260
261
|
@download_config.project_config.exporter.timezone_offset
|
|
261
262
|
end
|