jirametrics 2.10 → 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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +62 -17
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +63 -11
  12. data/lib/jirametrics/board_config.rb +5 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +49 -19
  17. data/lib/jirametrics/chart_base.rb +147 -7
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +128 -71
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  37. data/lib/jirametrics/examples/standard_project.rb +42 -27
  38. data/lib/jirametrics/expedited_chart.rb +3 -1
  39. data/lib/jirametrics/exporter.rb +28 -8
  40. data/lib/jirametrics/file_config.rb +10 -12
  41. data/lib/jirametrics/file_system.rb +59 -3
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +12 -3
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  56. data/lib/jirametrics/html/index.css +323 -63
  57. data/lib/jirametrics/html/index.erb +17 -19
  58. data/lib/jirametrics/html/index.js +164 -0
  59. data/lib/jirametrics/html/legacy_colors.css +174 -0
  60. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  61. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  62. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  63. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  64. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  65. data/lib/jirametrics/html_generator.rb +32 -0
  66. data/lib/jirametrics/html_report_config.rb +52 -55
  67. data/lib/jirametrics/issue.rb +347 -103
  68. data/lib/jirametrics/issue_collection.rb +33 -0
  69. data/lib/jirametrics/issue_printer.rb +97 -0
  70. data/lib/jirametrics/jira_gateway.rb +81 -14
  71. data/lib/jirametrics/mcp_server.rb +531 -0
  72. data/lib/jirametrics/project_config.rb +151 -18
  73. data/lib/jirametrics/pull_request.rb +30 -0
  74. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  75. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  76. data/lib/jirametrics/pull_request_review.rb +13 -0
  77. data/lib/jirametrics/raw_javascript.rb +17 -0
  78. data/lib/jirametrics/settings.json +6 -1
  79. data/lib/jirametrics/sprint.rb +13 -0
  80. data/lib/jirametrics/sprint_burndown.rb +45 -37
  81. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  82. data/lib/jirametrics/status.rb +3 -0
  83. data/lib/jirametrics/status_collection.rb +7 -0
  84. data/lib/jirametrics/stitcher.rb +81 -0
  85. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  86. data/lib/jirametrics/throughput_chart.rb +73 -23
  87. data/lib/jirametrics/time_based_histogram.rb +139 -0
  88. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  89. data/lib/jirametrics/user.rb +12 -0
  90. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  91. data/lib/jirametrics.rb +83 -64
  92. metadata +66 -6
@@ -3,12 +3,16 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class DailyGroupingRules < GroupingRules
6
- attr_accessor :current_date, :group_priority, :issue_hint
6
+ attr_accessor :current_date, :group_priority, :issue_hint, :highlight
7
7
 
8
8
  def initialize
9
9
  super
10
10
  @group_priority = 0
11
11
  end
12
+
13
+ def group
14
+ [@label, @color, @highlight ? true : false]
15
+ end
12
16
  end
13
17
 
14
18
  class DailyWipChart < ChartBase
@@ -19,6 +23,8 @@ class DailyWipChart < ChartBase
19
23
 
20
24
  header_text default_header_text
21
25
  description_text default_description_text
26
+ @x_axis_title = nil
27
+ @y_axis_title = 'Count of items'
22
28
 
23
29
  instance_eval(&block) if block
24
30
 
@@ -33,8 +39,15 @@ class DailyWipChart < ChartBase
33
39
  issue_rules_by_active_date = group_issues_by_active_dates
34
40
  possible_rules = select_possible_rules issue_rules_by_active_date
35
41
 
42
+ conflicting_labels = possible_rules
43
+ .group_by(&:label)
44
+ .select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
45
+ .keys
46
+
36
47
  data_sets = possible_rules.collect do |grouping_rule|
37
- make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date
48
+ suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
49
+ make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
50
+ label_suffix: suffix
38
51
  end
39
52
  if @trend_lines
40
53
  data_sets = @trend_lines.filter_map do |group_labels, line_color|
@@ -66,7 +79,7 @@ class DailyWipChart < ChartBase
66
79
  hash = {}
67
80
 
68
81
  @issues.each do |issue|
69
- start, stop = issue.board.cycletime.started_stopped_dates(issue)
82
+ start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
70
83
  next if start.nil? && stop.nil?
71
84
 
72
85
  # If it stopped but never started then assume it started at creation so the data points
@@ -82,16 +95,17 @@ class DailyWipChart < ChartBase
82
95
  hash
83
96
  end
84
97
 
85
- def make_data_set grouping_rule:, issue_rules_by_active_date:
98
+ def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
86
99
  positive = grouping_rule.group_priority >= 0
100
+ display_label = "#{grouping_rule.label}#{label_suffix}"
87
101
 
88
102
  data = issue_rules_by_active_date.collect do |date, issue_rules|
89
- # issues = []
90
103
  issue_strings = issue_rules
91
104
  .select { |_issue, rules| rules.group == grouping_rule.group }
92
105
  .sort_by { |issue, _rules| issue.key_as_i }
93
106
  .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
94
- title = ["#{grouping_rule.label} (#{label_issues issue_strings.size})"] + issue_strings
107
+ title_label = grouping_rule.label_hint || display_label
108
+ title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
95
109
 
96
110
  {
97
111
  x: date,
@@ -100,11 +114,19 @@ class DailyWipChart < ChartBase
100
114
  }
101
115
  end
102
116
 
117
+ color = grouping_rule.color || random_color
118
+ background_color = if grouping_rule.highlight
119
+ RawJavascript.new("createDiagonalPattern(#{color.to_json})")
120
+ else
121
+ color
122
+ end
123
+
103
124
  {
104
125
  type: 'bar',
105
- label: grouping_rule.label,
126
+ label: display_label,
127
+ label_hint: grouping_rule.label_hint,
106
128
  data: data,
107
- backgroundColor: grouping_rule.color || random_color,
129
+ backgroundColor: background_color,
108
130
  borderColor: CssVariable['--wip-chart-border-color'],
109
131
  borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
110
132
  borderRadius: positive ? 0 : 5
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
45
45
  scan_for_completed_issues_without_a_start_time entry: entry
46
46
  scan_for_status_change_after_done entry: entry
47
47
  scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
48
+ scan_for_issue_not_in_active_sprint entry: entry
48
49
  scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
49
50
  scan_for_stopped_before_started entry: entry
50
51
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
68
69
  result << render_problem_type(:status_changes_after_done)
69
70
  result << render_problem_type(:backwards_through_status_categories)
70
71
  result << render_problem_type(:backwords_through_statuses)
71
- result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:issue_not_visible_on_board)
72
73
  result << render_problem_type(:created_in_wrong_status)
73
74
  result << render_problem_type(:stopped_before_started)
74
75
  result << render_problem_type(:issue_not_started_but_subtasks_have)
@@ -120,7 +121,7 @@ class DataQualityReport < ChartBase
120
121
 
121
122
  def initialize_entries
122
123
  @entries = @issues.filter_map do |issue|
123
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
124
+ started, stopped = issue.started_stopped_times
124
125
  next if stopped && stopped < time_range.begin
125
126
  next if started && started > time_range.end
126
127
 
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
194
195
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
195
196
  detail = nil if backlog_statuses.any? { |s| s.name == change.value }
196
197
 
197
- entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
198
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
198
199
  elsif change.old_value.nil?
199
200
  # Do nothing
200
201
  elsif index < last_index
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
223
224
  end
224
225
  end
225
226
 
227
+ def scan_for_issue_not_in_active_sprint entry:
228
+ issue = entry.issue
229
+ return unless issue.board.scrum?
230
+ return if issue.sprints.any?(&:active?)
231
+
232
+ entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
233
+ end
234
+
235
+ def scan_for_issue_never_visible_on_board entry:
236
+ issue = entry.issue
237
+ ever_visible = issue.changes.any? do |change|
238
+ next unless change.status?
239
+
240
+ issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
241
+ end
242
+ return if ever_visible
243
+
244
+ entry.report(
245
+ problem_key: :issue_not_visible_on_board,
246
+ detail: 'Issue has never been in a status mapped to a visible column on the board'
247
+ )
248
+ end
249
+
226
250
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
251
  creation_change = entry.issue.changes.find { |issue| issue.status? }
228
252
 
@@ -250,7 +274,7 @@ class DataQualityReport < ChartBase
250
274
 
251
275
  started_subtasks = []
252
276
  entry.issue.subtasks.each do |subtask|
253
- started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
277
+ started_subtasks << subtask if subtask.started_stopped_times.first
254
278
  end
255
279
 
256
280
  return if started_subtasks.empty?
@@ -266,8 +290,10 @@ class DataQualityReport < ChartBase
266
290
 
267
291
  def scan_for_items_blocked_on_closed_tickets entry:
268
292
  entry.issue.issue_links.each do |link|
293
+ next unless settings['blocked_link_text'].include?(link.label)
294
+
269
295
  this_active = !entry.stopped
270
- other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
296
+ other_active = !link.other_issue.started_stopped_times.last
271
297
  next unless this_active && !other_active
272
298
 
273
299
  entry.report(
@@ -293,14 +319,14 @@ class DataQualityReport < ChartBase
293
319
  return "#{delta} hours" if delta < 24
294
320
 
295
321
  delta /= 24
296
- "#{delta} days"
322
+ label_days delta
297
323
  end
298
324
 
299
325
  def scan_for_incomplete_subtasks_when_issue_done entry:
300
326
  return unless entry.stopped
301
327
 
302
328
  subtask_labels = entry.issue.subtasks.filter_map do |subtask|
303
- subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
329
+ subtask_started, subtask_stopped = subtask.started_stopped_times
304
330
 
305
331
  if !subtask_started && !subtask_stopped
306
332
  "#{subtask_label subtask} (Not even started)"
@@ -407,17 +433,22 @@ class DataQualityReport < ChartBase
407
433
  HTML
408
434
  end
409
435
 
410
- def render_status_not_on_board problems
436
+ def render_issue_not_visible_on_board problems
437
+ unique_issue_count = problems.map(&:first).uniq.size
411
438
  <<-HTML
412
- #{label_issues problems.size} were not visible on the board for some period of time. This may impact
413
- timings as the work was likely to have been forgotten if it wasn't visible.
439
+ #{problems.size} #{'time'.then { |w| problems.size == 1 ? w : "#{w}s" }} across #{label_issues unique_issue_count},
440
+ an item was not visible on the board. This may impact
441
+ timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
442
+ for two reasons: the issue was in a status that is not mapped to any visible column on the board
443
+ (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
414
444
  HTML
415
445
  end
416
446
 
417
447
  def render_created_in_wrong_status problems
418
448
  <<-HTML
419
- #{label_issues problems.size} were created in a status not designated as Backlog. This will impact
420
- the measurement of start times and will therefore impact whether it's shown as in progress or not.
449
+ #{label_issues problems.size} were created in a status that is not considered to be some varient
450
+ of To Do. Most likely this means that the issue was created from one of the columns on the board,
451
+ rather than in the backlog. Why Jira allows this is still a mystery.
421
452
  HTML
422
453
  end
423
454
 
@@ -51,10 +51,13 @@ class DependencyChart < ChartBase
51
51
  instance_eval(&@rules_block) if @rules_block
52
52
 
53
53
  dot_graph = build_dot_graph
54
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
54
+ if dot_graph.nil?
55
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
56
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
57
+ end
55
58
 
56
59
  svg = execute_graphviz(dot_graph.join("\n"))
57
- "<h1>#{@header_text}</h1><div>#{@description_text}</div>#{shrink_svg svg}"
60
+ "<h1 class='foldable'>#{@header_text}</h1><div>#{@description_text}#{shrink_svg svg}</div>"
58
61
  end
59
62
 
60
63
  def link_rules &block
@@ -228,7 +231,7 @@ class DependencyChart < ChartBase
228
231
  elsif is_done
229
232
  line2 << 'Done'
230
233
  else
231
- started_at = issue.board.cycletime.started_stopped_times(issue).first
234
+ started_at = issue.started_stopped_times.first
232
235
  if started_at.nil?
233
236
  line2 << 'Not started'
234
237
  else
@@ -25,10 +25,25 @@ class DownloadConfig
25
25
  @no_earlier_than
26
26
  end
27
27
 
28
+ def github_repos
29
+ @github_repos ||= []
30
+ end
31
+
32
+ def github_repo *repos
33
+ github_repos.concat(repos.map { |r| normalize_github_repo(r) })
34
+ end
35
+
28
36
  def start_date today:
29
37
  date = today.to_date - @rolling_date_count if @rolling_date_count
30
38
  date = [date, @no_earlier_than].max if date && @no_earlier_than
31
39
  date = @no_earlier_than if date.nil? && @no_earlier_than
32
40
  date
33
41
  end
42
+
43
+ private
44
+
45
+ def normalize_github_repo repo
46
+ match = repo.match(%r{github\.com/([^/]+/[^/]+?)/?$})
47
+ match ? match[1] : repo
48
+ end
34
49
  end
@@ -3,8 +3,29 @@
3
3
  require 'cgi'
4
4
  require 'json'
5
5
 
6
+ class DownloadIssueData
7
+ attr_accessor :key, :found_in_primary_query, :last_modified,
8
+ :up_to_date, :cache_path, :issue
9
+
10
+ def initialize(
11
+ key:,
12
+ found_in_primary_query: true,
13
+ last_modified: nil,
14
+ up_to_date: true,
15
+ cache_path: nil,
16
+ issue: nil
17
+ )
18
+ @key = key
19
+ @found_in_primary_query = found_in_primary_query
20
+ @last_modified = last_modified
21
+ @up_to_date = up_to_date
22
+ @cache_path = cache_path
23
+ @issue = issue
24
+ end
25
+ end
26
+
6
27
  class Downloader
7
- CURRENT_METADATA_VERSION = 4
28
+ CURRENT_METADATA_VERSION = 5
8
29
 
9
30
  attr_accessor :metadata
10
31
  attr_reader :file_system
@@ -12,12 +33,23 @@ class Downloader
12
33
  # For testing only
13
34
  attr_reader :start_date_in_query, :board_id_to_filter_id
14
35
 
15
- def initialize download_config:, file_system:, jira_gateway:
36
+ def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
37
+ is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
38
+ (is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
39
+ download_config: download_config,
40
+ file_system: file_system,
41
+ jira_gateway: jira_gateway,
42
+ github_pr_cache: github_pr_cache
43
+ )
44
+ end
45
+
46
+ def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
16
47
  @metadata = {}
17
48
  @download_config = download_config
18
49
  @target_path = @download_config.project_config.target_path
19
50
  @file_system = file_system
20
51
  @jira_gateway = jira_gateway
52
+ @github_pr_cache = github_pr_cache
21
53
  @board_id_to_filter_id = {}
22
54
 
23
55
  @issue_keys_downloaded_in_current_run = []
@@ -28,7 +60,6 @@ class Downloader
28
60
  log '', both: true
29
61
  log @download_config.project_config.name, both: true
30
62
 
31
- init_gateway
32
63
  load_metadata
33
64
 
34
65
  if @metadata['no-download']
@@ -43,84 +74,41 @@ class Downloader
43
74
  download_statuses
44
75
  find_board_ids.each do |id|
45
76
  board = download_board_configuration board_id: id
77
+ board.project_config = @download_config.project_config
46
78
  download_issues board: board
47
79
  end
80
+ download_users
48
81
 
49
82
  save_metadata
50
- end
51
-
52
- def init_gateway
53
- @jira_gateway.load_jira_config(@download_config.project_config.jira_config)
54
- @jira_gateway.ignore_ssl_errors = @download_config.project_config.settings['ignore_ssl_errors']
83
+ download_github_prs if @download_config.github_repos.any?
55
84
  end
56
85
 
57
86
  def log text, both: false
58
87
  @file_system.log text, also_write_to_stderr: both
59
88
  end
60
89
 
61
- def find_board_ids
62
- ids = @download_config.project_config.board_configs.collect(&:id)
63
- raise 'Board ids must be specified' if ids.empty?
64
-
65
- ids
90
+ def log_start text
91
+ @file_system.log_start text
66
92
  end
67
93
 
68
- def download_issues board:
69
- log " Downloading primary issues for board #{board.id}", both: true
70
- path = File.join(@target_path, "#{file_prefix}_issues/")
71
- unless Dir.exist?(path)
72
- log " Creating path #{path}"
73
- Dir.mkdir(path)
74
- end
75
-
76
- filter_id = @board_id_to_filter_id[board.id]
77
- jql = make_jql(filter_id: filter_id)
78
- jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
79
-
80
- log " Downloading linked issues for board #{board.id}", both: true
81
- loop do
82
- @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
83
- break if @issue_keys_pending_download.empty?
84
-
85
- keys_to_request = @issue_keys_pending_download[0..99]
86
- @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
87
- jql = "key in (#{keys_to_request.join(', ')})"
88
- jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
89
- end
94
+ def start_progress
95
+ @file_system.start_progress
90
96
  end
91
97
 
92
- def jira_search_by_jql jql:, initial_query:, board:, path:
93
- intercept_jql = @download_config.project_config.settings['intercept_jql']
94
- jql = intercept_jql.call jql if intercept_jql
95
-
96
- log " JQL: #{jql}"
97
- escaped_jql = CGI.escape jql
98
-
99
- max_results = 100
100
- start_at = 0
101
- total = 1
102
- while start_at < total
103
- json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
104
- "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
105
-
106
- json['issues'].each do |issue_json|
107
- issue_json['exporter'] = {
108
- 'in_initial_query' => initial_query
109
- }
110
- identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
111
- file = "#{issue_json['key']}-#{board.id}.json"
112
-
113
- @file_system.save_json(json: issue_json, filename: File.join(path, file))
114
- end
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
115
102
 
116
- total = json['total'].to_i
117
- max_results = json['maxResults']
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
118
106
 
119
- message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
120
- log message, both: true
107
+ def find_board_ids
108
+ ids = @download_config.project_config.board_configs.collect(&:id)
109
+ raise 'Board ids must be specified' if ids.empty?
121
110
 
122
- start_at += json['issues'].size
123
- end
111
+ ids
124
112
  end
125
113
 
126
114
  def identify_other_issues_to_be_downloaded raw_issue:, board:
@@ -147,6 +135,18 @@ class Downloader
147
135
  )
148
136
  end
149
137
 
138
+ def download_users
139
+ return unless @jira_gateway.cloud?
140
+
141
+ log ' Downloading all users', both: true
142
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
143
+
144
+ @file_system.save_json(
145
+ json: json,
146
+ filename: File.join(@target_path, "#{file_prefix}_users.json")
147
+ )
148
+ end
149
+
150
150
  def update_status_history_file
151
151
  status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
152
  return unless file_system.file_exist? status_filename
@@ -186,11 +186,28 @@ class Downloader
186
186
  # actually look at the returned json.
187
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
188
188
 
189
+ if json['type'] == 'simple'
190
+ features_json = download_features board_id: board_id
191
+ if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
192
+ download_sprints board_id: board_id
193
+ end
194
+ end
189
195
  download_sprints board_id: board_id if json['type'] == 'scrum'
190
196
  # TODO: Should be passing actual statuses, not empty list
191
197
  Board.new raw: json, possible_statuses: StatusCollection.new
192
198
  end
193
199
 
200
+ def download_features board_id:
201
+ log " Downloading features for board #{board_id}", both: true
202
+ json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
203
+
204
+ @file_system.save_json(
205
+ json: json,
206
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
207
+ )
208
+ json
209
+ end
210
+
194
211
  def download_sprints board_id:
195
212
  log " Downloading sprints for board #{board_id}", both: true
196
213
  max_results = 100
@@ -232,19 +249,29 @@ class Downloader
232
249
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
233
250
  @metadata[key] = value
234
251
  end
252
+
235
253
  end
236
254
 
237
255
  # Even if this is the old format, we want to obey this one tag
238
256
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
239
257
  end
240
258
 
259
+ def timezone_offset
260
+ @download_config.project_config.exporter.timezone_offset
261
+ end
262
+
263
+ def today_in_project_timezone
264
+ Time.now.getlocal(timezone_offset).to_date
265
+ end
266
+
241
267
  def save_metadata
242
268
  @metadata['version'] = CURRENT_METADATA_VERSION
269
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
243
270
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
244
271
 
245
272
  if @download_date_range.nil?
246
273
  log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
247
- today = Date.today
274
+ today = today_in_project_timezone
248
275
  @download_date_range = (today - 7)..today
249
276
  end
250
277
 
@@ -279,7 +306,8 @@ class Downloader
279
306
  end
280
307
  end
281
308
 
282
- def make_jql filter_id:, today: Date.today
309
+ def make_jql filter_id:, today: nil
310
+ today ||= today_in_project_timezone
283
311
  segments = []
284
312
  segments << "filter=#{filter_id}"
285
313
 
@@ -287,11 +315,7 @@ class Downloader
287
315
 
288
316
  if start_date
289
317
  @download_date_range = start_date..today.to_date
290
-
291
- # For an incremental download, we want to query from the end of the previous one, not from the
292
- # beginning of the full range.
293
- @start_date_in_query = metadata['date_end'] || @download_date_range.begin
294
- log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
318
+ @start_date_in_query = @download_date_range.begin
295
319
 
296
320
  # Catch-all to pick up anything that's been around since before the range started but hasn't
297
321
  # had an update during the range.
@@ -308,6 +332,39 @@ class Downloader
308
332
  segments.join ' AND '
309
333
  end
310
334
 
335
+ def download_github_prs
336
+ project_keys = extract_project_keys_from_downloaded_issues
337
+ if project_keys.empty?
338
+ log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
339
+ return
340
+ end
341
+
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)
349
+ end
350
+
351
+ @file_system.save_json(
352
+ json: prs.map(&:raw),
353
+ filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
354
+ )
355
+ end
356
+
357
+ def extract_project_keys_from_downloaded_issues
358
+ path = File.join(@target_path, "#{file_prefix}_issues")
359
+ return [] unless @file_system.dir_exist?(path)
360
+
361
+ keys = []
362
+ @file_system.foreach(path) do |filename|
363
+ keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
364
+ end
365
+ keys.uniq
366
+ end
367
+
311
368
  def file_prefix
312
369
  @download_config.project_config.get_file_prefix
313
370
  end