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.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +62 -17
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +63 -11
- data/lib/jirametrics/board_config.rb +5 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +49 -19
- data/lib/jirametrics/chart_base.rb +147 -7
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +28 -8
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +12 -3
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +323 -63
- data/lib/jirametrics/html/index.erb +17 -19
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +347 -103
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
126
|
+
label: display_label,
|
|
127
|
+
label_hint: grouping_rule.label_hint,
|
|
106
128
|
data: data,
|
|
107
|
-
backgroundColor:
|
|
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(:
|
|
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.
|
|
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: :
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
436
|
+
def render_issue_not_visible_on_board problems
|
|
437
|
+
unique_issue_count = problems.map(&:first).uniq.size
|
|
411
438
|
<<-HTML
|
|
412
|
-
#{
|
|
413
|
-
|
|
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
|
|
420
|
-
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
69
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
117
|
-
|
|
103
|
+
def end_progress
|
|
104
|
+
@file_system.end_progress
|
|
105
|
+
end
|
|
118
106
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|