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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +73 -20
  12. data/lib/jirametrics/board_config.rb +10 -2
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +8 -6
  30. data/lib/jirametrics/download_config.rb +17 -2
  31. data/lib/jirametrics/downloader.rb +177 -108
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +5 -8
  37. data/lib/jirametrics/examples/standard_project.rb +54 -38
  38. data/lib/jirametrics/expedited_chart.rb +10 -9
  39. data/lib/jirametrics/exporter.rb +51 -16
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +481 -97
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  80. data/lib/jirametrics/settings.json +7 -1
  81. data/lib/jirametrics/sprint.rb +13 -0
  82. data/lib/jirametrics/sprint_burndown.rb +47 -39
  83. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  84. data/lib/jirametrics/status.rb +84 -19
  85. data/lib/jirametrics/status_collection.rb +83 -38
  86. data/lib/jirametrics/stitcher.rb +81 -0
  87. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  88. data/lib/jirametrics/throughput_chart.rb +73 -23
  89. data/lib/jirametrics/time_based_histogram.rb +139 -0
  90. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  91. data/lib/jirametrics/user.rb +12 -0
  92. data/lib/jirametrics/value_equality.rb +2 -2
  93. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  94. data/lib/jirametrics.rb +101 -66
  95. metadata +72 -16
  96. data/lib/jirametrics/cycletime_config.rb +0 -69
  97. data/lib/jirametrics/discard_changes_before.rb +0 -37
  98. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  99. 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.board.cycletime.started_time(issue)
43
- stopped_date = issue.board.cycletime.stopped_time(issue)&.to_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&.key
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
- unless @group_by_block
26
- grouping_rules do |issue, rules|
27
- default_grouping_rules issue: issue, rules: rules
28
- end
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
- make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date
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
- cycletime = issue.board.cycletime
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
- title = ["#{grouping_rule.label} (#{label_issues issue_strings.size})"] + issue_strings
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: grouping_rule.label,
126
+ label: display_label,
127
+ label_hint: grouping_rule.label_hint,
108
128
  data: data,
109
- backgroundColor: grouping_rule.color || random_color,
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: "Trendline",
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 :original_issue_times # For testing purposes only
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 original_issue_times
22
+ def initialize discarded_changes_data
23
23
  super()
24
24
 
25
- @original_issue_times = original_issue_times
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
- wrap_and_render(binding, __FILE__)
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
- format = '%Y-%m-%d %H:%M:%S %z'
108
+ formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
75
109
  @entries.collect do |entry|
76
- [entry.started&.strftime(format) || '', entry.stopped&.strftime(format) || '', entry.issue]
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
- cycletime = issue.board.cycletime
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.changes.filter_map do |change|
114
- next unless change.status?
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.value
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.value, board: board} on #{change.time.to_date}."
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? change.value_id
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.value, board: board} is not on the board"
161
- if issue.board.possible_statuses.expand_statuses(change.value).empty?
162
- detail = "Status #{format_status change.value, board: board} cannot be found at all. Was it deleted?"
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: :status_not_on_board, detail: detail) unless detail.nil?
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 = category_name_for(status_name: change.value, board: board)
173
- old_category = category_name_for(status_name: change.old_value, board: board)
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.old_value, board: board}" \
179
- " to #{format_status change.value, board: board}" \
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.old_value, board: board}" \
186
- " to #{format_status change.value, board: board}" \
187
- " on #{change.time.to_date}, " \
188
- " crossing from category #{format_status old_category, board: board, is_category: true}" \
189
- " to #{format_status new_category, board: board, is_category: true}."
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 scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
198
- return if backlog_statuses.empty?
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.name, board: entry.issue.board }.join(', ')
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.value, board: entry.issue.board}, " \
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.board.cycletime.started_time(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
- "Started subtask: #{link_to_issue(subtask)} (#{format_status subtask.status.name, board: entry.issue.board}) " \
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 = @original_issue_times[entry.issue]
355
+ hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
249
356
  return if hash.nil?
250
357
 
251
- old_start_time = hash[:started_time]
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
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
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}</div>#{shrink_svg svg}"
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 = "Unable to execute the command 'dot' which is part of graphviz. " \
187
- 'Ensure that graphviz is installed and that dot is in your path.'
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.board.cycletime.started_time(issue)
234
+ started_at = issue.started_stopped_times.first
233
235
  if started_at.nil?
234
236
  line2 << 'Not started'
235
237
  else