jirametrics 2.31 → 3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) 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 +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  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 +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  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 +19 -10
  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 +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. 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
 
@@ -128,15 +128,7 @@ class DataQualityReport < ChartBase
128
128
  Entry.new started: started, stopped: stopped, issue: issue
129
129
  end
130
130
 
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
131
+ @entries.sort_by! { |entry| entry.issue.key_as_i }
140
132
  end
141
133
 
142
134
  def scan_for_completed_issues_without_a_start_time entry:
@@ -174,56 +166,66 @@ class DataQualityReport < ChartBase
174
166
  end
175
167
 
176
168
  def scan_for_backwards_movement entry:, backlog_statuses:
177
- issue = entry.issue
178
-
179
169
  # Moving backwards through statuses is bad. Moving backwards through status categories is almost always worse.
180
170
  last_index = -1
181
- issue.changes.each do |change|
171
+ entry.issue.changes.each do |change|
182
172
  next unless change.status?
183
173
 
184
174
  board = entry.issue.board
185
- index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
175
+ index = column_index_for(board, change)
186
176
  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?
177
+ report_status_not_on_board(entry: entry, change: change, board: board, backlog_statuses: backlog_statuses)
199
178
  elsif change.old_value.nil?
200
- # Do nothing
179
+ # A move onto the board with no prior status has nothing to compare against.
201
180
  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
181
+ report_backwards_movement(entry: entry, change: change, board: board)
222
182
  end
223
183
  last_index = index || -1
224
184
  end
225
185
  end
226
186
 
187
+ def column_index_for board, change
188
+ board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
189
+ end
190
+
191
+ def report_status_not_on_board entry:, change:, board:, backlog_statuses:
192
+ # If it's a backlog status then ignore it. Not supposed to be visible.
193
+ return if board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
194
+
195
+ detail = "Status #{format_status change, board: board} is not on the board"
196
+ if board.possible_statuses.find_by_id(change.value_id).nil?
197
+ detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
198
+ end
199
+
200
+ # If it's been moved back to backlog then it's on a different report. Ignore it here.
201
+ detail = nil if backlog_statuses.any? { |s| s.name == change.value }
202
+
203
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
204
+ end
205
+
206
+ def report_backwards_movement entry:, change:, board:
207
+ new_category = board.possible_statuses.find_by_id(change.value_id).category.name
208
+ old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
209
+
210
+ if new_category == old_category
211
+ entry.report(
212
+ problem_key: :backwords_through_statuses,
213
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
214
+ " to #{format_status change, board: board}" \
215
+ " on #{change.time.to_date}"
216
+ )
217
+ else
218
+ entry.report(
219
+ problem_key: :backwards_through_status_categories,
220
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
221
+ " to #{format_status change, board: board}" \
222
+ " on #{change.time.to_date}," \
223
+ " crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
224
+ " to #{format_status change, board: board, is_category: true}."
225
+ )
226
+ end
227
+ end
228
+
227
229
  def scan_for_issue_not_in_active_sprint entry:
228
230
  issue = entry.issue
229
231
  return unless issue.board.scrum?
@@ -248,7 +250,7 @@ class DataQualityReport < ChartBase
248
250
  end
249
251
 
250
252
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
251
- creation_change = entry.issue.changes.find { |issue| issue.status? }
253
+ creation_change = entry.issue.changes.find(&:status?)
252
254
 
253
255
  return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
254
256
 
@@ -325,18 +327,7 @@ class DataQualityReport < ChartBase
325
327
  def scan_for_incomplete_subtasks_when_issue_done entry:
326
328
  return unless entry.stopped
327
329
 
328
- subtask_labels = entry.issue.subtasks.filter_map 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
-
330
+ subtask_labels = entry.issue.subtasks.filter_map { |subtask| incomplete_subtask_label subtask, entry.stopped }
340
331
  return if subtask_labels.empty?
341
332
 
342
333
  entry.report(
@@ -345,10 +336,17 @@ class DataQualityReport < ChartBase
345
336
  )
346
337
  end
347
338
 
348
- def label_issues number
349
- return '1 item' if number == 1
339
+ # The label for one subtask that isn't finished when its parent is done, or nil if it is fine.
340
+ def incomplete_subtask_label subtask, issue_stopped
341
+ subtask_started, subtask_stopped = subtask.started_stopped_times
350
342
 
351
- "#{number} items"
343
+ if !subtask_started && !subtask_stopped
344
+ "#{subtask_label subtask} (Not even started)"
345
+ elsif !subtask_stopped
346
+ "#{subtask_label subtask} (Still not done)"
347
+ elsif subtask_stopped > issue_stopped
348
+ "#{subtask_label subtask} (Closed #{time_as_english issue_stopped, subtask_stopped} later)"
349
+ end
352
350
  end
353
351
 
354
352
  def scan_for_discarded_data entry:
@@ -470,7 +468,7 @@ class DataQualityReport < ChartBase
470
468
 
471
469
  def render_incomplete_subtasks_when_issue_done problems
472
470
  <<-HTML
473
- #{label_issues problems.size} issues were marked as done while subtasks were still not done.
471
+ #{label_issues problems.size} were marked as done while subtasks were still not done.
474
472
  HTML
475
473
  end
476
474
 
@@ -14,6 +14,7 @@ class DependencyChart < ChartBase
14
14
  @merge_bidirectional = keep.to_sym
15
15
  end
16
16
 
17
+ # get_ prefix because the plain name is the config DSL setter (merge_bidirectional keep:).
17
18
  def get_merge_bidirectional # rubocop:disable Naming/AccessorMethodName
18
19
  @merge_bidirectional
19
20
  end
@@ -135,34 +136,46 @@ class DependencyChart < ChartBase
135
136
  @link_rules_block.call link, link_rules
136
137
 
137
138
  next if link_rules.ignored?
138
-
139
- if link_rules.get_merge_bidirectional
140
- opposite = issue_links.find do |l|
141
- l.name == link.name && l.origin.key == link.other_issue.key && l.other_issue.key == link.origin.key
142
- end
143
- if opposite
144
- # rubocop:disable Style/GuardClause
145
- if link_rules.get_merge_bidirectional.to_sym == link.direction
146
- # We keep this one and discard the opposite
147
- links_to_ignore << opposite
148
- else
149
- # We keep the opposite and discard this one
150
- next
151
- end
152
- # rubocop:enable Style/GuardClause
153
- end
154
- end
139
+ next if merge_bidirectional_skip?(link, link_rules, issue_links, links_to_ignore)
155
140
 
156
141
  link_graph << make_dot_link(issue_link: link, link_rules: link_rules)
157
-
158
142
  visible_issues[link.origin.key] = link.origin
159
143
  visible_issues[link.other_issue.key] = link.other_issue
160
144
  end
161
145
 
162
- 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