jirametrics 2.4 → 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 +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- 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 +6 -4
- data/lib/jirametrics/board.rb +74 -22
- data/lib/jirametrics/board_config.rb +11 -3
- 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 +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +42 -12
- data/lib/jirametrics/download_config.rb +27 -0
- data/lib/jirametrics/downloader.rb +185 -110
- 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 +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +9 -23
- data/lib/jirametrics/examples/standard_project.rb +57 -58
- data/lib/jirametrics/expedited_chart.rb +11 -10
- data/lib/jirametrics/exporter.rb +51 -14
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- 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 +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- 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 +83 -76
- data/lib/jirametrics/issue.rb +499 -91
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- 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/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -2
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- 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/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForCloud < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira Cloud'
|
|
6
|
+
end
|
|
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
|
+
|
|
47
|
+
def search_for_issues jql:, board_id:, path:
|
|
48
|
+
log " JQL: #{jql}"
|
|
49
|
+
escaped_jql = CGI.escape jql
|
|
50
|
+
|
|
51
|
+
hash = {}
|
|
52
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
53
|
+
next_page_token = nil
|
|
54
|
+
issue_count = 0
|
|
55
|
+
|
|
56
|
+
start_progress
|
|
57
|
+
loop do
|
|
58
|
+
relative_url = +''
|
|
59
|
+
relative_url << '/rest/api/3/search/jql'
|
|
60
|
+
relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
|
|
61
|
+
relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
|
|
62
|
+
relative_url << '&fields=updated'
|
|
63
|
+
|
|
64
|
+
json = @jira_gateway.call_url relative_url: relative_url
|
|
65
|
+
next_page_token = json['nextPageToken']
|
|
66
|
+
|
|
67
|
+
json['issues'].each do |i|
|
|
68
|
+
key = i['key']
|
|
69
|
+
data = DownloadIssueData.new key: key
|
|
70
|
+
data.key = key
|
|
71
|
+
data.last_modified = Time.parse i['fields']['updated']
|
|
72
|
+
data.found_in_primary_query = true
|
|
73
|
+
data.cache_path = File.join(path, "#{key}-#{board_id}.json")
|
|
74
|
+
data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
|
|
75
|
+
hash[key] = data
|
|
76
|
+
issue_count += 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
progress_dot " Found #{issue_count} issues"
|
|
80
|
+
|
|
81
|
+
break unless next_page_token
|
|
82
|
+
end
|
|
83
|
+
end_progress
|
|
84
|
+
|
|
85
|
+
hash
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def bulk_fetch_issues issue_datas:, board:, in_initial_query:
|
|
89
|
+
# We used to use the expand option to pull in the changelog directly. Unfortunately
|
|
90
|
+
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
91
|
+
# without changes and then make a second call for that changes. Then we insert it
|
|
92
|
+
# into the raw issue as if it had been there all along.
|
|
93
|
+
log " Downloading #{issue_datas.size} issues"
|
|
94
|
+
payload = {
|
|
95
|
+
'fields' => ['*all'],
|
|
96
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
97
|
+
}
|
|
98
|
+
response = @jira_gateway.post_request(
|
|
99
|
+
relative_url: '/rest/api/3/issue/bulkfetch',
|
|
100
|
+
payload: JSON.generate(payload)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
|
|
104
|
+
|
|
105
|
+
response['issues'].each do |issue_json|
|
|
106
|
+
issue_json['exporter'] = {
|
|
107
|
+
'in_initial_query' => in_initial_query
|
|
108
|
+
}
|
|
109
|
+
issue = Issue.new(raw: issue_json, board: board)
|
|
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
|
|
115
|
+
data.up_to_date = true
|
|
116
|
+
data.last_modified = issue.updated
|
|
117
|
+
data.issue = issue
|
|
118
|
+
end
|
|
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
|
+
|
|
129
|
+
issue_datas
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def attach_changelog_to_issues issue_datas:, issue_jsons:
|
|
133
|
+
max_results = 10_000 # The max jira accepts is 10K
|
|
134
|
+
payload = {
|
|
135
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key),
|
|
136
|
+
'maxResults' => max_results
|
|
137
|
+
}
|
|
138
|
+
loop do
|
|
139
|
+
response = @jira_gateway.post_request(
|
|
140
|
+
relative_url: '/rest/api/3/changelog/bulkfetch',
|
|
141
|
+
payload: JSON.generate(payload)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
response['issueChangeLogs'].each do |issue_change_log|
|
|
145
|
+
issue_id = issue_change_log['issueId']
|
|
146
|
+
json = issue_jsons.find { |json| json['id'] == issue_id }
|
|
147
|
+
|
|
148
|
+
unless json['changelog']
|
|
149
|
+
# If this is our first time in, there won't be a changelog section
|
|
150
|
+
json['changelog'] = {
|
|
151
|
+
'startAt' => 0,
|
|
152
|
+
'maxResults' => max_results,
|
|
153
|
+
'total' => 0,
|
|
154
|
+
'histories' => []
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
new_changes = issue_change_log['changeHistories']
|
|
159
|
+
json['changelog']['total'] += new_changes.size
|
|
160
|
+
json['changelog']['histories'] += new_changes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
next_page_token = response['nextPageToken']
|
|
164
|
+
payload['nextPageToken'] = next_page_token
|
|
165
|
+
break if next_page_token.nil?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def download_issues board:
|
|
170
|
+
log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
|
|
171
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
172
|
+
unless @file_system.dir_exist?(path)
|
|
173
|
+
log " Creating path #{path}"
|
|
174
|
+
@file_system.mkdir(path)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
178
|
+
jql = make_jql(filter_id: filter_id)
|
|
179
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
180
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
181
|
+
|
|
182
|
+
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
183
|
+
|
|
184
|
+
checked_for_related = Set.new
|
|
185
|
+
in_related_phase = false
|
|
186
|
+
|
|
187
|
+
loop do
|
|
188
|
+
related_issue_keys = Set.new
|
|
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
|
|
195
|
+
slice.each do |data|
|
|
196
|
+
next unless data.issue
|
|
197
|
+
|
|
198
|
+
@file_system.save_json(
|
|
199
|
+
json: data.issue.raw, filename: data.cache_path
|
|
200
|
+
)
|
|
201
|
+
# Set the timestamp on the file to match the updated one so that we don't have
|
|
202
|
+
# to parse the file just to find the timestamp
|
|
203
|
+
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
204
|
+
|
|
205
|
+
collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
|
|
206
|
+
checked_for_related << data.key
|
|
207
|
+
end
|
|
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
|
|
222
|
+
|
|
223
|
+
# Remove all the ones we already have
|
|
224
|
+
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
225
|
+
|
|
226
|
+
related_issue_keys.each do |key|
|
|
227
|
+
data = DownloadIssueData.new key: key
|
|
228
|
+
data.found_in_primary_query = false
|
|
229
|
+
data.up_to_date = false
|
|
230
|
+
data.cache_path = File.join(path, "#{key}-#{board.id}.json")
|
|
231
|
+
issue_data_hash[key] = data
|
|
232
|
+
end
|
|
233
|
+
break if related_issue_keys.empty?
|
|
234
|
+
|
|
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
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
end_progress if in_related_phase
|
|
243
|
+
|
|
244
|
+
delete_issues_from_cache_that_are_not_in_server(
|
|
245
|
+
issue_data_hash: issue_data_hash, path: path
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
|
|
250
|
+
# The gotcha with deleted issues is that they just stop being returned in queries
|
|
251
|
+
# and we have no way to know that they should be removed from our local cache.
|
|
252
|
+
# With the new approach, we ask for every issue that Jira knows about (within
|
|
253
|
+
# the parameters of the query) and then delete anything that's in our local cache
|
|
254
|
+
# but wasn't returned.
|
|
255
|
+
@file_system.foreach path do |file|
|
|
256
|
+
next if file.start_with? '.'
|
|
257
|
+
unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
|
|
258
|
+
raise "Unexpected filename in #{path}: #{file}"
|
|
259
|
+
end
|
|
260
|
+
next if issue_data_hash[key] # Still in Jira
|
|
261
|
+
|
|
262
|
+
file_to_delete = File.join(path, file)
|
|
263
|
+
log " Removing #{file_to_delete} from local cache"
|
|
264
|
+
file_system.unlink file_to_delete
|
|
265
|
+
end
|
|
266
|
+
end
|
|
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
|
+
|
|
284
|
+
def last_modified filename:
|
|
285
|
+
File.mtime(filename) if File.exist?(filename)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -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,17 +20,30 @@ 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
|
|
|
25
|
-
@
|
|
32
|
+
@x_axis_title = 'Cycletime (days)'
|
|
33
|
+
@y_axis_title = 'Estimate'
|
|
34
|
+
|
|
26
35
|
@y_axis_type = 'linear'
|
|
27
|
-
@y_axis_block = ->(issue, start_time) {
|
|
36
|
+
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
28
37
|
@y_axis_sort_order = nil
|
|
29
38
|
|
|
30
39
|
instance_eval(&configuration_block)
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
def run
|
|
43
|
+
if @y_axis_title.nil?
|
|
44
|
+
text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
|
|
45
|
+
@y_axis_title = "Estimated #{text}"
|
|
46
|
+
end
|
|
34
47
|
data_sets = scan_issues
|
|
35
48
|
|
|
36
49
|
return '' if data_sets.empty?
|
|
@@ -40,7 +53,8 @@ class EstimateAccuracyChart < ChartBase
|
|
|
40
53
|
|
|
41
54
|
def scan_issues
|
|
42
55
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
43
|
-
|
|
56
|
+
@correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
|
|
57
|
+
estimation_units = current_board.estimation_configuration.units
|
|
44
58
|
@has_aging_data = !aging_hash.empty?
|
|
45
59
|
|
|
46
60
|
[
|
|
@@ -53,9 +67,13 @@ class EstimateAccuracyChart < ChartBase
|
|
|
53
67
|
# We sort so that the smaller circles are in front of the bigger circles.
|
|
54
68
|
data = hash.sort(&hash_sorter).collect do |key, values|
|
|
55
69
|
estimate, cycle_time = *key
|
|
56
|
-
|
|
57
|
-
title = [
|
|
58
|
-
|
|
70
|
+
|
|
71
|
+
title = [
|
|
72
|
+
"Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
|
|
73
|
+
"Cycletime: #{label_days(cycle_time)}, " \
|
|
74
|
+
"#{values.size} issues"
|
|
75
|
+
] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
|
|
76
|
+
|
|
59
77
|
{
|
|
60
78
|
'x' => cycle_time,
|
|
61
79
|
'y' => estimate,
|
|
@@ -77,14 +95,25 @@ class EstimateAccuracyChart < ChartBase
|
|
|
77
95
|
end
|
|
78
96
|
end
|
|
79
97
|
|
|
98
|
+
def estimate_label estimate:, estimation_units:
|
|
99
|
+
if @y_axis_type == 'linear'
|
|
100
|
+
if estimation_units == :story_points
|
|
101
|
+
estimate_label = "#{estimate}pts"
|
|
102
|
+
elsif estimation_units == :seconds
|
|
103
|
+
estimate_label = label_days estimate
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
estimate_label = estimate.to_s if estimate_label.nil?
|
|
107
|
+
estimate_label
|
|
108
|
+
end
|
|
109
|
+
|
|
80
110
|
def split_into_completed_and_aging issues:
|
|
81
111
|
aging_hash = {}
|
|
82
112
|
completed_hash = {}
|
|
83
113
|
|
|
84
114
|
issues.each do |issue|
|
|
85
115
|
cycletime = issue.board.cycletime
|
|
86
|
-
start_time = cycletime.
|
|
87
|
-
stop_time = cycletime.stopped_time(issue)
|
|
116
|
+
start_time, stop_time = cycletime.started_stopped_times(issue)
|
|
88
117
|
|
|
89
118
|
next unless start_time
|
|
90
119
|
|
|
@@ -127,14 +156,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
127
156
|
end
|
|
128
157
|
end
|
|
129
158
|
|
|
130
|
-
def
|
|
131
|
-
|
|
159
|
+
def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
|
|
160
|
+
estimate = nil
|
|
161
|
+
|
|
132
162
|
issue.changes.each do |change|
|
|
133
|
-
return
|
|
163
|
+
return estimate if change.time >= start_time
|
|
134
164
|
|
|
135
|
-
|
|
165
|
+
if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
|
|
166
|
+
estimate = change.value
|
|
167
|
+
estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
|
|
168
|
+
end
|
|
136
169
|
end
|
|
137
|
-
|
|
170
|
+
estimate
|
|
138
171
|
end
|
|
139
172
|
|
|
140
173
|
def y_axis label:, sort_order: nil, &block
|
|
@@ -147,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
|
|
|
147
180
|
end
|
|
148
181
|
@y_axis_block = block
|
|
149
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
|
|
150
211
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EstimationConfiguration
|
|
4
|
+
attr_reader :units, :display_name, :field_id
|
|
5
|
+
|
|
6
|
+
def initialize raw:
|
|
7
|
+
@units = :story_points
|
|
8
|
+
@display_name = 'Story Points'
|
|
9
|
+
|
|
10
|
+
# If there wasn't an estimation section they rely on all defaults
|
|
11
|
+
return if raw.nil?
|
|
12
|
+
|
|
13
|
+
if raw['type'] == 'field'
|
|
14
|
+
@field_id = raw['field']['fieldId']
|
|
15
|
+
@display_name = raw['field']['displayName']
|
|
16
|
+
if @field_id == 'timeoriginalestimate'
|
|
17
|
+
@units = :seconds
|
|
18
|
+
@display_name = 'Original estimate'
|
|
19
|
+
end
|
|
20
|
+
elsif raw['type'] == 'issueCount'
|
|
21
|
+
@display_name = 'Issue Count'
|
|
22
|
+
@units = :issue_count
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
# This file is really intended to give you ideas about how you might configure your own reports, not
|
|
4
4
|
# as a complete setup that will work in every case.
|
|
5
5
|
#
|
|
6
|
-
# See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
|
|
7
|
-
#
|
|
8
6
|
# The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
|
|
9
7
|
# S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
|
|
10
8
|
# single team. For that reason, we look at slightly different things that we would on a single team board.
|
|
@@ -12,8 +10,9 @@
|
|
|
12
10
|
class Exporter
|
|
13
11
|
def aggregated_project name:, project_names:, settings: {}
|
|
14
12
|
project name: name do
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
file_system.log name
|
|
14
|
+
file_prefix name
|
|
15
|
+
self.settings.merge! stringify_keys(settings)
|
|
17
16
|
|
|
18
17
|
aggregate do
|
|
19
18
|
project_names.each do |project_name|
|
|
@@ -21,8 +20,6 @@ class Exporter
|
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
file_prefix name
|
|
25
|
-
|
|
26
23
|
file do
|
|
27
24
|
file_suffix '.html'
|
|
28
25
|
issues.reject! do |issue|
|
|
@@ -33,8 +30,8 @@ class Exporter
|
|
|
33
30
|
html '<h1>Boards included in this report</h1><ul>', type: :header
|
|
34
31
|
board_lines = []
|
|
35
32
|
included_projects.each do |project|
|
|
36
|
-
project.all_boards.
|
|
37
|
-
board_lines << "<a href='#{project.
|
|
33
|
+
project.all_boards.each_value do |board|
|
|
34
|
+
board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
|
|
38
35
|
end
|
|
39
36
|
end
|
|
40
37
|
board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
|
|
@@ -64,25 +61,14 @@ class Exporter
|
|
|
64
61
|
|
|
65
62
|
# By default, the issue doesn't show what board it's on and this is important for an
|
|
66
63
|
# aggregated view
|
|
64
|
+
chart = self
|
|
67
65
|
issue_rules do |issue, rules|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
|
|
66
|
+
chart.default_issue_rules.call(issue, rules)
|
|
67
|
+
rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
|
|
71
68
|
end
|
|
72
69
|
|
|
73
70
|
link_rules do |link, rules|
|
|
74
|
-
|
|
75
|
-
case link.name
|
|
76
|
-
when 'Cloners'
|
|
77
|
-
# We don't want to see any clone links at all.
|
|
78
|
-
rules.ignore
|
|
79
|
-
when 'Blocks'
|
|
80
|
-
# For blocks, by default Jira will have links going both
|
|
81
|
-
# ways and we want them only going one way. Also make the
|
|
82
|
-
# link red.
|
|
83
|
-
rules.merge_bidirectional keep: 'outward'
|
|
84
|
-
rules.line_color = 'red'
|
|
85
|
-
end
|
|
71
|
+
chart.default_link_rules.call(link, rules)
|
|
86
72
|
|
|
87
73
|
# Because this is the aggregated view, let's hide any link that doesn't cross boards.
|
|
88
74
|
rules.ignore if link.origin.board == link.other_issue.board
|