jirametrics 2.22 → 2.24
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/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +15 -1
- data/lib/jirametrics/aging_work_table.rb +1 -1
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +104 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +20 -8
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/change_item.rb +4 -3
- data/lib/jirametrics/chart_base.rb +87 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
- data/lib/jirametrics/daily_view.rb +32 -9
- data/lib/jirametrics/daily_wip_chart.rb +27 -7
- data/lib/jirametrics/data_quality_report.rb +31 -7
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +76 -5
- data/lib/jirametrics/downloader_for_cloud.rb +39 -0
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +20 -9
- data/lib/jirametrics/expedited_chart.rb +2 -0
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
- data/lib/jirametrics/github_gateway.rb +106 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +21 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/index.css +114 -0
- data/lib/jirametrics/html/index.erb +5 -0
- data/lib/jirametrics/html/index.js +52 -2
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +5 -8
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +84 -95
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +66 -6
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +2 -0
- data/lib/jirametrics/stitcher.rb +2 -1
- data/lib/jirametrics/throughput_chart.rb +7 -1
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- metadata +12 -5
|
@@ -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
|
|
@@ -33,21 +33,23 @@ class Downloader
|
|
|
33
33
|
# For testing only
|
|
34
34
|
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
35
35
|
|
|
36
|
-
def self.create download_config:, file_system:, jira_gateway:
|
|
36
|
+
def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
37
37
|
is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
|
|
38
38
|
(is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
|
|
39
39
|
download_config: download_config,
|
|
40
40
|
file_system: file_system,
|
|
41
|
-
jira_gateway: jira_gateway
|
|
41
|
+
jira_gateway: jira_gateway,
|
|
42
|
+
github_pr_cache: github_pr_cache
|
|
42
43
|
)
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def initialize download_config:, file_system:, jira_gateway:
|
|
46
|
+
def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
|
|
46
47
|
@metadata = {}
|
|
47
48
|
@download_config = download_config
|
|
48
49
|
@target_path = @download_config.project_config.target_path
|
|
49
50
|
@file_system = file_system
|
|
50
51
|
@jira_gateway = jira_gateway
|
|
52
|
+
@github_pr_cache = github_pr_cache
|
|
51
53
|
@board_id_to_filter_id = {}
|
|
52
54
|
|
|
53
55
|
@issue_keys_downloaded_in_current_run = []
|
|
@@ -77,6 +79,7 @@ class Downloader
|
|
|
77
79
|
download_users
|
|
78
80
|
|
|
79
81
|
save_metadata
|
|
82
|
+
download_github_prs if @download_config.github_repos.any?
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
def log text, both: false
|
|
@@ -165,11 +168,28 @@ class Downloader
|
|
|
165
168
|
# actually look at the returned json.
|
|
166
169
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
167
170
|
|
|
171
|
+
if json['type'] == 'simple'
|
|
172
|
+
features_json = download_features board_id: board_id
|
|
173
|
+
if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
174
|
+
download_sprints board_id: board_id
|
|
175
|
+
end
|
|
176
|
+
end
|
|
168
177
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
169
178
|
# TODO: Should be passing actual statuses, not empty list
|
|
170
179
|
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
171
180
|
end
|
|
172
181
|
|
|
182
|
+
def download_features board_id:
|
|
183
|
+
log " Downloading features for board #{board_id}", both: true
|
|
184
|
+
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
|
|
185
|
+
|
|
186
|
+
@file_system.save_json(
|
|
187
|
+
json: json,
|
|
188
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
|
|
189
|
+
)
|
|
190
|
+
json
|
|
191
|
+
end
|
|
192
|
+
|
|
173
193
|
def download_sprints board_id:
|
|
174
194
|
log " Downloading sprints for board #{board_id}", both: true
|
|
175
195
|
max_results = 100
|
|
@@ -211,19 +231,36 @@ class Downloader
|
|
|
211
231
|
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
212
232
|
@metadata[key] = value
|
|
213
233
|
end
|
|
234
|
+
|
|
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
|
|
214
242
|
end
|
|
215
243
|
|
|
216
244
|
# Even if this is the old format, we want to obey this one tag
|
|
217
245
|
@metadata['no-download'] = hash['no-download'] if hash['no-download']
|
|
218
246
|
end
|
|
219
247
|
|
|
248
|
+
def timezone_offset
|
|
249
|
+
@download_config.project_config.exporter.timezone_offset
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def today_in_project_timezone
|
|
253
|
+
Time.now.getlocal(timezone_offset).to_date
|
|
254
|
+
end
|
|
255
|
+
|
|
220
256
|
def save_metadata
|
|
221
257
|
@metadata['version'] = CURRENT_METADATA_VERSION
|
|
258
|
+
@metadata['rolling_date_count'] = @download_config.rolling_date_count
|
|
222
259
|
@metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
|
|
223
260
|
|
|
224
261
|
if @download_date_range.nil?
|
|
225
262
|
log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
|
|
226
|
-
today =
|
|
263
|
+
today = today_in_project_timezone
|
|
227
264
|
@download_date_range = (today - 7)..today
|
|
228
265
|
end
|
|
229
266
|
|
|
@@ -258,7 +295,8 @@ class Downloader
|
|
|
258
295
|
end
|
|
259
296
|
end
|
|
260
297
|
|
|
261
|
-
def make_jql filter_id:, today:
|
|
298
|
+
def make_jql filter_id:, today: nil
|
|
299
|
+
today ||= today_in_project_timezone
|
|
262
300
|
segments = []
|
|
263
301
|
segments << "filter=#{filter_id}"
|
|
264
302
|
|
|
@@ -283,6 +321,39 @@ class Downloader
|
|
|
283
321
|
segments.join ' AND '
|
|
284
322
|
end
|
|
285
323
|
|
|
324
|
+
def download_github_prs
|
|
325
|
+
project_keys = extract_project_keys_from_downloaded_issues
|
|
326
|
+
if project_keys.empty?
|
|
327
|
+
log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
prs = @download_config.github_repos.flat_map do |repo|
|
|
332
|
+
GithubGateway.new(
|
|
333
|
+
repo: repo,
|
|
334
|
+
project_keys: project_keys,
|
|
335
|
+
file_system: @file_system,
|
|
336
|
+
raw_pr_cache: @github_pr_cache
|
|
337
|
+
).fetch_pull_requests(since: @download_date_range&.begin)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
@file_system.save_json(
|
|
341
|
+
json: prs.map(&:raw),
|
|
342
|
+
filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def extract_project_keys_from_downloaded_issues
|
|
347
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
348
|
+
return [] unless @file_system.dir_exist?(path)
|
|
349
|
+
|
|
350
|
+
keys = []
|
|
351
|
+
@file_system.foreach(path) do |filename|
|
|
352
|
+
keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
|
|
353
|
+
end
|
|
354
|
+
keys.uniq
|
|
355
|
+
end
|
|
356
|
+
|
|
286
357
|
def file_prefix
|
|
287
358
|
@download_config.project_config.get_file_prefix
|
|
288
359
|
end
|
|
@@ -5,6 +5,45 @@ class DownloaderForCloud < Downloader
|
|
|
5
5
|
'Jira Cloud'
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
def run
|
|
9
|
+
super
|
|
10
|
+
download_fix_versions
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def download_board_configuration board_id:
|
|
14
|
+
board = super
|
|
15
|
+
location = board.raw['location']
|
|
16
|
+
@project_key ||= location['key'] if location&.[]('type') == 'project'
|
|
17
|
+
board
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def download_fix_versions
|
|
21
|
+
return unless @project_key
|
|
22
|
+
|
|
23
|
+
log " Downloading fix versions for project #{@project_key}", both: true
|
|
24
|
+
max_results = 50
|
|
25
|
+
start_at = 0
|
|
26
|
+
all_versions = []
|
|
27
|
+
|
|
28
|
+
loop do
|
|
29
|
+
json = @jira_gateway.call_url(
|
|
30
|
+
relative_url: "/rest/api/3/project/#{@project_key}/version?" \
|
|
31
|
+
"startAt=#{start_at}&maxResults=#{max_results}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
values = json['values'] || []
|
|
35
|
+
all_versions.concat(values)
|
|
36
|
+
break if json['isLast'] || values.empty?
|
|
37
|
+
|
|
38
|
+
start_at += values.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@file_system.save_json(
|
|
42
|
+
json: all_versions,
|
|
43
|
+
filename: File.join(@target_path, "#{file_prefix}_fix_versions.json")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
8
47
|
def search_for_issues jql:, board_id:, path:
|
|
9
48
|
log " JQL: #{jql}"
|
|
10
49
|
escaped_jql = CGI.escape jql
|
|
@@ -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
|
|
@@ -6,14 +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
|
puts name
|
|
13
13
|
file_prefix file_prefix
|
|
14
14
|
|
|
15
15
|
self.anonymize if anonymize
|
|
16
|
-
self.settings.merge! settings
|
|
16
|
+
self.settings.merge! stringify_keys(settings)
|
|
17
17
|
|
|
18
18
|
boards.each_key do |board_id|
|
|
19
19
|
block = boards[board_id]
|
|
@@ -35,19 +35,20 @@ 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
39
|
end
|
|
39
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
|
|
|
@@ -72,10 +73,11 @@ class Exporter
|
|
|
72
73
|
header_text nil
|
|
73
74
|
description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
|
|
74
75
|
grouping_rules do |issue, rules|
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
status, resolution = issue.status_resolution_at_done
|
|
77
|
+
if resolution
|
|
78
|
+
rules.label = "#{status.name}:#{resolution}"
|
|
77
79
|
else
|
|
78
|
-
rules.label =
|
|
80
|
+
rules.label = status.name
|
|
79
81
|
end
|
|
80
82
|
end
|
|
81
83
|
end
|
|
@@ -87,7 +89,6 @@ class Exporter
|
|
|
87
89
|
daily_wip_by_blocked_stalled_chart
|
|
88
90
|
daily_wip_by_parent_chart
|
|
89
91
|
flow_efficiency_scatterplot if show_experimental_charts
|
|
90
|
-
expedited_chart
|
|
91
92
|
sprint_burndown
|
|
92
93
|
estimate_accuracy_chart
|
|
93
94
|
dependency_chart
|
|
@@ -95,4 +96,14 @@ class Exporter
|
|
|
95
96
|
end
|
|
96
97
|
end
|
|
97
98
|
end
|
|
99
|
+
|
|
100
|
+
# Extracted as a separate method so it can be tested independently, without needing to invoke
|
|
101
|
+
# the full standard_project DSL setup.
|
|
102
|
+
def filter_issues issues, ignore_issues
|
|
103
|
+
return unless ignore_issues
|
|
104
|
+
|
|
105
|
+
issues.reject! do |issue|
|
|
106
|
+
ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
98
109
|
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -40,6 +40,7 @@ class Exporter
|
|
|
40
40
|
|
|
41
41
|
def download name_filter:
|
|
42
42
|
@downloading = true
|
|
43
|
+
github_pr_cache = {}
|
|
43
44
|
each_project_config(name_filter: name_filter) do |project|
|
|
44
45
|
project.evaluate_next_level
|
|
45
46
|
next if project.aggregated_project?
|
|
@@ -56,7 +57,8 @@ class Exporter
|
|
|
56
57
|
downloader = Downloader.create(
|
|
57
58
|
download_config: project.download_config,
|
|
58
59
|
file_system: file_system,
|
|
59
|
-
jira_gateway: gateway
|
|
60
|
+
jira_gateway: gateway,
|
|
61
|
+
github_pr_cache: github_pr_cache
|
|
60
62
|
)
|
|
61
63
|
downloader.run
|
|
62
64
|
end
|
|
@@ -61,6 +61,10 @@ class FileSystem
|
|
|
61
61
|
logfile.puts more if more
|
|
62
62
|
return unless also_write_to_stderr
|
|
63
63
|
|
|
64
|
+
# Obscure edge-case where we're trying to log something before logging is even
|
|
65
|
+
# set up. Quick escape here so that we don't dump the error twice.
|
|
66
|
+
return if logfile == $stdout
|
|
67
|
+
|
|
64
68
|
$stderr.puts message # rubocop:disable Style/StderrPuts
|
|
65
69
|
end
|
|
66
70
|
|
|
@@ -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|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
class GithubGateway
|
|
7
|
+
attr_reader :repo
|
|
8
|
+
|
|
9
|
+
def initialize repo:, project_keys:, file_system:, raw_pr_cache: {}
|
|
10
|
+
@repo = repo
|
|
11
|
+
@project_keys = project_keys
|
|
12
|
+
@file_system = file_system
|
|
13
|
+
@raw_pr_cache = raw_pr_cache
|
|
14
|
+
@issue_key_pattern = build_issue_key_pattern
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch_pull_requests since: nil
|
|
18
|
+
raw_prs = @raw_pr_cache[[@repo, since]] ||= fetch_raw_pull_requests(since: since)
|
|
19
|
+
raw_prs.filter_map { |pr| build_pr_data(pr) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_raw_pull_requests since: nil
|
|
23
|
+
# Note: 'commits' is intentionally excluded — including it triggers GitHub's GraphQL node
|
|
24
|
+
# limit (authors sub-connection × PRs × commits exceeds 500,000 nodes). Branch name,
|
|
25
|
+
# title, and body are sufficient for issue key extraction in the vast majority of cases.
|
|
26
|
+
json_fields = %w[number title body headRefName createdAt closedAt mergedAt
|
|
27
|
+
url state reviews additions deletions changedFiles].join(',')
|
|
28
|
+
args = ['pr', 'list', '--state', 'all', '--limit', '5000', '--json', json_fields]
|
|
29
|
+
args += ['--repo', @repo]
|
|
30
|
+
args += ['--search', "updated:>=#{since}"] if since
|
|
31
|
+
|
|
32
|
+
@file_system.log " Downloading pull requests from #{@repo}", also_write_to_stderr: true
|
|
33
|
+
run_command(args)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_pr_data raw_pr
|
|
37
|
+
issue_keys = extract_issue_keys(raw_pr)
|
|
38
|
+
return nil if issue_keys.empty?
|
|
39
|
+
|
|
40
|
+
PullRequest.new(raw: {
|
|
41
|
+
'number' => raw_pr['number'],
|
|
42
|
+
'repo' => @repo,
|
|
43
|
+
'url' => raw_pr['url'],
|
|
44
|
+
'title' => raw_pr['title'],
|
|
45
|
+
'branch' => raw_pr['headRefName'],
|
|
46
|
+
'opened_at' => raw_pr['createdAt'],
|
|
47
|
+
'closed_at' => raw_pr['closedAt'],
|
|
48
|
+
'merged_at' => raw_pr['mergedAt'],
|
|
49
|
+
'state' => raw_pr['state'],
|
|
50
|
+
'issue_keys' => issue_keys,
|
|
51
|
+
'reviews' => extract_reviews(raw_pr['reviews'] || []),
|
|
52
|
+
'additions' => raw_pr['additions'],
|
|
53
|
+
'deletions' => raw_pr['deletions'],
|
|
54
|
+
'changed_files' => raw_pr['changedFiles']
|
|
55
|
+
})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_issue_keys raw_pr
|
|
59
|
+
return [] if @issue_key_pattern.nil?
|
|
60
|
+
|
|
61
|
+
sources = [
|
|
62
|
+
raw_pr['headRefName'],
|
|
63
|
+
raw_pr['title'],
|
|
64
|
+
raw_pr['body']
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
sources.compact
|
|
68
|
+
.flat_map { |s| s.scan(@issue_key_pattern) }
|
|
69
|
+
.uniq
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def extract_reviews raw_reviews
|
|
73
|
+
raw_reviews
|
|
74
|
+
.select { |r| %w[APPROVED CHANGES_REQUESTED].include?(r['state']) }
|
|
75
|
+
.map do |r|
|
|
76
|
+
{
|
|
77
|
+
'author' => r.dig('author', 'login'),
|
|
78
|
+
'submitted_at' => r['submittedAt'],
|
|
79
|
+
'state' => r['state']
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def build_issue_key_pattern
|
|
87
|
+
return nil if @project_keys.empty?
|
|
88
|
+
|
|
89
|
+
keys_pattern = @project_keys.map { |k| Regexp.escape(k) }.join('|')
|
|
90
|
+
Regexp.new("\\b(?:#{keys_pattern})-\\d+\\b")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run_command args
|
|
94
|
+
stdout, stderr, status = Open3.capture3('gh', *args)
|
|
95
|
+
|
|
96
|
+
# This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
|
|
97
|
+
if stderr.include?('SAML enforcement')
|
|
98
|
+
raise "GitHub CLI is not authorized to access #{@repo}. " \
|
|
99
|
+
"Run: gh auth refresh -h github.com -s read:org"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
|
|
103
|
+
|
|
104
|
+
JSON.parse(stdout)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -16,6 +16,7 @@ module GroupableIssueChart
|
|
|
16
16
|
def group_issues completed_issues
|
|
17
17
|
result = {}
|
|
18
18
|
ignored_issues = []
|
|
19
|
+
@issue_hints = {}
|
|
19
20
|
completed_issues.each do |issue|
|
|
20
21
|
rules = GroupingRules.new
|
|
21
22
|
@group_by_block.call(issue, rules)
|
|
@@ -24,6 +25,7 @@ module GroupableIssueChart
|
|
|
24
25
|
next
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
@issue_hints[issue] = rules.issue_hint
|
|
27
29
|
(result[rules] ||= []) << issue
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class GroupingRules < Rules
|
|
4
|
-
attr_accessor :label
|
|
4
|
+
attr_accessor :label, :issue_hint
|
|
5
5
|
attr_reader :color
|
|
6
6
|
|
|
7
7
|
def eql? other
|
|
@@ -13,7 +13,25 @@ class GroupingRules < Rules
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def color= color
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
if color.is_a?(Array)
|
|
17
|
+
raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
|
|
18
|
+
raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
|
|
19
|
+
|
|
20
|
+
if color.any? { |c| c.start_with?('--') }
|
|
21
|
+
raise ArgumentError,
|
|
22
|
+
'CSS variable references are not supported as color pair elements; use a literal color value instead'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
light, dark = color
|
|
26
|
+
@color = RawJavascript.new(
|
|
27
|
+
"(document.documentElement.dataset.theme === 'dark' || " \
|
|
28
|
+
'(!document.documentElement.dataset.theme && ' \
|
|
29
|
+
"window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
|
|
30
|
+
"? #{dark.to_json} : #{light.to_json}"
|
|
31
|
+
)
|
|
32
|
+
else
|
|
33
|
+
color = CssVariable[color] unless color.is_a?(CssVariable)
|
|
34
|
+
@color = color
|
|
35
|
+
end
|
|
18
36
|
end
|
|
19
37
|
end
|
|
@@ -16,11 +16,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
16
16
|
x: {
|
|
17
17
|
type: 'time',
|
|
18
18
|
min: '<%= @date_range.begin.to_s %>',
|
|
19
|
-
max: '<%= (@date_range.end ).to_s %>',
|
|
19
|
+
max: '<%= (@date_range.end + 1).to_s %>',
|
|
20
20
|
stacked: false,
|
|
21
|
-
|
|
22
|
-
display: false
|
|
23
|
-
},
|
|
21
|
+
<%= render_axis_title :x %>
|
|
24
22
|
grid: {
|
|
25
23
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
26
24
|
},
|
|
@@ -31,6 +29,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
31
29
|
ticks: {
|
|
32
30
|
display: true
|
|
33
31
|
},
|
|
32
|
+
<%= render_axis_title :y %>
|
|
34
33
|
grid: {
|
|
35
34
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
36
35
|
},
|
|
@@ -21,7 +21,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
21
21
|
time: {
|
|
22
22
|
unit: 'day'
|
|
23
23
|
},
|
|
24
|
+
min: "<%= date_range.begin.to_s %>",
|
|
25
|
+
max: "<%= (date_range.end + 1).to_s %>",
|
|
24
26
|
stacked: true,
|
|
27
|
+
<%= render_axis_title :x %>
|
|
25
28
|
grid: {
|
|
26
29
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
27
30
|
},
|
|
@@ -32,10 +35,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
32
35
|
display: true,
|
|
33
36
|
labelString: 'WIP'
|
|
34
37
|
},
|
|
35
|
-
|
|
36
|
-
display: true,
|
|
37
|
-
text: 'Count of items'
|
|
38
|
-
},
|
|
38
|
+
<%= render_axis_title :y %>
|
|
39
39
|
grid: {
|
|
40
40
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
41
41
|
},
|
|
@@ -52,6 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
52
52
|
annotation: {
|
|
53
53
|
annotations: {
|
|
54
54
|
<%= working_days_annotation %>
|
|
55
|
+
<%= date_annotation %>
|
|
55
56
|
}
|
|
56
57
|
},
|
|
57
58
|
legend: {
|