jirametrics 2.31 → 3.1

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. 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
- 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
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
- elsif change&.blocked?
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
- conflicting_labels = possible_rules
43
- .group_by(&:label)
44
- .select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
45
- .keys
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
- data_sets = possible_rules.collect do |grouping_rule|
48
- suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
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
- if @trend_lines
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
- wrap_and_render(binding, __FILE__)
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: # rubocop:disable Lint/UnusedMethodArgument
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
- start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
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
- issue_strings = issue_rules
104
- .select { |_issue, rules| rules.group == grouping_rule.group }
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: background_color,
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
- def trend_line_data_set data:, group_labels:, color:
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
- points = day_wip_hash
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
 
@@ -28,9 +28,15 @@ class DataQualityReport < ChartBase
28
28
  description_text <<-HTML
29
29
  <p>
30
30
  We have a tendency to assume that anything we see in a chart is 100% accurate, although that's
31
- not always true. To understand the accuracy of the chart, we have to understand how accurate the
32
- initial data was and also how much of the original data set was used in the chart. This section
33
- will hopefully give you enough information to make that decision.
31
+ not always true. The accuracy of a chart depends both on how good the underlying data was and
32
+ on how much of that data actually made it into the chart.
33
+ </p>
34
+ <p>
35
+ The items highlighted below are not necessarily bad data. Some are genuine contradictions worth
36
+ fixing in Jira, such as an item finishing before it started. But many are simply places where the
37
+ way your team actually works bumps up against the simplified start-to-finish model this tool uses
38
+ to compute flow metrics. Those aren't mistakes; we flag them so you can read the charts with the
39
+ right context, not because anyone did anything wrong.
34
40
  </p>
35
41
  HTML
36
42
  end
@@ -128,15 +134,7 @@ class DataQualityReport < ChartBase
128
134
  Entry.new started: started, stopped: stopped, issue: issue
129
135
  end
130
136
 
131
- @entries.sort! do |a, b|
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
137
+ @entries.sort_by! { |entry| entry.issue.key_as_i }
140
138
  end
141
139
 
142
140
  def scan_for_completed_issues_without_a_start_time entry:
@@ -174,56 +172,66 @@ class DataQualityReport < ChartBase
174
172
  end
175
173
 
176
174
  def scan_for_backwards_movement entry:, backlog_statuses:
177
- issue = entry.issue
178
-
179
175
  # Moving backwards through statuses is bad. Moving backwards through status categories is almost always worse.
180
176
  last_index = -1
181
- issue.changes.each do |change|
177
+ entry.issue.changes.each do |change|
182
178
  next unless change.status?
183
179
 
184
180
  board = entry.issue.board
185
- index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
181
+ index = column_index_for(board, change)
186
182
  if index.nil?
187
- # If it's a backlog status then ignore it. Not supposed to be visible.
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?
183
+ report_status_not_on_board(entry: entry, change: change, board: board, backlog_statuses: backlog_statuses)
199
184
  elsif change.old_value.nil?
200
- # Do nothing
185
+ # A move onto the board with no prior status has nothing to compare against.
201
186
  elsif index < last_index
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
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
187
+ report_backwards_movement(entry: entry, change: change, board: board)
222
188
  end
223
189
  last_index = index || -1
224
190
  end
225
191
  end
226
192
 
193
+ def column_index_for board, change
194
+ board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
195
+ end
196
+
197
+ def report_status_not_on_board entry:, change:, board:, backlog_statuses:
198
+ # If it's a backlog status then ignore it. Not supposed to be visible.
199
+ return if board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
200
+
201
+ detail = "Status #{format_status change, board: board} is not on the board"
202
+ if board.possible_statuses.find_by_id(change.value_id).nil?
203
+ detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
204
+ end
205
+
206
+ # If it's been moved back to backlog then it's on a different report. Ignore it here.
207
+ detail = nil if backlog_statuses.any? { |s| s.name == change.value }
208
+
209
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
210
+ end
211
+
212
+ def report_backwards_movement entry:, change:, board:
213
+ new_category = board.possible_statuses.find_by_id(change.value_id).category.name
214
+ old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
215
+
216
+ if new_category == old_category
217
+ entry.report(
218
+ problem_key: :backwords_through_statuses,
219
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
220
+ " to #{format_status change, board: board}" \
221
+ " on #{change.time.to_date}"
222
+ )
223
+ else
224
+ entry.report(
225
+ problem_key: :backwards_through_status_categories,
226
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
227
+ " to #{format_status change, board: board}" \
228
+ " on #{change.time.to_date}," \
229
+ " crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
230
+ " to #{format_status change, board: board, is_category: true}."
231
+ )
232
+ end
233
+ end
234
+
227
235
  def scan_for_issue_not_in_active_sprint entry:
228
236
  issue = entry.issue
229
237
  return unless issue.board.scrum?
@@ -248,7 +256,7 @@ class DataQualityReport < ChartBase
248
256
  end
249
257
 
250
258
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
251
- creation_change = entry.issue.changes.find { |issue| issue.status? }
259
+ creation_change = entry.issue.changes.find(&:status?)
252
260
 
253
261
  return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
254
262
 
@@ -325,18 +333,7 @@ class DataQualityReport < ChartBase
325
333
  def scan_for_incomplete_subtasks_when_issue_done entry:
326
334
  return unless entry.stopped
327
335
 
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
-
336
+ subtask_labels = entry.issue.subtasks.filter_map { |subtask| incomplete_subtask_label subtask, entry.stopped }
340
337
  return if subtask_labels.empty?
341
338
 
342
339
  entry.report(
@@ -345,10 +342,17 @@ class DataQualityReport < ChartBase
345
342
  )
346
343
  end
347
344
 
348
- def label_issues number
349
- return '1 item' if number == 1
345
+ # The label for one subtask that isn't finished when its parent is done, or nil if it is fine.
346
+ def incomplete_subtask_label subtask, issue_stopped
347
+ subtask_started, subtask_stopped = subtask.started_stopped_times
350
348
 
351
- "#{number} items"
349
+ if !subtask_started && !subtask_stopped
350
+ "#{subtask_label subtask} (Not even started)"
351
+ elsif !subtask_stopped
352
+ "#{subtask_label subtask} (Still not done)"
353
+ elsif subtask_stopped > issue_stopped
354
+ "#{subtask_label subtask} (Closed #{time_as_english issue_stopped, subtask_stopped} later)"
355
+ end
352
356
  end
353
357
 
354
358
  def scan_for_discarded_data entry:
@@ -470,7 +474,7 @@ class DataQualityReport < ChartBase
470
474
 
471
475
  def render_incomplete_subtasks_when_issue_done problems
472
476
  <<-HTML
473
- #{label_issues problems.size} issues were marked as done while subtasks were still not done.
477
+ #{label_issues problems.size} were marked as done while subtasks were still not done.
474
478
  HTML
475
479
  end
476
480
 
@@ -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
- dot_graph = []
163
- dot_graph << 'digraph mygraph {'
164
- dot_graph << 'rankdir=LR'
165
- dot_graph << 'bgcolor="transparent"'
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 += link_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
- width = $1.to_i * scale
198
- height = $2.to_i * scale
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
@@ -39,7 +39,4 @@ class DownloadConfig
39
39
  date = @no_earlier_than if date.nil? && @no_earlier_than
40
40
  date
41
41
  end
42
-
43
- private
44
-
45
42
  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
@@ -339,21 +340,45 @@ class Downloader
339
340
  return
340
341
  end
341
342
 
342
- prs = @download_config.github_repos.flat_map do |repo|
343
- GithubGateway.new(
344
- repo: repo,
345
- project_keys: project_keys,
346
- file_system: @file_system,
347
- raw_pr_cache: @github_pr_cache
348
- ).fetch_pull_requests(since: @download_date_range&.begin)
343
+ prs = []
344
+ any_repo_failed = false
345
+ @download_config.github_repos.each do |repo|
346
+ prs.concat(
347
+ GithubGateway.new(
348
+ repo: repo,
349
+ project_keys: project_keys,
350
+ file_system: @file_system,
351
+ raw_pr_cache: @github_pr_cache
352
+ ).fetch_pull_requests(since: @download_date_range&.begin)
353
+ )
354
+ rescue StandardError => e
355
+ any_repo_failed = true
356
+ warn_about_failed_pr_download(repo, e)
349
357
  end
350
358
 
359
+ # A successful-but-empty fetch is a legitimate result and gets saved. But if any repo raised, we
360
+ # can't produce a complete file, so we leave the previously downloaded data untouched.
361
+ return if any_repo_failed
362
+
351
363
  @file_system.save_json(
352
364
  json: prs.map(&:raw),
353
365
  filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
354
366
  )
355
367
  end
356
368
 
369
+ # The same github_repos are downloaded for every project, so an unreachable repo would otherwise
370
+ # warn once per project (dozens of identical messages). The github_pr_cache is shared across all
371
+ # projects in a run, so we use it to warn about each repo at most once.
372
+ def warn_about_failed_pr_download repo, error
373
+ warned_key = [repo, :download_failure_warned]
374
+ return if @github_pr_cache[warned_key]
375
+
376
+ @github_pr_cache[warned_key] = true
377
+ @file_system.warning "Failed to download pull requests from #{repo}: #{error.message}. " \
378
+ 'Left the existing PR data in place rather than overwriting it with an incomplete set. ' \
379
+ 'Fix the access problem (often a `gh auth login` / repo-access issue) and re-run download to refresh it.'
380
+ end
381
+
357
382
  def extract_project_keys_from_downloaded_issues
358
383
  path = File.join(@target_path, "#{file_prefix}_issues")
359
384
  return [] unless @file_system.dir_exist?(path)