jirametrics 2.22 → 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 +26 -10
- 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 +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +28 -8
- 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 +4 -3
- data/lib/jirametrics/chart_base.rb +107 -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} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
- data/lib/jirametrics/daily_view.rb +38 -13
- data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +29 -7
- data/lib/jirametrics/data_quality_report.rb +38 -12
- data/lib/jirametrics/dependency_chart.rb +2 -2
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +87 -5
- data/lib/jirametrics/downloader_for_cloud.rb +107 -22
- data/lib/jirametrics/downloader_for_data_center.rb +3 -2
- 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 +32 -19
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +15 -2
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +35 -2
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +4 -0
- data/lib/jirametrics/grouping_rules.rb +26 -4
- 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/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
- 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 +228 -60
- data/lib/jirametrics/html/index.erb +6 -0
- data/lib/jirametrics/html/index.js +53 -3
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +40 -9
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +2 -1
- data/lib/jirametrics/html_report_config.rb +45 -33
- data/lib/jirametrics/issue.rb +197 -99
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +32 -10
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +87 -8
- 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 +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +4 -2
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +7 -1
- 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 +58 -0
- metadata +52 -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 = []
|
|
@@ -72,17 +74,36 @@ class Downloader
|
|
|
72
74
|
download_statuses
|
|
73
75
|
find_board_ids.each do |id|
|
|
74
76
|
board = download_board_configuration board_id: id
|
|
77
|
+
board.project_config = @download_config.project_config
|
|
75
78
|
download_issues board: board
|
|
76
79
|
end
|
|
77
80
|
download_users
|
|
78
81
|
|
|
79
82
|
save_metadata
|
|
83
|
+
download_github_prs if @download_config.github_repos.any?
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
def log text, both: false
|
|
83
87
|
@file_system.log text, also_write_to_stderr: both
|
|
84
88
|
end
|
|
85
89
|
|
|
90
|
+
def log_start text
|
|
91
|
+
@file_system.log_start text
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def start_progress
|
|
95
|
+
@file_system.start_progress
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def progress_dot message = nil
|
|
99
|
+
@file_system.log message if message
|
|
100
|
+
@file_system.progress_dot
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def end_progress
|
|
104
|
+
@file_system.end_progress
|
|
105
|
+
end
|
|
106
|
+
|
|
86
107
|
def find_board_ids
|
|
87
108
|
ids = @download_config.project_config.board_configs.collect(&:id)
|
|
88
109
|
raise 'Board ids must be specified' if ids.empty?
|
|
@@ -165,11 +186,28 @@ class Downloader
|
|
|
165
186
|
# actually look at the returned json.
|
|
166
187
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
167
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
|
|
168
195
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
169
196
|
# TODO: Should be passing actual statuses, not empty list
|
|
170
197
|
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
171
198
|
end
|
|
172
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
|
+
|
|
173
211
|
def download_sprints board_id:
|
|
174
212
|
log " Downloading sprints for board #{board_id}", both: true
|
|
175
213
|
max_results = 100
|
|
@@ -211,19 +249,29 @@ class Downloader
|
|
|
211
249
|
value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
|
|
212
250
|
@metadata[key] = value
|
|
213
251
|
end
|
|
252
|
+
|
|
214
253
|
end
|
|
215
254
|
|
|
216
255
|
# Even if this is the old format, we want to obey this one tag
|
|
217
256
|
@metadata['no-download'] = hash['no-download'] if hash['no-download']
|
|
218
257
|
end
|
|
219
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
|
+
|
|
220
267
|
def save_metadata
|
|
221
268
|
@metadata['version'] = CURRENT_METADATA_VERSION
|
|
269
|
+
@metadata['rolling_date_count'] = @download_config.rolling_date_count
|
|
222
270
|
@metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
|
|
223
271
|
|
|
224
272
|
if @download_date_range.nil?
|
|
225
273
|
log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
|
|
226
|
-
today =
|
|
274
|
+
today = today_in_project_timezone
|
|
227
275
|
@download_date_range = (today - 7)..today
|
|
228
276
|
end
|
|
229
277
|
|
|
@@ -258,7 +306,8 @@ class Downloader
|
|
|
258
306
|
end
|
|
259
307
|
end
|
|
260
308
|
|
|
261
|
-
def make_jql filter_id:, today:
|
|
309
|
+
def make_jql filter_id:, today: nil
|
|
310
|
+
today ||= today_in_project_timezone
|
|
262
311
|
segments = []
|
|
263
312
|
segments << "filter=#{filter_id}"
|
|
264
313
|
|
|
@@ -283,6 +332,39 @@ class Downloader
|
|
|
283
332
|
segments.join ' AND '
|
|
284
333
|
end
|
|
285
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
|
+
|
|
286
368
|
def file_prefix
|
|
287
369
|
@download_config.project_config.get_file_prefix
|
|
288
370
|
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
|
|
@@ -14,6 +53,7 @@ class DownloaderForCloud < Downloader
|
|
|
14
53
|
next_page_token = nil
|
|
15
54
|
issue_count = 0
|
|
16
55
|
|
|
56
|
+
start_progress
|
|
17
57
|
loop do
|
|
18
58
|
relative_url = +''
|
|
19
59
|
relative_url << '/rest/api/3/search/jql'
|
|
@@ -36,11 +76,12 @@ class DownloaderForCloud < Downloader
|
|
|
36
76
|
issue_count += 1
|
|
37
77
|
end
|
|
38
78
|
|
|
39
|
-
|
|
40
|
-
log message, both: true
|
|
79
|
+
progress_dot " Found #{issue_count} issues"
|
|
41
80
|
|
|
42
81
|
break unless next_page_token
|
|
43
82
|
end
|
|
83
|
+
end_progress
|
|
84
|
+
|
|
44
85
|
hash
|
|
45
86
|
end
|
|
46
87
|
|
|
@@ -49,7 +90,7 @@ class DownloaderForCloud < Downloader
|
|
|
49
90
|
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
50
91
|
# without changes and then make a second call for that changes. Then we insert it
|
|
51
92
|
# into the raw issue as if it had been there all along.
|
|
52
|
-
log " Downloading #{issue_datas.size} issues"
|
|
93
|
+
log " Downloading #{issue_datas.size} issues"
|
|
53
94
|
payload = {
|
|
54
95
|
'fields' => ['*all'],
|
|
55
96
|
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
@@ -67,11 +108,24 @@ class DownloaderForCloud < Downloader
|
|
|
67
108
|
}
|
|
68
109
|
issue = Issue.new(raw: issue_json, board: board)
|
|
69
110
|
data = issue_datas.find { |d| d.key == issue.key }
|
|
111
|
+
unless data
|
|
112
|
+
log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
70
115
|
data.up_to_date = true
|
|
71
116
|
data.last_modified = issue.updated
|
|
72
117
|
data.issue = issue
|
|
73
118
|
end
|
|
74
119
|
|
|
120
|
+
# Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
|
|
121
|
+
# This happens when Jira returns a different key (moved issue) leaving the original unmatched.
|
|
122
|
+
issue_datas.each do |data|
|
|
123
|
+
next if data.up_to_date
|
|
124
|
+
|
|
125
|
+
log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
|
|
126
|
+
data.up_to_date = true
|
|
127
|
+
end
|
|
128
|
+
|
|
75
129
|
issue_datas
|
|
76
130
|
end
|
|
77
131
|
|
|
@@ -127,16 +181,20 @@ class DownloaderForCloud < Downloader
|
|
|
127
181
|
|
|
128
182
|
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
129
183
|
|
|
184
|
+
checked_for_related = Set.new
|
|
185
|
+
in_related_phase = false
|
|
186
|
+
|
|
130
187
|
loop do
|
|
131
188
|
related_issue_keys = Set.new
|
|
132
|
-
issue_data_hash
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.each_slice(100) do |slice|
|
|
136
|
-
slice = bulk_fetch_issues(
|
|
137
|
-
|
|
138
|
-
)
|
|
189
|
+
stale = issue_data_hash.values.reject { |data| data.up_to_date }
|
|
190
|
+
unless stale.empty?
|
|
191
|
+
log_start ' Downloading more issues ' unless in_related_phase
|
|
192
|
+
stale.each_slice(100) do |slice|
|
|
193
|
+
slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
|
|
194
|
+
progress_dot
|
|
139
195
|
slice.each do |data|
|
|
196
|
+
next unless data.issue
|
|
197
|
+
|
|
140
198
|
@file_system.save_json(
|
|
141
199
|
json: data.issue.raw, filename: data.cache_path
|
|
142
200
|
)
|
|
@@ -144,20 +202,25 @@ class DownloaderForCloud < Downloader
|
|
|
144
202
|
# to parse the file just to find the timestamp
|
|
145
203
|
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
146
204
|
|
|
147
|
-
issue
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
151
|
-
related_issue_keys << parent_key if parent_key
|
|
152
|
-
|
|
153
|
-
# Sub-tasks
|
|
154
|
-
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
155
|
-
related_issue_keys << raw_subtask['key']
|
|
156
|
-
end
|
|
205
|
+
collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
|
|
206
|
+
checked_for_related << data.key
|
|
157
207
|
end
|
|
158
208
|
end
|
|
209
|
+
end_progress unless in_related_phase
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Also scan up-to-date cached issues we haven't checked yet — they may reference
|
|
213
|
+
# related issues that are not in the primary query result.
|
|
214
|
+
issue_data_hash.each_value do |data|
|
|
215
|
+
next if checked_for_related.include?(data.key)
|
|
216
|
+
next unless @file_system.file_exist?(data.cache_path)
|
|
217
|
+
|
|
218
|
+
checked_for_related << data.key
|
|
219
|
+
raw = @file_system.load_json(data.cache_path)
|
|
220
|
+
collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
|
|
221
|
+
end
|
|
159
222
|
|
|
160
|
-
# Remove all the ones we already
|
|
223
|
+
# Remove all the ones we already have
|
|
161
224
|
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
162
225
|
|
|
163
226
|
related_issue_keys.each do |key|
|
|
@@ -169,9 +232,15 @@ class DownloaderForCloud < Downloader
|
|
|
169
232
|
end
|
|
170
233
|
break if related_issue_keys.empty?
|
|
171
234
|
|
|
172
|
-
|
|
235
|
+
unless in_related_phase
|
|
236
|
+
in_related_phase = true
|
|
237
|
+
log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
|
|
238
|
+
log_start ' Downloading more issues '
|
|
239
|
+
end
|
|
173
240
|
end
|
|
174
241
|
|
|
242
|
+
end_progress if in_related_phase
|
|
243
|
+
|
|
175
244
|
delete_issues_from_cache_that_are_not_in_server(
|
|
176
245
|
issue_data_hash: issue_data_hash, path: path
|
|
177
246
|
)
|
|
@@ -196,6 +265,22 @@ class DownloaderForCloud < Downloader
|
|
|
196
265
|
end
|
|
197
266
|
end
|
|
198
267
|
|
|
268
|
+
def collect_related_issue_keys issue:, related_issue_keys:
|
|
269
|
+
parent_key = issue.parent_key(project_config: @download_config.project_config)
|
|
270
|
+
related_issue_keys << parent_key if parent_key
|
|
271
|
+
|
|
272
|
+
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
273
|
+
related_issue_keys << raw_subtask['key']
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
issue.raw['fields']['issuelinks']&.each do |link|
|
|
277
|
+
next if link['type']['name'] == 'Cloners'
|
|
278
|
+
|
|
279
|
+
linked = link['inwardIssue'] || link['outwardIssue']
|
|
280
|
+
related_issue_keys << linked['key'] if linked
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
199
284
|
def last_modified filename:
|
|
200
285
|
File.mtime(filename) if File.exist?(filename)
|
|
201
286
|
end
|
|
@@ -25,7 +25,7 @@ class DownloaderForDataCenter < Downloader
|
|
|
25
25
|
keys_to_request = @issue_keys_pending_download[0..99]
|
|
26
26
|
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
27
27
|
jql = "key in (#{keys_to_request.join(', ')})"
|
|
28
|
-
jira_search_by_jql(jql: jql, initial_query:
|
|
28
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -63,7 +63,8 @@ class DownloaderForDataCenter < Downloader
|
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
def make_jql filter_id:, today:
|
|
66
|
+
def make_jql filter_id:, today: nil
|
|
67
|
+
today ||= today_in_project_timezone
|
|
67
68
|
segments = []
|
|
68
69
|
segments << "filter=#{filter_id}"
|
|
69
70
|
|
|
@@ -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,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
|
+
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
|
|
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
|
|
|
@@ -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 class="foldable">Expedited work</h1><
|
|
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
|