jirametrics 2.14 → 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 +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +96 -96
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +139 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +42 -31
- 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 +117 -100
- 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 +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- 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 +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- 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 +244 -69
- data/lib/jirametrics/html/index.erb +9 -35
- 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 -57
- data/lib/jirametrics/issue.rb +302 -98
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +108 -9
- 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 +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForDataCenter < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira DataCenter'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def download_issues board:
|
|
9
|
+
log " Downloading primary issues for board #{board.id}", both: true
|
|
10
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
11
|
+
unless Dir.exist?(path)
|
|
12
|
+
log " Creating path #{path}"
|
|
13
|
+
Dir.mkdir(path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
filter_id = board_id_to_filter_id[board.id]
|
|
17
|
+
jql = make_jql(filter_id: filter_id)
|
|
18
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
19
|
+
|
|
20
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
21
|
+
loop do
|
|
22
|
+
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
23
|
+
break if @issue_keys_pending_download.empty?
|
|
24
|
+
|
|
25
|
+
keys_to_request = @issue_keys_pending_download[0..99]
|
|
26
|
+
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
27
|
+
jql = "key in (#{keys_to_request.join(', ')})"
|
|
28
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
33
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
34
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
35
|
+
|
|
36
|
+
log " JQL: #{jql}"
|
|
37
|
+
escaped_jql = CGI.escape jql
|
|
38
|
+
|
|
39
|
+
max_results = 100
|
|
40
|
+
start_at = 0
|
|
41
|
+
total = 1
|
|
42
|
+
while start_at < total
|
|
43
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
44
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
45
|
+
|
|
46
|
+
json['issues'].each do |issue_json|
|
|
47
|
+
issue_json['exporter'] = {
|
|
48
|
+
'in_initial_query' => initial_query
|
|
49
|
+
}
|
|
50
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
51
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
52
|
+
|
|
53
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
total = json['total'].to_i
|
|
57
|
+
max_results = json['maxResults']
|
|
58
|
+
|
|
59
|
+
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
60
|
+
log message, both: true
|
|
61
|
+
|
|
62
|
+
start_at += json['issues'].size
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def make_jql filter_id:, today: nil
|
|
67
|
+
today ||= today_in_project_timezone
|
|
68
|
+
segments = []
|
|
69
|
+
segments << "filter=#{filter_id}"
|
|
70
|
+
|
|
71
|
+
start_date = @download_config.start_date today: today
|
|
72
|
+
|
|
73
|
+
if start_date
|
|
74
|
+
@download_date_range = start_date..today.to_date
|
|
75
|
+
|
|
76
|
+
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
77
|
+
# beginning of the full range.
|
|
78
|
+
@start_date_in_query = metadata['date_end'] || @download_date_range.begin
|
|
79
|
+
log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
|
|
80
|
+
|
|
81
|
+
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
82
|
+
# had an update during the range.
|
|
83
|
+
catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
|
|
84
|
+
|
|
85
|
+
# Pick up any issues that had a status change in the range
|
|
86
|
+
start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
|
|
87
|
+
# find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
|
|
88
|
+
find_in_range = %(updated >= "#{start_date_text} 00:00")
|
|
89
|
+
|
|
90
|
+
segments << "(#{find_in_range} OR #{catch_all})"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
segments.join ' AND '
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -5,7 +5,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
5
5
|
super()
|
|
6
6
|
|
|
7
7
|
header_text 'Estimate Accuracy'
|
|
8
|
-
description_text
|
|
8
|
+
description_text <<~HTML
|
|
9
9
|
<div class="p">
|
|
10
10
|
This chart graphs estimates against actual recorded cycle times. Since
|
|
11
11
|
estimates can change over time, we're graphing the estimate at the time that the story started.
|
|
@@ -20,8 +20,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
20
20
|
far to the right then you know you have a problem.
|
|
21
21
|
<% end %>
|
|
22
22
|
</div>
|
|
23
|
+
<% if @correlation_coefficient %>
|
|
24
|
+
<div class="p">
|
|
25
|
+
The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
|
|
26
|
+
The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
|
|
27
|
+
the stronger the negative collalation. Zero would mean no correlation at all.
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
23
30
|
HTML
|
|
24
31
|
|
|
32
|
+
@x_axis_title = 'Cycletime (days)'
|
|
33
|
+
@y_axis_title = 'Estimate'
|
|
34
|
+
|
|
25
35
|
@y_axis_type = 'linear'
|
|
26
36
|
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
27
37
|
@y_axis_sort_order = nil
|
|
@@ -30,9 +40,9 @@ class EstimateAccuracyChart < ChartBase
|
|
|
30
40
|
end
|
|
31
41
|
|
|
32
42
|
def run
|
|
33
|
-
if @
|
|
43
|
+
if @y_axis_title.nil?
|
|
34
44
|
text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
|
|
35
|
-
@
|
|
45
|
+
@y_axis_title = "Estimated #{text}"
|
|
36
46
|
end
|
|
37
47
|
data_sets = scan_issues
|
|
38
48
|
|
|
@@ -43,7 +53,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
43
53
|
|
|
44
54
|
def scan_issues
|
|
45
55
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
46
|
-
|
|
56
|
+
@correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
|
|
47
57
|
estimation_units = current_board.estimation_configuration.units
|
|
48
58
|
@has_aging_data = !aging_hash.empty?
|
|
49
59
|
|
|
@@ -170,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
|
|
|
170
180
|
end
|
|
171
181
|
@y_axis_block = block
|
|
172
182
|
end
|
|
183
|
+
|
|
184
|
+
# Correlation coefficient is calculated using the Pearson Correlation Coefficient
|
|
185
|
+
# r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
|
|
186
|
+
def correlation_coefficient completed_hash
|
|
187
|
+
list1 = []
|
|
188
|
+
list2 = []
|
|
189
|
+
completed_hash.each do |(estimate, cycle_time), issues|
|
|
190
|
+
issues.size.times do
|
|
191
|
+
list1 << estimate
|
|
192
|
+
list2 << cycle_time
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
n = list1.size
|
|
197
|
+
return nil if n < 2
|
|
198
|
+
|
|
199
|
+
mean1 = list1.sum.to_f / n
|
|
200
|
+
mean2 = list2.sum.to_f / n
|
|
201
|
+
|
|
202
|
+
numerator = list1.zip(list2).sum { |x, y| (x - mean1) * (y - mean2) }
|
|
203
|
+
sum_sq1 = list1.sum { |x| (x - mean1)**2 }
|
|
204
|
+
sum_sq2 = list2.sum { |y| (y - mean2)**2 }
|
|
205
|
+
|
|
206
|
+
denominator = Math.sqrt(sum_sq1 * sum_sq2)
|
|
207
|
+
return nil if denominator.zero?
|
|
208
|
+
|
|
209
|
+
numerator / denominator
|
|
210
|
+
end
|
|
173
211
|
end
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
class Exporter
|
|
11
11
|
def aggregated_project name:, project_names:, settings: {}
|
|
12
12
|
project name: name do
|
|
13
|
-
|
|
13
|
+
file_system.log name
|
|
14
14
|
file_prefix name
|
|
15
|
-
self.settings.merge! settings
|
|
15
|
+
self.settings.merge! stringify_keys(settings)
|
|
16
16
|
|
|
17
17
|
aggregate do
|
|
18
18
|
project_names.each do |project_name|
|
|
@@ -6,23 +6,14 @@ class Exporter
|
|
|
6
6
|
def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
|
|
7
7
|
default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
|
|
8
8
|
rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
|
|
9
|
-
show_experimental_charts: false
|
|
10
|
-
|
|
9
|
+
show_experimental_charts: false, github_repos: nil
|
|
10
|
+
exporter = self
|
|
11
11
|
project name: name do
|
|
12
|
-
|
|
12
|
+
file_system.log name, also_write_to_stderr: true
|
|
13
13
|
file_prefix file_prefix
|
|
14
14
|
|
|
15
15
|
self.anonymize if anonymize
|
|
16
|
-
self.settings.merge! settings
|
|
17
|
-
|
|
18
|
-
status_category_mappings.each do |status, category|
|
|
19
|
-
status_category_mapping status: status, category: category
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
download do
|
|
23
|
-
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
24
|
-
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
25
|
-
end
|
|
16
|
+
self.settings.merge! stringify_keys(settings)
|
|
26
17
|
|
|
27
18
|
boards.each_key do |board_id|
|
|
28
19
|
block = boards[board_id]
|
|
@@ -37,17 +28,27 @@ class Exporter
|
|
|
37
28
|
end
|
|
38
29
|
end
|
|
39
30
|
|
|
31
|
+
status_category_mappings.each do |status, category|
|
|
32
|
+
status_category_mapping status: status, category: category
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
download do
|
|
36
|
+
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
37
|
+
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
38
|
+
github_repo *github_repos if github_repos
|
|
39
|
+
end
|
|
40
|
+
|
|
40
41
|
issues.reject! do |issue|
|
|
41
42
|
ignore_types.include? issue.type
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
exporter.filter_issues issues, ignore_issues
|
|
46
|
+
|
|
44
47
|
discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
|
|
45
48
|
|
|
46
49
|
file do
|
|
47
50
|
file_suffix '.html'
|
|
48
51
|
|
|
49
|
-
issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
|
|
50
|
-
|
|
51
52
|
html_report do
|
|
52
53
|
board_id default_board if default_board
|
|
53
54
|
|
|
@@ -57,37 +58,39 @@ class Exporter
|
|
|
57
58
|
html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
|
|
58
59
|
type: :header
|
|
59
60
|
end
|
|
60
|
-
|
|
61
61
|
daily_view
|
|
62
|
-
|
|
62
|
+
cumulative_flow_diagram
|
|
63
63
|
cycletime_scatterplot do
|
|
64
64
|
show_trend_lines
|
|
65
65
|
end
|
|
66
66
|
cycletime_histogram
|
|
67
67
|
|
|
68
68
|
throughput_chart do
|
|
69
|
-
description_text
|
|
69
|
+
description_text <<~TEXT
|
|
70
|
+
<div>Throughput data is very useful for#{' '}
|
|
71
|
+
<a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
|
|
72
|
+
to determine when we'll be done. Try it now with the
|
|
73
|
+
<a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
|
|
74
|
+
Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
|
|
75
|
+
<%= @not_started_count %> items you currently have in your backlog.
|
|
76
|
+
</div>
|
|
77
|
+
<h2>Number of items completed, grouped by issue type</h2>'
|
|
78
|
+
TEXT
|
|
70
79
|
end
|
|
71
|
-
|
|
72
|
-
header_text nil
|
|
80
|
+
throughput_by_completed_resolution_chart do
|
|
73
81
|
description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
|
|
74
|
-
grouping_rules do |issue, rules|
|
|
75
|
-
if issue.resolution
|
|
76
|
-
rules.label = "#{issue.status.name}:#{issue.resolution}"
|
|
77
|
-
else
|
|
78
|
-
rules.label = issue.status.name
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
aging_work_in_progress_chart
|
|
85
|
+
wip_by_column_chart do
|
|
86
|
+
show_recommendations
|
|
87
|
+
end
|
|
84
88
|
aging_work_bar_chart
|
|
85
89
|
aging_work_table
|
|
86
90
|
daily_wip_by_age_chart
|
|
87
91
|
daily_wip_by_blocked_stalled_chart
|
|
88
92
|
daily_wip_by_parent_chart
|
|
89
93
|
flow_efficiency_scatterplot if show_experimental_charts
|
|
90
|
-
expedited_chart
|
|
91
94
|
sprint_burndown
|
|
92
95
|
estimate_accuracy_chart
|
|
93
96
|
dependency_chart
|
|
@@ -95,4 +98,14 @@ class Exporter
|
|
|
95
98
|
end
|
|
96
99
|
end
|
|
97
100
|
end
|
|
101
|
+
|
|
102
|
+
# Extracted as a separate method so it can be tested independently, without needing to invoke
|
|
103
|
+
# the full standard_project DSL setup.
|
|
104
|
+
def filter_issues issues, ignore_issues
|
|
105
|
+
return unless ignore_issues
|
|
106
|
+
|
|
107
|
+
issues.reject! do |issue|
|
|
108
|
+
ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
98
111
|
end
|
|
@@ -38,6 +38,8 @@ class ExpeditedChart < ChartBase
|
|
|
38
38
|
</div>
|
|
39
39
|
#{describe_non_working_days}
|
|
40
40
|
HTML
|
|
41
|
+
@x_axis_title = 'Date'
|
|
42
|
+
@y_axis_title = 'Age in days'
|
|
41
43
|
|
|
42
44
|
instance_eval(&block)
|
|
43
45
|
end
|
|
@@ -48,7 +50,7 @@ class ExpeditedChart < ChartBase
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
if data_sets.empty?
|
|
51
|
-
'<h1>Expedited work</h1>There is no expedited work in this time period
|
|
53
|
+
'<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
|
|
52
54
|
else
|
|
53
55
|
wrap_and_render(binding, __FILE__)
|
|
54
56
|
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -8,7 +8,13 @@ class Exporter
|
|
|
8
8
|
|
|
9
9
|
def self.configure &block
|
|
10
10
|
logfile_name = 'jirametrics.log'
|
|
11
|
-
logfile = File.open
|
|
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
|
|
@@ -40,6 +46,7 @@ class Exporter
|
|
|
40
46
|
|
|
41
47
|
def download name_filter:
|
|
42
48
|
@downloading = true
|
|
49
|
+
github_pr_cache = {}
|
|
43
50
|
each_project_config(name_filter: name_filter) do |project|
|
|
44
51
|
project.evaluate_next_level
|
|
45
52
|
next if project.aggregated_project?
|
|
@@ -50,33 +57,42 @@ class Exporter
|
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
project.download_config.run
|
|
53
|
-
|
|
60
|
+
gateway = JiraGateway.new(
|
|
61
|
+
file_system: file_system, jira_config: project.jira_config, settings: project.settings
|
|
62
|
+
)
|
|
63
|
+
downloader = Downloader.create(
|
|
54
64
|
download_config: project.download_config,
|
|
55
65
|
file_system: file_system,
|
|
56
|
-
jira_gateway:
|
|
66
|
+
jira_gateway: gateway,
|
|
67
|
+
github_pr_cache: github_pr_cache
|
|
57
68
|
)
|
|
58
69
|
downloader.run
|
|
59
70
|
end
|
|
60
71
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
61
72
|
end
|
|
62
73
|
|
|
63
|
-
def info
|
|
74
|
+
def info key, name_filter:
|
|
64
75
|
selected = []
|
|
76
|
+
file_system.log_only = true
|
|
65
77
|
each_project_config(name_filter: name_filter) do |project|
|
|
66
78
|
project.evaluate_next_level
|
|
67
79
|
|
|
68
80
|
project.run load_only: true
|
|
69
81
|
project.issues.each do |issue|
|
|
70
|
-
selected << [project, issue] if
|
|
82
|
+
selected << [project, issue] if key == issue.key
|
|
83
|
+
issue.subtasks.each do |subtask|
|
|
84
|
+
selected << [project, subtask] if key == subtask.key
|
|
85
|
+
end
|
|
71
86
|
end
|
|
72
87
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
88
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
74
89
|
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
75
90
|
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
76
91
|
end
|
|
92
|
+
file_system.log_only = false
|
|
77
93
|
|
|
78
94
|
if selected.empty?
|
|
79
|
-
file_system.log "No issues found to match #{
|
|
95
|
+
file_system.log "No issues found to match #{key.inspect}"
|
|
80
96
|
else
|
|
81
97
|
selected.each do |project, issue|
|
|
82
98
|
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
@@ -85,6 +101,10 @@ class Exporter
|
|
|
85
101
|
end
|
|
86
102
|
end
|
|
87
103
|
|
|
104
|
+
def stitch stitch_file
|
|
105
|
+
Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
|
|
106
|
+
end
|
|
107
|
+
|
|
88
108
|
def each_project_config name_filter:
|
|
89
109
|
@project_configs.each do |project|
|
|
90
110
|
yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
|
|
@@ -65,22 +65,20 @@ class FileConfig
|
|
|
65
65
|
# most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
|
67
67
|
def sort_output all_lines
|
|
68
|
-
all_lines.sort do |a, b|
|
|
69
|
-
result =
|
|
70
|
-
|
|
71
|
-
result = a[1..] <=> b[1..]
|
|
68
|
+
all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
|
|
69
|
+
result = if a[0] == b[0]
|
|
70
|
+
a[1..] <=> b[1..]
|
|
72
71
|
elsif a[0].nil?
|
|
73
|
-
|
|
72
|
+
1
|
|
74
73
|
elsif b[0].nil?
|
|
75
|
-
|
|
74
|
+
-1
|
|
76
75
|
else
|
|
77
|
-
|
|
76
|
+
a[0] <=> b[0]
|
|
78
77
|
end
|
|
79
78
|
|
|
80
|
-
#
|
|
81
|
-
result
|
|
82
|
-
|
|
83
|
-
end
|
|
79
|
+
# When objects aren't comparable, preserve original order for a stable sort.
|
|
80
|
+
result.nil? || result.zero? ? a_idx <=> b_idx : result
|
|
81
|
+
end.map(&:first)
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def columns &block
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
class FileSystem
|
|
6
|
-
attr_accessor :logfile, :logfile_name
|
|
6
|
+
attr_accessor :logfile, :logfile_name, :log_only
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
# In almost all cases, this will be immediately replaced in the Exporter
|
|
10
|
+
# but if we fail before we get that far, this will at least let a useful
|
|
11
|
+
# error show up on the console.
|
|
12
|
+
@logfile = $stdout
|
|
13
|
+
@log_only = false
|
|
14
|
+
end
|
|
7
15
|
|
|
8
16
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
17
|
def load filename, supress_deprecation: false
|
|
@@ -31,6 +39,14 @@ class FileSystem
|
|
|
31
39
|
File.write(filename, content)
|
|
32
40
|
end
|
|
33
41
|
|
|
42
|
+
def mkdir path
|
|
43
|
+
FileUtils.mkdir_p path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def utime file:, time:
|
|
47
|
+
File.utime time, time, file
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def warning message, more: nil
|
|
35
51
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
52
|
end
|
|
@@ -44,11 +60,43 @@ class FileSystem
|
|
|
44
60
|
|
|
45
61
|
logfile.puts message
|
|
46
62
|
logfile.puts more if more
|
|
47
|
-
return
|
|
63
|
+
return if log_only || !also_write_to_stderr
|
|
64
|
+
|
|
65
|
+
# Obscure edge-case where we're trying to log something before logging is even
|
|
66
|
+
# set up. Quick escape here so that we don't dump the error twice.
|
|
67
|
+
return if logfile == $stdout
|
|
48
68
|
|
|
49
69
|
$stderr.puts message # rubocop:disable Style/StderrPuts
|
|
50
70
|
end
|
|
51
71
|
|
|
72
|
+
def log_start message
|
|
73
|
+
logfile.puts message
|
|
74
|
+
return if log_only || logfile == $stdout
|
|
75
|
+
|
|
76
|
+
$stderr.print message
|
|
77
|
+
$stderr.flush
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def start_progress
|
|
81
|
+
return if log_only
|
|
82
|
+
|
|
83
|
+
$stderr.print ' '
|
|
84
|
+
$stderr.flush
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def progress_dot
|
|
88
|
+
return if log_only
|
|
89
|
+
|
|
90
|
+
$stderr.print '.'
|
|
91
|
+
$stderr.flush
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def end_progress
|
|
95
|
+
return if log_only
|
|
96
|
+
|
|
97
|
+
$stderr.puts '' # rubocop:disable Style/StderrPuts
|
|
98
|
+
end
|
|
99
|
+
|
|
52
100
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
53
101
|
# cases where this simple compression will drop the filesize by half.
|
|
54
102
|
def compress node
|
|
@@ -66,7 +114,15 @@ class FileSystem
|
|
|
66
114
|
end
|
|
67
115
|
|
|
68
116
|
def file_exist? filename
|
|
69
|
-
File.exist? filename
|
|
117
|
+
File.exist?(filename) && File.file?(filename)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def dir_exist? path
|
|
121
|
+
File.exist?(path) && File.directory?(path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unlink filename
|
|
125
|
+
File.unlink filename
|
|
70
126
|
end
|
|
71
127
|
|
|
72
128
|
def deprecated message:, date:, depth: 2
|
|
@@ -11,11 +11,24 @@ class FixVersion
|
|
|
11
11
|
@raw['name']
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
def description
|
|
15
|
+
@raw['description']
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
def id
|
|
15
19
|
@raw['id'].to_i
|
|
16
20
|
end
|
|
17
21
|
|
|
22
|
+
def release_date
|
|
23
|
+
text = @raw['releaseDate']
|
|
24
|
+
text.nil? ? nil : Date.parse(text)
|
|
25
|
+
end
|
|
26
|
+
|
|
18
27
|
def released?
|
|
19
28
|
@raw['released']
|
|
20
29
|
end
|
|
30
|
+
|
|
31
|
+
def archived?
|
|
32
|
+
@raw['archived']
|
|
33
|
+
end
|
|
21
34
|
end
|
|
@@ -32,6 +32,8 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
32
32
|
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
|
33
33
|
</div>
|
|
34
34
|
HTML
|
|
35
|
+
@x_axis_title = 'Total time (days)'
|
|
36
|
+
@y_axis_title = 'Time adding value (days)'
|
|
35
37
|
|
|
36
38
|
init_configuration_block block do
|
|
37
39
|
grouping_rules do |issue, rule|
|
|
@@ -60,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
60
62
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
if data_sets.empty?
|
|
66
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
|
|
67
|
+
end
|
|
64
68
|
|
|
65
69
|
wrap_and_render(binding, __FILE__)
|
|
66
70
|
end
|