jirametrics 2.25 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aging_work_bar_chart.rb +10 -8
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  5. data/lib/jirametrics/aging_work_table.rb +5 -2
  6. data/lib/jirametrics/board.rb +9 -1
  7. data/lib/jirametrics/cfd_data_builder.rb +5 -0
  8. data/lib/jirametrics/chart_base.rb +14 -2
  9. data/lib/jirametrics/cumulative_flow_diagram.rb +8 -0
  10. data/lib/jirametrics/cycletime_scatterplot.rb +4 -0
  11. data/lib/jirametrics/daily_view.rb +5 -4
  12. data/lib/jirametrics/data_quality_report.rb +3 -1
  13. data/lib/jirametrics/dependency_chart.rb +1 -1
  14. data/lib/jirametrics/downloader.rb +18 -7
  15. data/lib/jirametrics/downloader_for_cloud.rb +68 -22
  16. data/lib/jirametrics/downloader_for_data_center.rb +1 -1
  17. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  18. data/lib/jirametrics/examples/standard_project.rb +5 -2
  19. data/lib/jirametrics/exporter.rb +12 -1
  20. data/lib/jirametrics/file_config.rb +9 -11
  21. data/lib/jirametrics/file_system.rb +31 -2
  22. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  23. data/lib/jirametrics/github_gateway.rb +13 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  25. data/lib/jirametrics/grouping_rules.rb +5 -1
  26. data/lib/jirametrics/html/cumulative_flow_diagram.erb +7 -8
  27. data/lib/jirametrics/html/index.css +139 -88
  28. data/lib/jirametrics/html/index.erb +1 -0
  29. data/lib/jirametrics/html/index.js +1 -1
  30. data/lib/jirametrics/html/legacy_colors.css +174 -0
  31. data/lib/jirametrics/html/time_based_scatterplot.erb +8 -3
  32. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  33. data/lib/jirametrics/html_generator.rb +2 -1
  34. data/lib/jirametrics/html_report_config.rb +33 -27
  35. data/lib/jirametrics/issue.rb +99 -6
  36. data/lib/jirametrics/jira_gateway.rb +26 -7
  37. data/lib/jirametrics/mcp_server.rb +531 -0
  38. data/lib/jirametrics/project_config.rb +20 -1
  39. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +2 -2
  40. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +13 -6
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/stitcher.rb +5 -0
  43. data/lib/jirametrics/throughput_chart.rb +18 -2
  44. data/lib/jirametrics/time_based_scatterplot.rb +9 -2
  45. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  46. data/lib/jirametrics.rb +58 -0
  47. metadata +36 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 615528aa77577881d7658b0ce059e9e3972ce4285aeb3c1081c4347dd1a20ca4
4
- data.tar.gz: 483cc9b7535da95ca2249813e5ad8ff924334f65c8b7fa84c5705367a9d1b147
3
+ metadata.gz: a1f64f63f13e8cb59d3b18fb1e1ad90f77ca06d0e2f59a75ff7b7bae4db1870f
4
+ data.tar.gz: 9b7d6b8759102d7590e86c114d2ac8b1b2e7c4cc9f45a168002752196a6bf797
5
5
  SHA512:
6
- metadata.gz: 36acebeef4d036c6ed043f0073177944c390d5c80ba6ef162319f6a9bbb67bb2762ca4a345f4160d0f1d3fdc7047e257474920c09da9c5770c8a0e45a4748e59
7
- data.tar.gz: 2c6f92cdb1d61b49ed7b318bc5c2108649ad387e9af51c312ade43f1522f3cc842dc949dde7d0373d41dc25630bbf9e5a3f883fc9255423ecd6e19c9008b3a4f
6
+ metadata.gz: 8ec0bee468f8c34c001ea9151b0d78b1018d246cc86f9c2588a70ee55e3940b6263f69032884ec5c2dc596d3182909829f6ee63fe51b6c65444ce667bf70a6ca
7
+ data.tar.gz: 9b9f5337d0fc671639f9f651977cb2ecc60b2d27105cdce1b75a2a4188c093c111ba29ed880adccb515d548b8bd9209b4cf703482a0ede4ccea6b182212a3a57
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'jirametrics'
5
+ JiraMetrics.start(['mcp'] + ARGV)
@@ -15,7 +15,7 @@ class AgingWorkBarChart < ChartBase
15
15
  newest at the bottom.
16
16
  </p>
17
17
  <p>
18
- There are <%= current_board.scrum? ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
18
+ There are <%= (aggregated_project? || current_board.scrum?) ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
19
19
  <ol>
20
20
  <li>Status: The status the issue was in at any time. The colour indicates the
21
21
  status category, which will be one of #{color_block '--status-category-todo-color'} To Do,
@@ -25,7 +25,7 @@ class AgingWorkBarChart < ChartBase
25
25
  or #{color_block '--stalled-color'} stalled.</li>
26
26
  <li>Priority: This shows the priority over time. If one of these priorities is considered expedited
27
27
  then it will be drawn with diagonal lines.</li>
28
- <% if current_board.scrum? %>
28
+ <% if aggregated_project? || current_board.scrum? %>
29
29
  <li>Sprints: The sprints that the issue was in.</li>
30
30
  <% end %>
31
31
  </ol>
@@ -84,7 +84,7 @@ class AgingWorkBarChart < ChartBase
84
84
  ['blocked', collect_blocked_stalled_ranges(issue: issue, issue_start_time: issue_start_time)],
85
85
  ['priority', collect_priority_ranges(issue: issue)]
86
86
  ]
87
- bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if current_board.scrum?
87
+ bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if aggregated_project? || current_board.scrum?
88
88
 
89
89
  bar_data.each { |entry| clip_ranges_to_start_time(ranges: entry.last, issue_start_time: issue_start_time) }
90
90
 
@@ -113,7 +113,7 @@ class AgingWorkBarChart < ChartBase
113
113
  def grow_chart_height_if_too_many_issues aging_issue_count:
114
114
  px_per_bar = 10
115
115
  bars_per_issue = 3
116
- bars_per_issue += 1 if current_board.scrum?
116
+ bars_per_issue += 1 if aggregated_project? || current_board.scrum?
117
117
 
118
118
  preferred_height = aging_issue_count * px_per_bar * bars_per_issue
119
119
  @canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
@@ -235,10 +235,12 @@ class AgingWorkBarChart < ChartBase
235
235
  previous_change = change
236
236
  end
237
237
 
238
- results << create_range_for_priority(
239
- previous_change: previous_change, stop_time: time_range.end,
240
- expedited_priority_names: expedited_priority_names
241
- )
238
+ if previous_change
239
+ results << create_range_for_priority(
240
+ previous_change: previous_change, stop_time: time_range.end,
241
+ expedited_priority_names: expedited_priority_names
242
+ )
243
+ end
242
244
  results
243
245
  end
244
246
 
@@ -6,6 +6,7 @@ require 'jirametrics/board_movement_calculator'
6
6
 
7
7
  class AgingWorkInProgressChart < ChartBase
8
8
  include GroupableIssueChart
9
+
9
10
  attr_accessor :possible_statuses, :board_id
10
11
  attr_reader :board_columns
11
12
 
@@ -55,7 +56,7 @@ class AgingWorkInProgressChart < ChartBase
55
56
  def run
56
57
  determine_board_columns
57
58
 
58
- @header_text += " on board: #{@all_boards[@board_id].name}"
59
+ @header_text += " on board: #{current_board.name}"
59
60
  data_sets = make_data_sets
60
61
 
61
62
  adjust_visibility_of_unmapped_status_column data_sets: data_sets
@@ -76,7 +77,7 @@ class AgingWorkInProgressChart < ChartBase
76
77
 
77
78
  @fake_column = BoardColumn.new({
78
79
  'name' => '[Unmapped Statuses]',
79
- 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq # rubocop:disable Performance/ChainArrayAllocation
80
+ 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
80
81
  })
81
82
  @board_columns = columns + [@fake_column]
82
83
  end
@@ -114,14 +115,7 @@ class AgingWorkInProgressChart < ChartBase
114
115
 
115
116
  calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
116
117
 
117
- column_indexes_to_remove = []
118
- unless @show_all_columns
119
- column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
120
-
121
- column_indexes_to_remove.reverse_each do |index|
122
- @board_columns.delete_at index
123
- end
124
- end
118
+ column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
125
119
 
126
120
  @row_index_offset = data_sets.size
127
121
 
@@ -177,6 +171,44 @@ class AgingWorkInProgressChart < ChartBase
177
171
  result
178
172
  end
179
173
 
174
+ def trim_board_columns data_sets:, calculator:
175
+ return [] if @show_all_columns
176
+
177
+ columns_with_aging_items = data_sets.flat_map do |set|
178
+ set['data'].filter_map { |d| d['x'] if d.is_a? Hash }
179
+ end.uniq
180
+
181
+ # @fake_column is always the last element and is handled separately.
182
+ real_column_count = @board_columns.size - 1
183
+
184
+ # The last visible column always has artificially inflated age_data because
185
+ # ages_of_issues_when_leaving_column uses `today` as end_date when there is no
186
+ # next column. Exclude it from the right-boundary search so it is only kept when
187
+ # it has current aging items (handled by the last_aging fallback below).
188
+ age_data = calculator.age_data_for(percentage: 100)
189
+ last_data = (0...(real_column_count - 1)).to_a.reverse.find { |i| !age_data[i].zero? }
190
+
191
+ in_current = ->(i) { columns_with_aging_items.include?(@board_columns[i].name) }
192
+ first_aging = (0...real_column_count).find(&in_current)
193
+ last_aging = (0...real_column_count).to_a.reverse.find(&in_current)
194
+
195
+ # Combine: include any column with age_data (up to but not including the last visible
196
+ # column) and any column with current aging items.
197
+ first_data = (0...real_column_count).find { |i| !age_data[i].zero? }
198
+ left_bound = [first_data, first_aging].compact.min
199
+ right_bound = [last_data, last_aging].compact.max
200
+
201
+ indexes_to_remove =
202
+ if left_bound && right_bound
203
+ (0...left_bound).to_a + ((right_bound + 1)...real_column_count).to_a
204
+ else
205
+ (0...real_column_count).to_a
206
+ end
207
+
208
+ indexes_to_remove.reverse_each { |index| @board_columns.delete_at index }
209
+ indexes_to_remove
210
+ end
211
+
180
212
  def column_for issue:
181
213
  @board_columns.find do |board_column|
182
214
  board_column.status_ids.include? issue.status.id
@@ -192,7 +224,7 @@ class AgingWorkInProgressChart < ChartBase
192
224
  end
193
225
  end
194
226
 
195
- if has_unmapped
227
+ if has_unmapped && @description_text
196
228
  @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
197
229
  'board but are still active. Most likely everyone has forgotten about them.</p>'
198
230
  else
@@ -45,7 +45,9 @@ class AgingWorkTable < ChartBase
45
45
  # This is its own method simply so the tests can initialize the calculator without doing a full run.
46
46
  def initialize_calculator
47
47
  @today = date_range.end
48
- @calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
48
+ @calculators = @all_boards.transform_values do |board|
49
+ BoardMovementCalculator.new board: board, issues: issues, today: @today
50
+ end
49
51
  end
50
52
 
51
53
  def expedited_but_not_started
@@ -123,7 +125,8 @@ class AgingWorkTable < ChartBase
123
125
  due = issue.due_date
124
126
  message = nil
125
127
 
126
- days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
128
+ calculator = @calculators[issue.board.id]
129
+ days_remaining, error = calculator.forecasted_days_remaining_and_message issue: issue, today: @today
127
130
 
128
131
  unless error
129
132
  if due
@@ -72,7 +72,7 @@ class Board
72
72
  return true if board_type == 'scrum'
73
73
  return false unless board_type == 'simple'
74
74
 
75
- @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
75
+ has_sprints_feature?
76
76
  end
77
77
 
78
78
  def kanban?
@@ -82,6 +82,14 @@ class Board
82
82
  !scrum?
83
83
  end
84
84
 
85
+ def team_managed_kanban?
86
+ board_type == 'simple' && !has_sprints_feature?
87
+ end
88
+
89
+ def has_sprints_feature?
90
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
91
+ end
92
+
85
93
  def id
86
94
  @raw['id'].to_i
87
95
  end
@@ -31,12 +31,17 @@ class CfdDataBuilder
31
31
 
32
32
  # Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
33
33
  def process_issue issue, column_map
34
+ start_time = issue.started_stopped_times.first
35
+ return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
36
+
34
37
  high_water_mark = nil
35
38
  correction_open_since = nil
36
39
  correction_windows = []
37
40
  hwm_timeline = [] # sorted chronologically by date
38
41
 
39
42
  issue.status_changes.each do |change|
43
+ next if change.time < start_time
44
+
40
45
  col_index = column_map[change.value_id]
41
46
  next if col_index.nil?
42
47
 
@@ -1,6 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChartBase
4
+ # Okabe-Ito palette — perceptually distinct under the most common forms of colour blindness.
5
+ # Ordered from most- to least-commonly useful for chart series.
6
+ OKABE_ITO_PALETTE = %w[
7
+ #0072B2
8
+ #E69F00
9
+ #009E73
10
+ #56B4E9
11
+ #D55E00
12
+ #CC79A7
13
+ #F0E442
14
+ ].freeze
4
15
  attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
5
16
  :time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
6
17
  :atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
@@ -328,7 +339,8 @@ class ChartBase
328
339
  end
329
340
 
330
341
  def random_color
331
- "##{Random.bytes(3).unpack1('H*')}"
342
+ @palette_index = (@palette_index || -1) + 1
343
+ OKABE_ITO_PALETTE[@palette_index % OKABE_ITO_PALETTE.size]
332
344
  end
333
345
 
334
346
  def canvas width:, height:, responsive: true
@@ -377,7 +389,7 @@ class ChartBase
377
389
  end
378
390
 
379
391
  def seam_start type = 'chart'
380
- "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
392
+ "\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->\n"
381
393
  end
382
394
 
383
395
  def seam_end type = 'chart'
@@ -64,6 +64,14 @@ class CumulativeFlowDiagram < ChartBase
64
64
  <b>average throughput (TP)</b> (items completed per day). Use the checkbox above the chart to toggle
65
65
  between the triangle and the normal data tooltips.
66
66
  </div>
67
+ <div class="p">
68
+ CT and TP require a future point C where cumulative completions catch up to current arrivals.
69
+ When the cursor is near the right edge and that point falls outside the visible date range,
70
+ CT and TP cannot be calculated and are hidden; only WIP is shown.
71
+ </div>
72
+ <div class="p">
73
+ See also: This article on <a href="https://blog.mikebowler.ca/2026/03/27/cumulative-flow-diagram/">how to read a CFD</a>.
74
+ </div>
67
75
  HTML
68
76
  instance_eval(&block)
69
77
  end
@@ -35,6 +35,10 @@ class CycletimeScatterplot < TimeBasedScatterplot
35
35
  end
36
36
  end
37
37
 
38
+ def minimum_y_value
39
+ 1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
40
+ end
41
+
38
42
  def all_items
39
43
  completed_issues_in_range include_unstarted: false
40
44
  end
@@ -24,7 +24,7 @@ class DailyView < ChartBase
24
24
  def run
25
25
  aging_issues = select_aging_issues
26
26
 
27
- return "<h1 class='foldable'>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
27
+ return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>" if aging_issues.empty?
28
28
 
29
29
  result = +''
30
30
  result << render_top_text(binding)
@@ -87,13 +87,14 @@ class DailyView < ChartBase
87
87
  lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
88
88
  lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
89
89
  blocked_stalled.blocking_issue_keys&.each do |key|
90
- blocking_issue = issues.find { |i| i.key == key }
90
+ blocking_issue = issues.find_by_key key: key, include_hidden: true
91
91
  if blocking_issue
92
- lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: #{key}</div>"
92
+ lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
93
+ "#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
93
94
  lines << blocking_issue
94
95
  lines << '</section>'
95
96
  else
96
- lines << ["#{marker} Blocked by issue: #{key}"]
97
+ lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
97
98
  end
98
99
  end
99
100
  elsif blocked_stalled.stalled_by_status?
@@ -434,8 +434,10 @@ class DataQualityReport < ChartBase
434
434
  end
435
435
 
436
436
  def render_issue_not_visible_on_board problems
437
+ unique_issue_count = problems.map(&:first).uniq.size
437
438
  <<-HTML
438
- #{label_issues problems.size} were not visible on the board for some period of time. This may impact
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
439
441
  timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
440
442
  for two reasons: the issue was in a status that is not mapped to any visible column on the board
441
443
  (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
@@ -57,7 +57,7 @@ class DependencyChart < ChartBase
57
57
  end
58
58
 
59
59
  svg = execute_graphviz(dot_graph.join("\n"))
60
- "<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>"
61
61
  end
62
62
 
63
63
  def link_rules &block
@@ -74,6 +74,7 @@ class Downloader
74
74
  download_statuses
75
75
  find_board_ids.each do |id|
76
76
  board = download_board_configuration board_id: id
77
+ board.project_config = @download_config.project_config
77
78
  download_issues board: board
78
79
  end
79
80
  download_users
@@ -86,6 +87,23 @@ class Downloader
86
87
  @file_system.log text, also_write_to_stderr: both
87
88
  end
88
89
 
90
+ def log_start text
91
+ @file_system.log_start text
92
+ end
93
+
94
+ def start_progress
95
+ @file_system.start_progress
96
+ end
97
+
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
102
+
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
106
+
89
107
  def find_board_ids
90
108
  ids = @download_config.project_config.board_configs.collect(&:id)
91
109
  raise 'Board ids must be specified' if ids.empty?
@@ -232,13 +250,6 @@ class Downloader
232
250
  @metadata[key] = value
233
251
  end
234
252
 
235
- # If rolling_date_count has changed, we may be missing data outside the previous range,
236
- # so force a full re-download.
237
- if @metadata['rolling_date_count'] != @download_config.rolling_date_count
238
- log ' rolling_date_count has changed. Forcing a full download.', both: true
239
- @cached_data_format_is_current = false
240
- @metadata = {}
241
- end
242
253
  end
243
254
 
244
255
  # Even if this is the old format, we want to obey this one tag
@@ -53,6 +53,7 @@ class DownloaderForCloud < Downloader
53
53
  next_page_token = nil
54
54
  issue_count = 0
55
55
 
56
+ start_progress
56
57
  loop do
57
58
  relative_url = +''
58
59
  relative_url << '/rest/api/3/search/jql'
@@ -75,11 +76,12 @@ class DownloaderForCloud < Downloader
75
76
  issue_count += 1
76
77
  end
77
78
 
78
- message = " Found #{issue_count} issues"
79
- log message, both: true
79
+ progress_dot " Found #{issue_count} issues"
80
80
 
81
81
  break unless next_page_token
82
82
  end
83
+ end_progress
84
+
83
85
  hash
84
86
  end
85
87
 
@@ -88,7 +90,7 @@ class DownloaderForCloud < Downloader
88
90
  # that only returns the "recent" changes, not all of them. So now we get the issue
89
91
  # without changes and then make a second call for that changes. Then we insert it
90
92
  # into the raw issue as if it had been there all along.
91
- log " Downloading #{issue_datas.size} issues", both: true
93
+ log " Downloading #{issue_datas.size} issues"
92
94
  payload = {
93
95
  'fields' => ['*all'],
94
96
  'issueIdsOrKeys' => issue_datas.collect(&:key)
@@ -106,11 +108,24 @@ class DownloaderForCloud < Downloader
106
108
  }
107
109
  issue = Issue.new(raw: issue_json, board: board)
108
110
  data = issue_datas.find { |d| d.key == issue.key }
111
+ unless data
112
+ log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
113
+ next
114
+ end
109
115
  data.up_to_date = true
110
116
  data.last_modified = issue.updated
111
117
  data.issue = issue
112
118
  end
113
119
 
120
+ # Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
121
+ # This happens when Jira returns a different key (moved issue) leaving the original unmatched.
122
+ issue_datas.each do |data|
123
+ next if data.up_to_date
124
+
125
+ log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
126
+ data.up_to_date = true
127
+ end
128
+
114
129
  issue_datas
115
130
  end
116
131
 
@@ -166,16 +181,20 @@ class DownloaderForCloud < Downloader
166
181
 
167
182
  issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
168
183
 
184
+ checked_for_related = Set.new
185
+ in_related_phase = false
186
+
169
187
  loop do
170
188
  related_issue_keys = Set.new
171
- issue_data_hash
172
- .values
173
- .reject { |data| data.up_to_date }
174
- .each_slice(100) do |slice|
175
- slice = bulk_fetch_issues(
176
- issue_datas: slice, board: board, in_initial_query: true
177
- )
189
+ stale = issue_data_hash.values.reject { |data| data.up_to_date }
190
+ unless stale.empty?
191
+ log_start ' Downloading more issues ' unless in_related_phase
192
+ stale.each_slice(100) do |slice|
193
+ slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
194
+ progress_dot
178
195
  slice.each do |data|
196
+ next unless data.issue
197
+
179
198
  @file_system.save_json(
180
199
  json: data.issue.raw, filename: data.cache_path
181
200
  )
@@ -183,20 +202,25 @@ class DownloaderForCloud < Downloader
183
202
  # to parse the file just to find the timestamp
184
203
  @file_system.utime time: data.issue.updated, file: data.cache_path
185
204
 
186
- issue = data.issue
187
- next unless issue
188
-
189
- parent_key = issue.parent_key(project_config: @download_config.project_config)
190
- related_issue_keys << parent_key if parent_key
191
-
192
- # Sub-tasks
193
- issue.raw['fields']['subtasks']&.each do |raw_subtask|
194
- related_issue_keys << raw_subtask['key']
195
- end
205
+ collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
206
+ checked_for_related << data.key
196
207
  end
197
208
  end
209
+ end_progress unless in_related_phase
210
+ end
211
+
212
+ # Also scan up-to-date cached issues we haven't checked yet — they may reference
213
+ # related issues that are not in the primary query result.
214
+ issue_data_hash.each_value do |data|
215
+ next if checked_for_related.include?(data.key)
216
+ next unless @file_system.file_exist?(data.cache_path)
198
217
 
199
- # Remove all the ones we already downloaded
218
+ checked_for_related << data.key
219
+ raw = @file_system.load_json(data.cache_path)
220
+ collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
221
+ end
222
+
223
+ # Remove all the ones we already have
200
224
  related_issue_keys.reject! { |key| issue_data_hash[key] }
201
225
 
202
226
  related_issue_keys.each do |key|
@@ -208,9 +232,15 @@ class DownloaderForCloud < Downloader
208
232
  end
209
233
  break if related_issue_keys.empty?
210
234
 
211
- log " Downloading linked issues for board #{board.id}", both: true
235
+ unless in_related_phase
236
+ in_related_phase = true
237
+ log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
238
+ log_start ' Downloading more issues '
239
+ end
212
240
  end
213
241
 
242
+ end_progress if in_related_phase
243
+
214
244
  delete_issues_from_cache_that_are_not_in_server(
215
245
  issue_data_hash: issue_data_hash, path: path
216
246
  )
@@ -235,6 +265,22 @@ class DownloaderForCloud < Downloader
235
265
  end
236
266
  end
237
267
 
268
+ def collect_related_issue_keys issue:, related_issue_keys:
269
+ parent_key = issue.parent_key(project_config: @download_config.project_config)
270
+ related_issue_keys << parent_key if parent_key
271
+
272
+ issue.raw['fields']['subtasks']&.each do |raw_subtask|
273
+ related_issue_keys << raw_subtask['key']
274
+ end
275
+
276
+ issue.raw['fields']['issuelinks']&.each do |link|
277
+ next if link['type']['name'] == 'Cloners'
278
+
279
+ linked = link['inwardIssue'] || link['outwardIssue']
280
+ related_issue_keys << linked['key'] if linked
281
+ end
282
+ end
283
+
238
284
  def last_modified filename:
239
285
  File.mtime(filename) if File.exist?(filename)
240
286
  end
@@ -25,7 +25,7 @@ class DownloaderForDataCenter < Downloader
25
25
  keys_to_request = @issue_keys_pending_download[0..99]
26
26
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
27
  jql = "key in (#{keys_to_request.join(', ')})"
28
- jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
28
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
29
29
  end
30
30
  end
31
31
 
@@ -10,7 +10,7 @@
10
10
  class Exporter
11
11
  def aggregated_project name:, project_names:, settings: {}
12
12
  project name: name do
13
- puts name
13
+ file_system.log name
14
14
  file_prefix name
15
15
  self.settings.merge! stringify_keys(settings)
16
16
 
@@ -9,7 +9,7 @@ class Exporter
9
9
  show_experimental_charts: false, github_repos: nil
10
10
  exporter = self
11
11
  project name: name do
12
- puts name
12
+ file_system.log name, also_write_to_stderr: true
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
@@ -35,7 +35,7 @@ class Exporter
35
35
  download do
36
36
  self.rolling_date_count(rolling_date_count) if rolling_date_count
37
37
  self.no_earlier_than(no_earlier_than) if no_earlier_than
38
- github_repo github_repos if github_repos
38
+ github_repo *github_repos if github_repos
39
39
  end
40
40
 
41
41
  issues.reject! do |issue|
@@ -82,6 +82,9 @@ class Exporter
82
82
  end
83
83
 
84
84
  aging_work_in_progress_chart
85
+ wip_by_column_chart do
86
+ show_recommendations
87
+ end
85
88
  aging_work_bar_chart
86
89
  aging_work_table
87
90
  daily_wip_by_age_chart
@@ -8,7 +8,13 @@ class Exporter
8
8
 
9
9
  def self.configure &block
10
10
  logfile_name = 'jirametrics.log'
11
- logfile = File.open logfile_name, 'w'
11
+ logfile = File.open(logfile_name, 'w')
12
+ rescue Errno::EACCES
13
+ # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
+ warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
+ 'Please ensure the current directory is writable.'
16
+ exit 1
17
+ else
12
18
  file_system = FileSystem.new
13
19
  file_system.logfile = logfile
14
20
  file_system.logfile_name = logfile_name
@@ -67,18 +73,23 @@ class Exporter
67
73
 
68
74
  def info key, name_filter:
69
75
  selected = []
76
+ file_system.log_only = true
70
77
  each_project_config(name_filter: name_filter) do |project|
71
78
  project.evaluate_next_level
72
79
 
73
80
  project.run load_only: true
74
81
  project.issues.each do |issue|
75
82
  selected << [project, issue] if key == issue.key
83
+ issue.subtasks.each do |subtask|
84
+ selected << [project, subtask] if key == subtask.key
85
+ end
76
86
  end
77
87
  rescue => e # rubocop:disable Style/RescueStandardError
78
88
  # This happens when we're attempting to load an aggregated project because it hasn't been
79
89
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
80
90
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
81
91
  end
92
+ file_system.log_only = false
82
93
 
83
94
  if selected.empty?
84
95
  file_system.log "No issues found to match #{key.inspect}"