Class: DataQualityReport

Inherits:
ChartBase show all
Defined in:
lib/jirametrics/data_quality_report.rb

Defined Under Namespace

Classes: Entry

Instance Attribute Summary collapse

Attributes inherited from ChartBase

#aggregated_project, #all_boards, #canvas_height, #canvas_width, #data_quality, #date_range, #holiday_dates, #issues, #settings, #time_range, #timezone_offset

Instance Method Summary collapse

Methods inherited from ChartBase

#aggregated_project?, #canvas, #canvas_responsive?, #chart_format, #collapsible_issues_panel, #color_for, #completed_issues_in_range, #current_board, #daily_chart_dataset, #description_text, #filter_issues, #format_integer, #format_status, #header_text, #holidays, #label_days, #link_to_issue, #next_id, #random_color, #render, #sprints_in_time_range, #status_category_color, #wrap_and_render

Constructor Details

#initialize(original_issue_times) ⇒ DataQualityReport

Returns a new instance of DataQualityReport.



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/jirametrics/data_quality_report.rb', line 22

def initialize original_issue_times
  super()

  @original_issue_times = original_issue_times

  header_text 'Data Quality Report'
  description_text <<-HTML
    <p>
      We have a tendency to assume that anything we see in a chart is 100% accurate, although that's
      not always true. To understand the accuracy of the chart, we have to understand how accurate the
      initial data was and also how much of the original data set was used in the chart. This section
      will hopefully give you enough information to make that decision.
    </p>
  HTML
end

Instance Attribute Details

#board_idObject

Returns the value of attribute board_id.



5
6
7
# File 'lib/jirametrics/data_quality_report.rb', line 5

def board_id
  @board_id
end

#original_issue_timesObject (readonly)

For testing purposes only



4
5
6
# File 'lib/jirametrics/data_quality_report.rb', line 4

def original_issue_times
  @original_issue_times
end

Instance Method Details

#category_name_for(status_name:, board:) ⇒ Object



81
82
83
# File 'lib/jirametrics/data_quality_report.rb', line 81

def category_name_for status_name:, board:
  board.possible_statuses.find { |status| status.name == status_name }&.category_name
end

#entries_with_problemsObject



77
78
79
# File 'lib/jirametrics/data_quality_report.rb', line 77

def entries_with_problems
  @entries.reject { |entry| entry.problems.empty? }
end

#initialize_entriesObject



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/jirametrics/data_quality_report.rb', line 85

def initialize_entries
  @entries = @issues.collect do |issue|
    cycletime = issue.board.cycletime
    started = cycletime.started_time(issue)
    stopped = cycletime.stopped_time(issue)
    next if stopped && stopped < time_range.begin
    next if started && started > time_range.end

    Entry.new started: started, stopped: stopped, issue: issue
  end.compact

  @entries.sort! do |a, b|
    a.issue.key =~ /.+-(\d+)$/
    a_id = $1.to_i

    b.issue.key =~ /.+-(\d+)$/
    b_id = $1.to_i

    a_id <=> b_id
  end
end

#label_issues(number) ⇒ Object



238
239
240
241
242
# File 'lib/jirametrics/data_quality_report.rb', line 238

def label_issues number
  return '1 item' if number == 1

  "#{number} items"
end

#problems_for(key) ⇒ Object



62
63
64
65
66
67
68
69
70
# File 'lib/jirametrics/data_quality_report.rb', line 62

def problems_for key
  result = []
  @entries.each do |entry|
    entry.problems.each do |problem_key, detail|
      result << [entry.issue, detail, key] if problem_key == key
    end
  end
  result
end

#runObject



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/jirametrics/data_quality_report.rb', line 38

def run
  initialize_entries

  @entries.each do |entry|
    board = entry.issue.board
    backlog_statuses = board.backlog_statuses

    scan_for_completed_issues_without_a_start_time entry: entry
    scan_for_status_change_after_done entry: entry
    scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
    scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
    scan_for_stopped_before_started entry: entry
    scan_for_issues_not_started_with_subtasks_that_have entry: entry
    scan_for_discarded_data entry: entry
  end

  scan_for_issues_on_multiple_boards entries: @entries

  entries_with_problems = entries_with_problems()
  return '' if entries_with_problems.empty?

  wrap_and_render(binding, __FILE__)
end

#scan_for_backwards_movement(entry:, backlog_statuses:) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/jirametrics/data_quality_report.rb', line 143

def scan_for_backwards_movement entry:, backlog_statuses:
  issue = entry.issue

  # Moving backwards through statuses is bad. Moving backwards through status categories is almost always worse.
  last_index = -1
  issue.changes.each do |change|
    next unless change.status?

    board = entry.issue.board
    index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
    if index.nil?
      # If it's a backlog status then ignore it. Not supposed to be visible.
      next if entry.issue.board.backlog_statuses.include? change.value_id

      detail = "Status #{format_status change.value, board: board} is not on the board"
      if issue.board.possible_statuses.expand_statuses(change.value).empty?
        detail = "Status #{format_status change.value, board: board} cannot be found at all. Was it deleted?"
      end

      # If it's been moved back to backlog then it's on a different report. Ignore it here.
      detail = nil if backlog_statuses.any? { |s| s.name == change.value }

      entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
    elsif change.old_value.nil?
      # Do nothing
    elsif index < last_index
      new_category = category_name_for(status_name: change.value, board: board)
      old_category = category_name_for(status_name: change.old_value, board: board)

      if new_category == old_category
        entry.report(
          problem_key: :backwords_through_statuses,
          detail: "Moved from #{format_status change.old_value, board: board}" \
            " to #{format_status change.value, board: board}" \
            " on #{change.time.to_date}"
        )
      else
        entry.report(
          problem_key: :backwards_through_status_categories,
          detail: "Moved from #{format_status change.old_value, board: board}" \
            " to #{format_status change.value, board: board}" \
            " on #{change.time.to_date}, " \
            " crossing from category #{format_status old_category, board: board, is_category: true}" \
            " to #{format_status new_category, board: board, is_category: true}."
        )
      end
    end
    last_index = index || -1
  end
end

#scan_for_completed_issues_without_a_start_time(entry:) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/jirametrics/data_quality_report.rb', line 107

def scan_for_completed_issues_without_a_start_time entry:
  return unless entry.stopped && entry.started.nil?

  status_names = entry.issue.changes.collect do |change|
    next unless change.status?

    format_status change.value, board: entry.issue.board
  end.compact

  entry.report(
    problem_key: :completed_but_not_started,
    detail: "Status changes: #{status_names.join ''}"
  )
end

#scan_for_discarded_data(entry:) ⇒ Object



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/jirametrics/data_quality_report.rb', line 244

def scan_for_discarded_data entry:
  hash = @original_issue_times[entry.issue]
  return if hash.nil?

  old_start_time = hash[:started_time]
  cutoff_time = hash[:cutoff_time]

  old_start_date = old_start_time.to_date
  cutoff_date = cutoff_time.to_date

  days_ignored = (cutoff_date - old_start_date).to_i + 1
  message = "Started: #{old_start_date}, Discarded: #{cutoff_date}, Ignored: #{label_days days_ignored}"

  # If days_ignored is zero then we don't really care as it won't affect any of the calculations.
  return if days_ignored == 1

  entry.report(
    problem_key: :discarded_changes,
    detail: message
  )
end

#scan_for_issues_not_created_in_a_backlog_status(entry:, backlog_statuses:) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/jirametrics/data_quality_report.rb', line 194

def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
  return if backlog_statuses.empty?

  creation_change = entry.issue.changes.find { |issue| issue.status? }

  return if backlog_statuses.any? { |status| status.id == creation_change.value_id }

  status_string = backlog_statuses.collect { |s| format_status s.name, board: entry.issue.board }.join(', ')
  entry.report(
    problem_key: :created_in_wrong_status,
    detail: "Created in #{format_status creation_change.value, board: entry.issue.board}, " \
      "which is not one of the backlog statuses for this board: #{status_string}"
  )
end

#scan_for_issues_not_started_with_subtasks_that_have(entry:) ⇒ Object



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/jirametrics/data_quality_report.rb', line 218

def scan_for_issues_not_started_with_subtasks_that_have entry:
  return if entry.started

  started_subtasks = []
  entry.issue.subtasks.each do |subtask|
    started_subtasks << subtask if subtask.board.cycletime.started_time(subtask)
  end

  return if started_subtasks.empty?

  subtask_labels = started_subtasks.collect do |subtask|
    "Started subtask: #{link_to_issue(subtask)} (#{format_status subtask.status.name, board: entry.issue.board}) " \
      "#{subtask.summary[..50].inspect}"
  end
  entry.report(
    problem_key: :issue_not_started_but_subtasks_have,
    detail: subtask_labels.join('<br />')
  )
end

#scan_for_issues_on_multiple_boards(entries:) ⇒ Object



266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/jirametrics/data_quality_report.rb', line 266

def scan_for_issues_on_multiple_boards entries:
  grouped_entries = entries.group_by { |entry| entry.issue.key }
  grouped_entries.each_value do |entry_list|
    next if entry_list.size == 1

    board_names = entry_list.collect { |entry| entry.issue.board.name.inspect }
    entry_list.first.report(
      problem_key: :issue_on_multiple_boards,
      detail: "Found on boards: #{board_names.join(', ')}"
    )
  end
end

#scan_for_status_change_after_done(entry:) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/jirametrics/data_quality_report.rb', line 122

def scan_for_status_change_after_done entry:
  return unless entry.stopped

  changes_after_done = entry.issue.changes.select do |change|
    change.status? && change.time >= entry.stopped
  end
  done_status = changes_after_done.shift.value

  return if changes_after_done.empty?

  board = entry.issue.board
  problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
  changes_after_done.each do |change|
    problem << " Changed to #{format_status change.value, board: board} on #{change.time.to_date}."
  end
  entry.report(
    problem_key: :status_changes_after_done,
    detail: problem
  )
end

#scan_for_stopped_before_started(entry:) ⇒ Object



209
210
211
212
213
214
215
216
# File 'lib/jirametrics/data_quality_report.rb', line 209

def scan_for_stopped_before_started entry:
  return unless entry.stopped && entry.started && entry.stopped < entry.started

  entry.report(
    problem_key: :stopped_before_started,
    detail: "The stopped time '#{entry.stopped}' is before the started time '#{entry.started}'"
  )
end

#testable_entriesObject

Return a format that’s easier to assert against



73
74
75
# File 'lib/jirametrics/data_quality_report.rb', line 73

def testable_entries
  @entries.collect { |entry| [entry.started.to_s, entry.stopped.to_s, entry.issue] }
end