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
|
@@ -39,23 +39,32 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
|
-
started = issue.
|
|
43
|
-
stopped_date =
|
|
42
|
+
started, stopped = issue.started_stopped_times
|
|
43
|
+
stopped_date = stopped&.to_date
|
|
44
|
+
started_date = started&.to_date
|
|
44
45
|
|
|
45
46
|
date = rules.current_date
|
|
46
47
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|
|
47
|
-
|
|
48
48
|
stopped_today = stopped_date == rules.current_date
|
|
49
49
|
|
|
50
|
+
days = nil
|
|
51
|
+
if started_date && stopped_date
|
|
52
|
+
days = (stopped_date - started_date).to_i + 1 # cycletime
|
|
53
|
+
elsif started_date
|
|
54
|
+
days = (time_range.end.to_date - started_date).to_i + 1 # age
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
if stopped_today && started.nil?
|
|
51
58
|
@has_completed_but_not_started = true
|
|
52
59
|
rules.label = 'Completed but not started'
|
|
53
60
|
rules.color = '--wip-chart-completed-but-not-started-color'
|
|
54
61
|
rules.group_priority = -1
|
|
62
|
+
rules.issue_hint = '(Cycle time: Unknown)'
|
|
55
63
|
elsif stopped_today
|
|
56
64
|
rules.label = 'Completed'
|
|
57
65
|
rules.color = '--wip-chart-completed-color'
|
|
58
66
|
rules.group_priority = -2
|
|
67
|
+
rules.issue_hint = "(Cycle time: #{label_days days})"
|
|
59
68
|
elsif started.nil?
|
|
60
69
|
rules.label = 'Start date unknown'
|
|
61
70
|
rules.color = '--body-background'
|
|
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
64
73
|
rules.label = 'Blocked'
|
|
65
74
|
rules.color = '--blocked-color'
|
|
66
75
|
rules.group_priority = 1
|
|
67
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
76
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
68
77
|
elsif change&.stalled?
|
|
69
78
|
rules.label = 'Stalled'
|
|
70
79
|
rules.color = '--stalled-color'
|
|
71
80
|
rules.group_priority = 2
|
|
72
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
81
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
73
82
|
else
|
|
74
83
|
rules.label = 'Active'
|
|
75
84
|
rules.color = '--wip-chart-active-color'
|
|
76
85
|
rules.group_priority = 3
|
|
86
|
+
rules.issue_hint = "(Age: #{label_days days})"
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -3,10 +3,6 @@
|
|
|
3
3
|
require 'jirametrics/daily_wip_chart'
|
|
4
4
|
|
|
5
5
|
class DailyWipByParentChart < DailyWipChart
|
|
6
|
-
def initialize block
|
|
7
|
-
super(block)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
6
|
def default_header_text
|
|
11
7
|
'Daily WIP, grouped by the parent ticket (Epic, Feature, etc)'
|
|
12
8
|
end
|
|
@@ -30,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
30
26
|
end
|
|
31
27
|
|
|
32
28
|
def default_grouping_rules issue:, rules:
|
|
33
|
-
parent = issue.parent
|
|
29
|
+
parent = issue.parent
|
|
34
30
|
if parent
|
|
35
|
-
rules.label = parent
|
|
31
|
+
rules.label = parent.key
|
|
32
|
+
rules.label_hint = "#{parent.key} : #{parent.summary}"
|
|
36
33
|
else
|
|
37
34
|
rules.label = 'No parent'
|
|
35
|
+
rules.label_hint = 'No parent'
|
|
38
36
|
rules.group_priority = 1000
|
|
39
37
|
rules.color = '--body-background'
|
|
40
38
|
end
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class DailyGroupingRules < GroupingRules
|
|
6
|
-
attr_accessor :current_date, :group_priority, :issue_hint
|
|
6
|
+
attr_accessor :current_date, :group_priority, :issue_hint, :highlight
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
super
|
|
9
|
+
super
|
|
10
10
|
@group_priority = 0
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
def group
|
|
14
|
+
[@label, @color, @highlight ? true : false]
|
|
15
|
+
end
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
class DailyWipChart < ChartBase
|
|
@@ -19,13 +23,15 @@ class DailyWipChart < ChartBase
|
|
|
19
23
|
|
|
20
24
|
header_text default_header_text
|
|
21
25
|
description_text default_description_text
|
|
26
|
+
@x_axis_title = nil
|
|
27
|
+
@y_axis_title = 'Count of items'
|
|
22
28
|
|
|
23
29
|
instance_eval(&block) if block
|
|
24
30
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
return if @group_by_block
|
|
32
|
+
|
|
33
|
+
grouping_rules do |issue, rules|
|
|
34
|
+
default_grouping_rules issue: issue, rules: rules
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
|
|
@@ -33,8 +39,15 @@ class DailyWipChart < ChartBase
|
|
|
33
39
|
issue_rules_by_active_date = group_issues_by_active_dates
|
|
34
40
|
possible_rules = select_possible_rules issue_rules_by_active_date
|
|
35
41
|
|
|
42
|
+
conflicting_labels = possible_rules
|
|
43
|
+
.group_by(&:label)
|
|
44
|
+
.select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
|
|
45
|
+
.keys
|
|
46
|
+
|
|
36
47
|
data_sets = possible_rules.collect do |grouping_rule|
|
|
37
|
-
|
|
48
|
+
suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
|
|
49
|
+
make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
|
|
50
|
+
label_suffix: suffix
|
|
38
51
|
end
|
|
39
52
|
if @trend_lines
|
|
40
53
|
data_sets = @trend_lines.filter_map do |group_labels, line_color|
|
|
@@ -66,9 +79,7 @@ class DailyWipChart < ChartBase
|
|
|
66
79
|
hash = {}
|
|
67
80
|
|
|
68
81
|
@issues.each do |issue|
|
|
69
|
-
|
|
70
|
-
start = cycletime.started_time(issue)&.to_date
|
|
71
|
-
stop = cycletime.stopped_time(issue)&.to_date
|
|
82
|
+
start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
|
|
72
83
|
next if start.nil? && stop.nil?
|
|
73
84
|
|
|
74
85
|
# If it stopped but never started then assume it started at creation so the data points
|
|
@@ -84,16 +95,17 @@ class DailyWipChart < ChartBase
|
|
|
84
95
|
hash
|
|
85
96
|
end
|
|
86
97
|
|
|
87
|
-
def make_data_set grouping_rule:, issue_rules_by_active_date:
|
|
98
|
+
def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
|
|
88
99
|
positive = grouping_rule.group_priority >= 0
|
|
100
|
+
display_label = "#{grouping_rule.label}#{label_suffix}"
|
|
89
101
|
|
|
90
102
|
data = issue_rules_by_active_date.collect do |date, issue_rules|
|
|
91
|
-
# issues = []
|
|
92
103
|
issue_strings = issue_rules
|
|
93
104
|
.select { |_issue, rules| rules.group == grouping_rule.group }
|
|
94
105
|
.sort_by { |issue, _rules| issue.key_as_i }
|
|
95
106
|
.collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
|
|
96
|
-
|
|
107
|
+
title_label = grouping_rule.label_hint || display_label
|
|
108
|
+
title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
|
|
97
109
|
|
|
98
110
|
{
|
|
99
111
|
x: date,
|
|
@@ -102,11 +114,19 @@ class DailyWipChart < ChartBase
|
|
|
102
114
|
}
|
|
103
115
|
end
|
|
104
116
|
|
|
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
|
+
|
|
105
124
|
{
|
|
106
125
|
type: 'bar',
|
|
107
|
-
label:
|
|
126
|
+
label: display_label,
|
|
127
|
+
label_hint: grouping_rule.label_hint,
|
|
108
128
|
data: data,
|
|
109
|
-
backgroundColor:
|
|
129
|
+
backgroundColor: background_color,
|
|
110
130
|
borderColor: CssVariable['--wip-chart-border-color'],
|
|
111
131
|
borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
|
|
112
132
|
borderRadius: positive ? 0 : 5
|
|
@@ -158,7 +178,7 @@ class DailyWipChart < ChartBase
|
|
|
158
178
|
|
|
159
179
|
{
|
|
160
180
|
type: 'line',
|
|
161
|
-
label:
|
|
181
|
+
label: 'Trendline',
|
|
162
182
|
data: data_points,
|
|
163
183
|
fill: false,
|
|
164
184
|
borderWidth: 1,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class DataQualityReport < ChartBase
|
|
4
|
-
attr_reader :
|
|
4
|
+
attr_reader :discarded_changes_data, :entries # Both for testing purposes only
|
|
5
5
|
attr_accessor :board_id
|
|
6
6
|
|
|
7
7
|
class Entry
|
|
@@ -19,10 +19,10 @@ class DataQualityReport < ChartBase
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def initialize
|
|
22
|
+
def initialize discarded_changes_data
|
|
23
23
|
super()
|
|
24
24
|
|
|
25
|
-
@
|
|
25
|
+
@discarded_changes_data = discarded_changes_data
|
|
26
26
|
|
|
27
27
|
header_text 'Data Quality Report'
|
|
28
28
|
description_text <<-HTML
|
|
@@ -45,10 +45,13 @@ class DataQualityReport < ChartBase
|
|
|
45
45
|
scan_for_completed_issues_without_a_start_time entry: entry
|
|
46
46
|
scan_for_status_change_after_done entry: entry
|
|
47
47
|
scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
|
|
48
|
+
scan_for_issue_not_in_active_sprint entry: entry
|
|
48
49
|
scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
|
|
49
50
|
scan_for_stopped_before_started entry: entry
|
|
50
51
|
scan_for_issues_not_started_with_subtasks_that_have entry: entry
|
|
52
|
+
scan_for_incomplete_subtasks_when_issue_done entry: entry
|
|
51
53
|
scan_for_discarded_data entry: entry
|
|
54
|
+
scan_for_items_blocked_on_closed_tickets entry: entry
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
scan_for_issues_on_multiple_boards entries: @entries
|
|
@@ -56,7 +59,26 @@ class DataQualityReport < ChartBase
|
|
|
56
59
|
entries_with_problems = entries_with_problems()
|
|
57
60
|
return '' if entries_with_problems.empty?
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
caller_binding = binding
|
|
63
|
+
result = +''
|
|
64
|
+
result << render_top_text(caller_binding)
|
|
65
|
+
|
|
66
|
+
result << '<ul class="quality_report">'
|
|
67
|
+
result << render_problem_type(:discarded_changes)
|
|
68
|
+
result << render_problem_type(:completed_but_not_started)
|
|
69
|
+
result << render_problem_type(:status_changes_after_done)
|
|
70
|
+
result << render_problem_type(:backwards_through_status_categories)
|
|
71
|
+
result << render_problem_type(:backwords_through_statuses)
|
|
72
|
+
result << render_problem_type(:issue_not_visible_on_board)
|
|
73
|
+
result << render_problem_type(:created_in_wrong_status)
|
|
74
|
+
result << render_problem_type(:stopped_before_started)
|
|
75
|
+
result << render_problem_type(:issue_not_started_but_subtasks_have)
|
|
76
|
+
result << render_problem_type(:incomplete_subtasks_when_issue_done)
|
|
77
|
+
result << render_problem_type(:issue_on_multiple_boards)
|
|
78
|
+
result << render_problem_type(:items_blocked_on_closed_tickets)
|
|
79
|
+
result << '</ul>'
|
|
80
|
+
|
|
81
|
+
result
|
|
60
82
|
end
|
|
61
83
|
|
|
62
84
|
def problems_for key
|
|
@@ -69,11 +91,27 @@ class DataQualityReport < ChartBase
|
|
|
69
91
|
result
|
|
70
92
|
end
|
|
71
93
|
|
|
94
|
+
def render_problem_type problem_key
|
|
95
|
+
problems = problems_for problem_key
|
|
96
|
+
return '' if problems.empty?
|
|
97
|
+
|
|
98
|
+
<<-HTML
|
|
99
|
+
<li>
|
|
100
|
+
#{__send__ :"render_#{problem_key}", problems}
|
|
101
|
+
#{collapsible_issues_panel problems}
|
|
102
|
+
</li>
|
|
103
|
+
HTML
|
|
104
|
+
end
|
|
105
|
+
|
|
72
106
|
# Return a format that's easier to assert against
|
|
73
107
|
def testable_entries
|
|
74
|
-
|
|
108
|
+
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
75
109
|
@entries.collect do |entry|
|
|
76
|
-
[
|
|
110
|
+
[
|
|
111
|
+
formatter.call(entry.started),
|
|
112
|
+
formatter.call(entry.stopped),
|
|
113
|
+
entry.issue
|
|
114
|
+
]
|
|
77
115
|
end
|
|
78
116
|
end
|
|
79
117
|
|
|
@@ -81,15 +119,9 @@ class DataQualityReport < ChartBase
|
|
|
81
119
|
@entries.reject { |entry| entry.problems.empty? }
|
|
82
120
|
end
|
|
83
121
|
|
|
84
|
-
def category_name_for status_name:, board:
|
|
85
|
-
board.possible_statuses.find { |status| status.name == status_name }&.category_name
|
|
86
|
-
end
|
|
87
|
-
|
|
88
122
|
def initialize_entries
|
|
89
123
|
@entries = @issues.filter_map do |issue|
|
|
90
|
-
|
|
91
|
-
started = cycletime.started_time(issue)
|
|
92
|
-
stopped = cycletime.stopped_time(issue)
|
|
124
|
+
started, stopped = issue.started_stopped_times
|
|
93
125
|
next if stopped && stopped < time_range.begin
|
|
94
126
|
next if started && started > time_range.end
|
|
95
127
|
|
|
@@ -110,10 +142,8 @@ class DataQualityReport < ChartBase
|
|
|
110
142
|
def scan_for_completed_issues_without_a_start_time entry:
|
|
111
143
|
return unless entry.stopped && entry.started.nil?
|
|
112
144
|
|
|
113
|
-
status_names = entry.issue.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
format_status change.value, board: entry.issue.board
|
|
145
|
+
status_names = entry.issue.status_changes.filter_map do |change|
|
|
146
|
+
format_status change, board: entry.issue.board
|
|
117
147
|
end
|
|
118
148
|
|
|
119
149
|
entry.report(
|
|
@@ -128,14 +158,14 @@ class DataQualityReport < ChartBase
|
|
|
128
158
|
changes_after_done = entry.issue.changes.select do |change|
|
|
129
159
|
change.status? && change.time >= entry.stopped
|
|
130
160
|
end
|
|
131
|
-
done_status = changes_after_done.shift
|
|
161
|
+
done_status = changes_after_done.shift
|
|
132
162
|
|
|
133
163
|
return if changes_after_done.empty?
|
|
134
164
|
|
|
135
165
|
board = entry.issue.board
|
|
136
166
|
problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
|
|
137
167
|
changes_after_done.each do |change|
|
|
138
|
-
problem << " Changed to #{format_status change
|
|
168
|
+
problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
|
|
139
169
|
end
|
|
140
170
|
entry.report(
|
|
141
171
|
problem_key: :status_changes_after_done,
|
|
@@ -155,38 +185,38 @@ class DataQualityReport < ChartBase
|
|
|
155
185
|
index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
|
|
156
186
|
if index.nil?
|
|
157
187
|
# If it's a backlog status then ignore it. Not supposed to be visible.
|
|
158
|
-
next if entry.issue.board.backlog_statuses.include?
|
|
188
|
+
next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
|
|
159
189
|
|
|
160
|
-
detail = "Status #{format_status change
|
|
161
|
-
if issue.board.possible_statuses.
|
|
162
|
-
detail = "Status #{format_status change
|
|
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?"
|
|
163
193
|
end
|
|
164
194
|
|
|
165
195
|
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
166
196
|
detail = nil if backlog_statuses.any? { |s| s.name == change.value }
|
|
167
197
|
|
|
168
|
-
entry.report(problem_key: :
|
|
198
|
+
entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
|
|
169
199
|
elsif change.old_value.nil?
|
|
170
200
|
# Do nothing
|
|
171
201
|
elsif index < last_index
|
|
172
|
-
new_category =
|
|
173
|
-
old_category =
|
|
202
|
+
new_category = board.possible_statuses.find_by_id(change.value_id).category.name
|
|
203
|
+
old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
|
|
174
204
|
|
|
175
205
|
if new_category == old_category
|
|
176
206
|
entry.report(
|
|
177
207
|
problem_key: :backwords_through_statuses,
|
|
178
|
-
detail: "Moved from #{format_status change
|
|
179
|
-
" to #{format_status change
|
|
208
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
209
|
+
" to #{format_status change, board: board}" \
|
|
180
210
|
" on #{change.time.to_date}"
|
|
181
211
|
)
|
|
182
212
|
else
|
|
183
213
|
entry.report(
|
|
184
214
|
problem_key: :backwards_through_status_categories,
|
|
185
|
-
detail: "Moved from #{format_status change
|
|
186
|
-
" to #{format_status change
|
|
187
|
-
" on #{change.time.to_date},
|
|
188
|
-
" crossing from category #{format_status
|
|
189
|
-
" to #{format_status
|
|
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}."
|
|
190
220
|
)
|
|
191
221
|
end
|
|
192
222
|
end
|
|
@@ -194,17 +224,38 @@ class DataQualityReport < ChartBase
|
|
|
194
224
|
end
|
|
195
225
|
end
|
|
196
226
|
|
|
197
|
-
def
|
|
198
|
-
|
|
227
|
+
def scan_for_issue_not_in_active_sprint entry:
|
|
228
|
+
issue = entry.issue
|
|
229
|
+
return unless issue.board.scrum?
|
|
230
|
+
return if issue.sprints.any?(&:active?)
|
|
231
|
+
|
|
232
|
+
entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def scan_for_issue_never_visible_on_board entry:
|
|
236
|
+
issue = entry.issue
|
|
237
|
+
ever_visible = issue.changes.any? do |change|
|
|
238
|
+
next unless change.status?
|
|
199
239
|
|
|
240
|
+
issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
|
|
241
|
+
end
|
|
242
|
+
return if ever_visible
|
|
243
|
+
|
|
244
|
+
entry.report(
|
|
245
|
+
problem_key: :issue_not_visible_on_board,
|
|
246
|
+
detail: 'Issue has never been in a status mapped to a visible column on the board'
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
200
251
|
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
|
201
252
|
|
|
202
253
|
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
|
203
254
|
|
|
204
|
-
status_string = backlog_statuses.collect { |s| format_status s
|
|
255
|
+
status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
|
|
205
256
|
entry.report(
|
|
206
257
|
problem_key: :created_in_wrong_status,
|
|
207
|
-
detail: "Created in #{format_status creation_change
|
|
258
|
+
detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
|
|
208
259
|
"which is not one of the backlog statuses for this board: #{status_string}"
|
|
209
260
|
)
|
|
210
261
|
end
|
|
@@ -223,14 +274,13 @@ class DataQualityReport < ChartBase
|
|
|
223
274
|
|
|
224
275
|
started_subtasks = []
|
|
225
276
|
entry.issue.subtasks.each do |subtask|
|
|
226
|
-
started_subtasks << subtask if subtask.
|
|
277
|
+
started_subtasks << subtask if subtask.started_stopped_times.first
|
|
227
278
|
end
|
|
228
279
|
|
|
229
280
|
return if started_subtasks.empty?
|
|
230
281
|
|
|
231
282
|
subtask_labels = started_subtasks.collect do |subtask|
|
|
232
|
-
|
|
233
|
-
"#{subtask.summary[..50].inspect}"
|
|
283
|
+
subtask_label(subtask)
|
|
234
284
|
end
|
|
235
285
|
entry.report(
|
|
236
286
|
problem_key: :issue_not_started_but_subtasks_have,
|
|
@@ -238,6 +288,63 @@ class DataQualityReport < ChartBase
|
|
|
238
288
|
)
|
|
239
289
|
end
|
|
240
290
|
|
|
291
|
+
def scan_for_items_blocked_on_closed_tickets entry:
|
|
292
|
+
entry.issue.issue_links.each do |link|
|
|
293
|
+
next unless settings['blocked_link_text'].include?(link.label)
|
|
294
|
+
|
|
295
|
+
this_active = !entry.stopped
|
|
296
|
+
other_active = !link.other_issue.started_stopped_times.last
|
|
297
|
+
next unless this_active && !other_active
|
|
298
|
+
|
|
299
|
+
entry.report(
|
|
300
|
+
problem_key: :items_blocked_on_closed_tickets,
|
|
301
|
+
detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
|
|
302
|
+
"except #{link.other_issue.key} is closed."
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def subtask_label subtask
|
|
308
|
+
"<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def time_as_english(from_time, to_time)
|
|
312
|
+
delta = (to_time - from_time).to_i
|
|
313
|
+
return "#{delta} seconds" if delta < 60
|
|
314
|
+
|
|
315
|
+
delta /= 60
|
|
316
|
+
return "#{delta} minutes" if delta < 60
|
|
317
|
+
|
|
318
|
+
delta /= 60
|
|
319
|
+
return "#{delta} hours" if delta < 24
|
|
320
|
+
|
|
321
|
+
delta /= 24
|
|
322
|
+
label_days delta
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def scan_for_incomplete_subtasks_when_issue_done entry:
|
|
326
|
+
return unless entry.stopped
|
|
327
|
+
|
|
328
|
+
subtask_labels = entry.issue.subtasks.filter_map do |subtask|
|
|
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
|
+
|
|
340
|
+
return if subtask_labels.empty?
|
|
341
|
+
|
|
342
|
+
entry.report(
|
|
343
|
+
problem_key: :incomplete_subtasks_when_issue_done,
|
|
344
|
+
detail: subtask_labels.join('<br />')
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
|
|
241
348
|
def label_issues number
|
|
242
349
|
return '1 item' if number == 1
|
|
243
350
|
|
|
@@ -245,10 +352,10 @@ class DataQualityReport < ChartBase
|
|
|
245
352
|
end
|
|
246
353
|
|
|
247
354
|
def scan_for_discarded_data entry:
|
|
248
|
-
hash = @
|
|
355
|
+
hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
|
|
249
356
|
return if hash.nil?
|
|
250
357
|
|
|
251
|
-
old_start_time = hash[:
|
|
358
|
+
old_start_time = hash[:original_start_time]
|
|
252
359
|
cutoff_time = hash[:cutoff_time]
|
|
253
360
|
|
|
254
361
|
old_start_date = old_start_time.to_date
|
|
@@ -278,4 +385,106 @@ class DataQualityReport < ChartBase
|
|
|
278
385
|
)
|
|
279
386
|
end
|
|
280
387
|
end
|
|
388
|
+
|
|
389
|
+
def render_discarded_changes problems
|
|
390
|
+
<<-HTML
|
|
391
|
+
#{label_issues problems.size} have had information discarded. This configuration is set
|
|
392
|
+
to "reset the clock" if an item is moved back to the backlog after it's been started. This hides important
|
|
393
|
+
information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b>
|
|
394
|
+
HTML
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def render_completed_but_not_started problems
|
|
398
|
+
percentage_work_included = ((issues.size - problems.size).to_f / issues.size * 100).to_i
|
|
399
|
+
html = <<-HTML
|
|
400
|
+
#{label_issues problems.size} were discarded from all charts using cycletime (scatterplot, histogram, etc)
|
|
401
|
+
as we couldn't determine when they started.
|
|
402
|
+
HTML
|
|
403
|
+
if percentage_work_included < 85
|
|
404
|
+
html << <<-HTML
|
|
405
|
+
Consider whether looking at only #{percentage_work_included}% of the total data points is enough
|
|
406
|
+
to come to any reasonable conclusions. See <a href="https://unconsciousagile.com/2024/11/19/survivor-bias.html">
|
|
407
|
+
Survivor Bias</a>.
|
|
408
|
+
HTML
|
|
409
|
+
end
|
|
410
|
+
html
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def render_status_changes_after_done problems
|
|
414
|
+
<<-HTML
|
|
415
|
+
#{label_issues problems.size} had a status change after being identified as done. We should question
|
|
416
|
+
whether they were really done at that point or if we stopped the clock too early.
|
|
417
|
+
HTML
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def render_backwards_through_status_categories problems
|
|
421
|
+
<<-HTML
|
|
422
|
+
#{label_issues problems.size} moved backwards across the board, <b>crossing status categories</b>.
|
|
423
|
+
This will almost certainly have impacted timings as the end times are often taken at status category
|
|
424
|
+
boundaries. You should assume that any timing measurements for this item are wrong.
|
|
425
|
+
HTML
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def render_backwords_through_statuses problems
|
|
429
|
+
<<-HTML
|
|
430
|
+
#{label_issues problems.size} moved backwards across the board. Depending where we have set the
|
|
431
|
+
start and end points, this may give us incorrect timing data. Note that these items did not cross
|
|
432
|
+
a status category and may not have affected metrics.
|
|
433
|
+
HTML
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def render_issue_not_visible_on_board problems
|
|
437
|
+
unique_issue_count = problems.map(&:first).uniq.size
|
|
438
|
+
<<-HTML
|
|
439
|
+
#{problems.size} #{'time'.then { |w| problems.size == 1 ? w : "#{w}s" }} across #{label_issues unique_issue_count},
|
|
440
|
+
an item was not visible on the board. This may impact
|
|
441
|
+
timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
|
|
442
|
+
for two reasons: the issue was in a status that is not mapped to any visible column on the board
|
|
443
|
+
(look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
|
|
444
|
+
HTML
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def render_created_in_wrong_status problems
|
|
448
|
+
<<-HTML
|
|
449
|
+
#{label_issues problems.size} were created in a status that is not considered to be some varient
|
|
450
|
+
of To Do. Most likely this means that the issue was created from one of the columns on the board,
|
|
451
|
+
rather than in the backlog. Why Jira allows this is still a mystery.
|
|
452
|
+
HTML
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def render_stopped_before_started problems
|
|
456
|
+
<<-HTML
|
|
457
|
+
#{label_issues problems.size} were stopped before they were started and this will play havoc with
|
|
458
|
+
any cycletime or WIP calculations. The most common case for this is when an item gets closed and
|
|
459
|
+
then moved back into an in-progress status.
|
|
460
|
+
HTML
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def render_issue_not_started_but_subtasks_have problems
|
|
464
|
+
<<-HTML
|
|
465
|
+
#{label_issues problems.size} still showing 'not started' while sub-tasks underneath them have
|
|
466
|
+
started. This is almost always a mistake; if we're working on subtasks, the top level item should
|
|
467
|
+
also have started.
|
|
468
|
+
HTML
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def render_incomplete_subtasks_when_issue_done problems
|
|
472
|
+
<<-HTML
|
|
473
|
+
#{label_issues problems.size} issues were marked as done while subtasks were still not done.
|
|
474
|
+
HTML
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def render_issue_on_multiple_boards problems
|
|
478
|
+
<<-HTML
|
|
479
|
+
For #{label_issues problems.size}, we have an issue that shows up on more than one board. This
|
|
480
|
+
could result in more data points showing up on a chart then there really should be.
|
|
481
|
+
HTML
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def render_items_blocked_on_closed_tickets problems
|
|
485
|
+
<<-HTML
|
|
486
|
+
For #{label_issues problems.size}, the issue is identified as being blocked by another issue. Yet,
|
|
487
|
+
that other issue is already completed so, by definition, it can't still be blocking.
|
|
488
|
+
HTML
|
|
489
|
+
end
|
|
281
490
|
end
|
|
@@ -51,10 +51,13 @@ class DependencyChart < ChartBase
|
|
|
51
51
|
instance_eval(&@rules_block) if @rules_block
|
|
52
52
|
|
|
53
53
|
dot_graph = build_dot_graph
|
|
54
|
-
|
|
54
|
+
if dot_graph.nil?
|
|
55
|
+
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
56
|
+
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
57
|
+
end
|
|
55
58
|
|
|
56
59
|
svg = execute_graphviz(dot_graph.join("\n"))
|
|
57
|
-
"<h1>#{@header_text}</h1><div>#{@description_text}
|
|
60
|
+
"<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def link_rules &block
|
|
@@ -183,9 +186,8 @@ class DependencyChart < ChartBase
|
|
|
183
186
|
return stdout.read
|
|
184
187
|
end
|
|
185
188
|
rescue # rubocop:disable Style/RescueStandardError
|
|
186
|
-
message =
|
|
187
|
-
|
|
188
|
-
puts message
|
|
189
|
+
message = 'Unable to generate the dependency chart because graphviz could not be found in the path.'
|
|
190
|
+
file_system.log message, also_write_to_stderr: true
|
|
189
191
|
message
|
|
190
192
|
end
|
|
191
193
|
|
|
@@ -229,7 +231,7 @@ class DependencyChart < ChartBase
|
|
|
229
231
|
elsif is_done
|
|
230
232
|
line2 << 'Done'
|
|
231
233
|
else
|
|
232
|
-
started_at = issue.
|
|
234
|
+
started_at = issue.started_stopped_times.first
|
|
233
235
|
if started_at.nil?
|
|
234
236
|
line2 << 'Not started'
|
|
235
237
|
else
|