jirametrics 2.15 → 2.20
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/anonymizer.rb +8 -6
- data/lib/jirametrics/atlassian_document_format.rb +2 -2
- data/lib/jirametrics/board_config.rb +2 -1
- data/lib/jirametrics/change_item.rb +2 -2
- data/lib/jirametrics/cycletime_config.rb +22 -3
- data/lib/jirametrics/daily_view.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +6 -3
- data/lib/jirametrics/downloader.rb +34 -99
- data/lib/jirametrics/downloader_for_cloud.rb +202 -0
- data/lib/jirametrics/downloader_for_data_center.rb +94 -0
- data/lib/jirametrics/examples/standard_project.rb +9 -9
- data/lib/jirametrics/exporter.rb +10 -5
- data/lib/jirametrics/file_system.rb +24 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +1 -1
- data/lib/jirametrics/html/index.erb +1 -1
- data/lib/jirametrics/html_report_config.rb +3 -1
- data/lib/jirametrics/issue.rb +15 -7
- data/lib/jirametrics/jira_gateway.rb +49 -20
- data/lib/jirametrics/project_config.rb +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics.rb +3 -3
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba3c618918a65132645f5c74d55695694bf54514b60c9a3d42b666592c1d1e39
|
|
4
|
+
data.tar.gz: 39436664f71ad5814f90bd3549520f76d8a1f9d44270338ef3642b5825ad9c76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f777b591e53d721796ef66fd9f107e5853e4f9f5e85178e63b43aa790bc271120596451928b27156694e4d44aee781fa16ac70d80e72e4e0a53f723090e02b70
|
|
7
|
+
data.tar.gz: d9b4392d5b45bdb94bb15530d52ce6dee6caddc8904570ce3d59dfc4dd2ba624344d7c43ed37c7eff433dbe9445f817ecffd4014164e82e4863ebcafeac64c19
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'random-word'
|
|
4
4
|
|
|
5
|
-
class Anonymizer
|
|
5
|
+
class Anonymizer < ChartBase
|
|
6
6
|
# needed for testing
|
|
7
7
|
attr_reader :project_config, :issues
|
|
8
8
|
|
|
9
9
|
def initialize project_config:, date_adjustment: -200
|
|
10
|
+
super()
|
|
10
11
|
@project_config = project_config
|
|
11
12
|
@issues = @project_config.issues
|
|
12
13
|
@all_boards = @project_config.all_boards
|
|
@@ -130,18 +131,19 @@ class Anonymizer
|
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
133
|
|
|
133
|
-
def shift_all_dates
|
|
134
|
-
|
|
134
|
+
def shift_all_dates date_adjustment: @date_adjustment
|
|
135
|
+
adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
|
|
136
|
+
@file_system.log "Shifting all dates by #{label_days date_adjustment}"
|
|
135
137
|
@issues.each do |issue|
|
|
136
138
|
issue.changes.each do |change|
|
|
137
|
-
change.time = change.time +
|
|
139
|
+
change.time = change.time + adjustment_in_seconds
|
|
138
140
|
end
|
|
139
141
|
|
|
140
|
-
issue.raw['fields']['updated'] = (issue.updated +
|
|
142
|
+
issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
range = @project_config.time_range
|
|
144
|
-
@project_config.time_range = (range.begin +
|
|
146
|
+
@project_config.time_range = (range.begin + date_adjustment)..(range.end + date_adjustment)
|
|
145
147
|
end
|
|
146
148
|
|
|
147
149
|
def random_name
|
|
@@ -13,9 +13,9 @@ class AtlassianDocumentFormat
|
|
|
13
13
|
input
|
|
14
14
|
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
15
|
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
16
|
-
.gsub(/\[([
|
|
16
|
+
.gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
17
17
|
.gsub("\n", '<br />')
|
|
18
|
-
elsif input['content'
|
|
18
|
+
elsif input&.[]('content')
|
|
19
19
|
input['content'].collect { |element| adf_node_to_html element }.join("\n")
|
|
20
20
|
else
|
|
21
21
|
# We have an actual ADF document with no content.
|
|
@@ -24,7 +24,8 @@ class BoardConfig
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
@board.cycletime = CycleTimeConfig.new(
|
|
27
|
-
parent_config: self, label: label, block: block, file_system: project_config.file_system
|
|
27
|
+
parent_config: self, label: label, block: block, file_system: project_config.file_system,
|
|
28
|
+
settings: project_config.settings
|
|
28
29
|
)
|
|
29
30
|
end
|
|
30
31
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :
|
|
5
|
-
attr_accessor :value, :old_value
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :author_raw
|
|
5
|
+
attr_accessor :value, :old_value, :time
|
|
6
6
|
|
|
7
7
|
def initialize raw:, author_raw:, time:, artificial: false
|
|
8
8
|
@raw = raw
|
|
@@ -6,12 +6,15 @@ require 'date'
|
|
|
6
6
|
class CycleTimeConfig
|
|
7
7
|
include SelfOrIssueDispatcher
|
|
8
8
|
|
|
9
|
-
attr_reader :label, :parent_config
|
|
9
|
+
attr_reader :label, :parent_config, :settings, :file_system
|
|
10
|
+
|
|
11
|
+
def initialize parent_config:, label:, block:, settings:, file_system: nil, today: Date.today
|
|
10
12
|
|
|
11
|
-
def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
|
|
12
13
|
@parent_config = parent_config
|
|
13
14
|
@label = label
|
|
14
15
|
@today = today
|
|
16
|
+
@settings = settings
|
|
17
|
+
@cache_cycletime_calculations = settings['cache_cycletime_calculations']
|
|
15
18
|
|
|
16
19
|
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
17
20
|
# may make it easier to find problems in the test code ;-)
|
|
@@ -63,6 +66,10 @@ class CycleTimeConfig
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def started_stopped_changes issue
|
|
69
|
+
cache_key = "#{issue.key}:#{issue.board.id}"
|
|
70
|
+
last_result = (@cache ||= {})[cache_key]
|
|
71
|
+
return *last_result if last_result && @cache_cycletime_calculations
|
|
72
|
+
|
|
66
73
|
started = @start_at.call(issue)
|
|
67
74
|
stopped = @stop_at.call(issue)
|
|
68
75
|
|
|
@@ -80,7 +87,15 @@ class CycleTimeConfig
|
|
|
80
87
|
# for the start and not have it conflict.
|
|
81
88
|
started = nil if started&.time == stopped&.time
|
|
82
89
|
|
|
83
|
-
[started, stopped]
|
|
90
|
+
result = [started, stopped]
|
|
91
|
+
if last_result && result != last_result
|
|
92
|
+
@file_system.error(
|
|
93
|
+
"Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
|
|
94
|
+
"previous=#{last_result.inspect}"
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
@cache[cache_key] = result
|
|
98
|
+
result
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
def started_stopped_times issue
|
|
@@ -88,6 +103,10 @@ class CycleTimeConfig
|
|
|
88
103
|
[started&.time, stopped&.time]
|
|
89
104
|
end
|
|
90
105
|
|
|
106
|
+
def flush_cache
|
|
107
|
+
@cache = nil
|
|
108
|
+
end
|
|
109
|
+
|
|
91
110
|
def started_stopped_dates issue
|
|
92
111
|
started_time, stopped_time = started_stopped_times(issue)
|
|
93
112
|
[started_time&.to_date, stopped_time&.to_date]
|
|
@@ -181,7 +181,7 @@ class DailyView < ChartBase
|
|
|
181
181
|
table = +''
|
|
182
182
|
table << '<table>'
|
|
183
183
|
history.each do |c|
|
|
184
|
-
time = c.time.strftime '%b %d, %I:%M%P'
|
|
184
|
+
time = c.time.strftime '%b %d, %Y @ %I:%M%P'
|
|
185
185
|
|
|
186
186
|
table << '<tr>'
|
|
187
187
|
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
@@ -410,14 +410,17 @@ class DataQualityReport < ChartBase
|
|
|
410
410
|
def render_status_not_on_board problems
|
|
411
411
|
<<-HTML
|
|
412
412
|
#{label_issues problems.size} were not visible on the board for some period of time. This may impact
|
|
413
|
-
timings as the work was likely to have been forgotten if it wasn't visible.
|
|
413
|
+
timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
|
|
414
|
+
mean in this context? The issue was in a status that is not mapped to any visible column on the board.
|
|
415
|
+
Look in "unmapped statuses" on your board.
|
|
414
416
|
HTML
|
|
415
417
|
end
|
|
416
418
|
|
|
417
419
|
def render_created_in_wrong_status problems
|
|
418
420
|
<<-HTML
|
|
419
|
-
#{label_issues problems.size} were created in a status not
|
|
420
|
-
|
|
421
|
+
#{label_issues problems.size} were created in a status that is not considered to be some varient
|
|
422
|
+
of To Do. Most likely this means that the issue was created from one of the columns on the board,
|
|
423
|
+
rather than in the backlog. Why Jira allows this is still a mystery.
|
|
421
424
|
HTML
|
|
422
425
|
end
|
|
423
426
|
|
|
@@ -3,8 +3,29 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
|
+
class DownloadIssueData
|
|
7
|
+
attr_accessor :key, :found_in_primary_query, :last_modified,
|
|
8
|
+
:up_to_date, :cache_path, :issue
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
key:,
|
|
12
|
+
found_in_primary_query: true,
|
|
13
|
+
last_modified: nil,
|
|
14
|
+
up_to_date: true,
|
|
15
|
+
cache_path: nil,
|
|
16
|
+
issue: nil
|
|
17
|
+
)
|
|
18
|
+
@key = key
|
|
19
|
+
@found_in_primary_query = found_in_primary_query
|
|
20
|
+
@last_modified = last_modified
|
|
21
|
+
@up_to_date = up_to_date
|
|
22
|
+
@cache_path = cache_path
|
|
23
|
+
@issue = issue
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
6
27
|
class Downloader
|
|
7
|
-
CURRENT_METADATA_VERSION =
|
|
28
|
+
CURRENT_METADATA_VERSION = 5
|
|
8
29
|
|
|
9
30
|
attr_accessor :metadata
|
|
10
31
|
attr_reader :file_system
|
|
@@ -12,6 +33,15 @@ class Downloader
|
|
|
12
33
|
# For testing only
|
|
13
34
|
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
14
35
|
|
|
36
|
+
def self.create download_config:, file_system:, jira_gateway:
|
|
37
|
+
is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
|
|
38
|
+
(is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
|
|
39
|
+
download_config: download_config,
|
|
40
|
+
file_system: file_system,
|
|
41
|
+
jira_gateway: jira_gateway
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
15
45
|
def initialize download_config:, file_system:, jira_gateway:
|
|
16
46
|
@metadata = {}
|
|
17
47
|
@download_config = download_config
|
|
@@ -28,7 +58,6 @@ class Downloader
|
|
|
28
58
|
log '', both: true
|
|
29
59
|
log @download_config.project_config.name, both: true
|
|
30
60
|
|
|
31
|
-
init_gateway
|
|
32
61
|
load_metadata
|
|
33
62
|
|
|
34
63
|
if @metadata['no-download']
|
|
@@ -50,11 +79,6 @@ class Downloader
|
|
|
50
79
|
save_metadata
|
|
51
80
|
end
|
|
52
81
|
|
|
53
|
-
def init_gateway
|
|
54
|
-
@jira_gateway.load_jira_config(@download_config.project_config.jira_config)
|
|
55
|
-
@jira_gateway.ignore_ssl_errors = @download_config.project_config.settings['ignore_ssl_errors']
|
|
56
|
-
end
|
|
57
|
-
|
|
58
82
|
def log text, both: false
|
|
59
83
|
@file_system.log text, also_write_to_stderr: both
|
|
60
84
|
end
|
|
@@ -66,93 +90,6 @@ class Downloader
|
|
|
66
90
|
ids
|
|
67
91
|
end
|
|
68
92
|
|
|
69
|
-
def download_issues board:
|
|
70
|
-
log " Downloading primary issues for board #{board.id}", both: true
|
|
71
|
-
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
72
|
-
unless Dir.exist?(path)
|
|
73
|
-
log " Creating path #{path}"
|
|
74
|
-
Dir.mkdir(path)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
filter_id = @board_id_to_filter_id[board.id]
|
|
78
|
-
jql = make_jql(filter_id: filter_id)
|
|
79
|
-
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
80
|
-
|
|
81
|
-
log " Downloading linked issues for board #{board.id}", both: true
|
|
82
|
-
loop do
|
|
83
|
-
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
84
|
-
break if @issue_keys_pending_download.empty?
|
|
85
|
-
|
|
86
|
-
keys_to_request = @issue_keys_pending_download[0..99]
|
|
87
|
-
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
88
|
-
jql = "key in (#{keys_to_request.join(', ')})"
|
|
89
|
-
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
94
|
-
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
95
|
-
jql = intercept_jql.call jql if intercept_jql
|
|
96
|
-
|
|
97
|
-
log " JQL: #{jql}"
|
|
98
|
-
escaped_jql = CGI.escape jql
|
|
99
|
-
|
|
100
|
-
if @jira_gateway.cloud?
|
|
101
|
-
max_results = 5_000 # The maximum allowed by Jira
|
|
102
|
-
next_page_token = nil
|
|
103
|
-
issue_count = 0
|
|
104
|
-
|
|
105
|
-
loop do
|
|
106
|
-
json = @jira_gateway.call_url relative_url: '/rest/api/3/search/jql' \
|
|
107
|
-
"?jql=#{escaped_jql}&maxResults=#{max_results}&" \
|
|
108
|
-
"nextPageToken=#{next_page_token}&expand=changelog&fields=*all"
|
|
109
|
-
next_page_token = json['nextPageToken']
|
|
110
|
-
|
|
111
|
-
json['issues'].each do |issue_json|
|
|
112
|
-
issue_json['exporter'] = {
|
|
113
|
-
'in_initial_query' => initial_query
|
|
114
|
-
}
|
|
115
|
-
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
116
|
-
file = "#{issue_json['key']}-#{board.id}.json"
|
|
117
|
-
|
|
118
|
-
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
119
|
-
issue_count += 1
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
message = " Downloaded #{issue_count} issues"
|
|
123
|
-
log message, both: true
|
|
124
|
-
|
|
125
|
-
break unless next_page_token
|
|
126
|
-
end
|
|
127
|
-
else
|
|
128
|
-
max_results = 100
|
|
129
|
-
start_at = 0
|
|
130
|
-
total = 1
|
|
131
|
-
while start_at < total
|
|
132
|
-
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
133
|
-
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
134
|
-
|
|
135
|
-
json['issues'].each do |issue_json|
|
|
136
|
-
issue_json['exporter'] = {
|
|
137
|
-
'in_initial_query' => initial_query
|
|
138
|
-
}
|
|
139
|
-
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
140
|
-
file = "#{issue_json['key']}-#{board.id}.json"
|
|
141
|
-
|
|
142
|
-
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
total = json['total'].to_i
|
|
146
|
-
max_results = json['maxResults']
|
|
147
|
-
|
|
148
|
-
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
149
|
-
log message, both: true
|
|
150
|
-
|
|
151
|
-
start_at += json['issues'].size
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
93
|
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
|
157
94
|
issue = Issue.new raw: raw_issue, board: board
|
|
158
95
|
@issue_keys_downloaded_in_current_run << issue.key
|
|
@@ -178,6 +115,8 @@ class Downloader
|
|
|
178
115
|
end
|
|
179
116
|
|
|
180
117
|
def download_users
|
|
118
|
+
return unless @jira_gateway.cloud?
|
|
119
|
+
|
|
181
120
|
log ' Downloading all users', both: true
|
|
182
121
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
183
122
|
|
|
@@ -327,11 +266,7 @@ class Downloader
|
|
|
327
266
|
|
|
328
267
|
if start_date
|
|
329
268
|
@download_date_range = start_date..today.to_date
|
|
330
|
-
|
|
331
|
-
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
332
|
-
# beginning of the full range.
|
|
333
|
-
@start_date_in_query = metadata['date_end'] || @download_date_range.begin
|
|
334
|
-
log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
|
|
269
|
+
@start_date_in_query = @download_date_range.begin
|
|
335
270
|
|
|
336
271
|
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
337
272
|
# had an update during the range.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DownloaderForCloud < Downloader
|
|
4
|
+
def jira_instance_type
|
|
5
|
+
'Jira Cloud'
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def search_for_issues jql:, board_id:, path:
|
|
9
|
+
log " JQL: #{jql}"
|
|
10
|
+
escaped_jql = CGI.escape jql
|
|
11
|
+
|
|
12
|
+
hash = {}
|
|
13
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
14
|
+
next_page_token = nil
|
|
15
|
+
issue_count = 0
|
|
16
|
+
|
|
17
|
+
loop do
|
|
18
|
+
relative_url = +''
|
|
19
|
+
relative_url << '/rest/api/3/search/jql'
|
|
20
|
+
relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
|
|
21
|
+
relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
|
|
22
|
+
relative_url << '&fields=updated'
|
|
23
|
+
|
|
24
|
+
json = @jira_gateway.call_url relative_url: relative_url
|
|
25
|
+
next_page_token = json['nextPageToken']
|
|
26
|
+
|
|
27
|
+
json['issues'].each do |i|
|
|
28
|
+
key = i['key']
|
|
29
|
+
data = DownloadIssueData.new key: key
|
|
30
|
+
data.key = key
|
|
31
|
+
data.last_modified = Time.parse i['fields']['updated']
|
|
32
|
+
data.found_in_primary_query = true
|
|
33
|
+
data.cache_path = File.join(path, "#{key}-#{board_id}.json")
|
|
34
|
+
data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
|
|
35
|
+
hash[key] = data
|
|
36
|
+
issue_count += 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
message = " Found #{issue_count} issues"
|
|
40
|
+
log message, both: true
|
|
41
|
+
|
|
42
|
+
break unless next_page_token
|
|
43
|
+
end
|
|
44
|
+
hash
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bulk_fetch_issues issue_datas:, board:, in_initial_query:
|
|
48
|
+
# We used to use the expand option to pull in the changelog directly. Unfortunately
|
|
49
|
+
# that only returns the "recent" changes, not all of them. So now we get the issue
|
|
50
|
+
# without changes and then make a second call for that changes. Then we insert it
|
|
51
|
+
# into the raw issue as if it had been there all along.
|
|
52
|
+
log " Downloading #{issue_datas.size} issues", both: true
|
|
53
|
+
payload = {
|
|
54
|
+
'fields' => ['*all'],
|
|
55
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key)
|
|
56
|
+
}
|
|
57
|
+
response = @jira_gateway.post_request(
|
|
58
|
+
relative_url: '/rest/api/3/issue/bulkfetch',
|
|
59
|
+
payload: JSON.generate(payload)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
|
|
63
|
+
|
|
64
|
+
response['issues'].each do |issue_json|
|
|
65
|
+
issue_json['exporter'] = {
|
|
66
|
+
'in_initial_query' => in_initial_query
|
|
67
|
+
}
|
|
68
|
+
issue = Issue.new(raw: issue_json, board: board)
|
|
69
|
+
data = issue_datas.find { |d| d.key == issue.key }
|
|
70
|
+
data.up_to_date = true
|
|
71
|
+
data.last_modified = issue.updated
|
|
72
|
+
data.issue = issue
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
issue_datas
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attach_changelog_to_issues issue_datas:, issue_jsons:
|
|
79
|
+
max_results = 10_000 # The max jira accepts is 10K
|
|
80
|
+
payload = {
|
|
81
|
+
'issueIdsOrKeys' => issue_datas.collect(&:key),
|
|
82
|
+
'maxResults' => max_results
|
|
83
|
+
}
|
|
84
|
+
loop do
|
|
85
|
+
response = @jira_gateway.post_request(
|
|
86
|
+
relative_url: '/rest/api/3/changelog/bulkfetch',
|
|
87
|
+
payload: JSON.generate(payload)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
response['issueChangeLogs'].each do |issue_change_log|
|
|
91
|
+
issue_id = issue_change_log['issueId']
|
|
92
|
+
json = issue_jsons.find { |json| json['id'] == issue_id }
|
|
93
|
+
|
|
94
|
+
unless json['changelog']
|
|
95
|
+
# If this is our first time in, there won't be a changelog section
|
|
96
|
+
json['changelog'] = {
|
|
97
|
+
'startAt' => 0,
|
|
98
|
+
'maxResults' => max_results,
|
|
99
|
+
'total' => 0,
|
|
100
|
+
'histories' => []
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
new_changes = issue_change_log['changeHistories']
|
|
105
|
+
json['changelog']['total'] += new_changes.size
|
|
106
|
+
json['changelog']['histories'] += new_changes
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
next_page_token = response['nextPageToken']
|
|
110
|
+
payload['nextPageToken'] = next_page_token
|
|
111
|
+
break if next_page_token.nil?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def download_issues board:
|
|
116
|
+
log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
|
|
117
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
118
|
+
unless @file_system.dir_exist?(path)
|
|
119
|
+
log " Creating path #{path}"
|
|
120
|
+
@file_system.mkdir(path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
124
|
+
jql = make_jql(filter_id: filter_id)
|
|
125
|
+
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
126
|
+
jql = intercept_jql.call jql if intercept_jql
|
|
127
|
+
|
|
128
|
+
issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
|
|
129
|
+
|
|
130
|
+
loop do
|
|
131
|
+
related_issue_keys = Set.new
|
|
132
|
+
issue_data_hash
|
|
133
|
+
.values
|
|
134
|
+
.reject { |data| data.up_to_date }
|
|
135
|
+
.each_slice(100) do |slice|
|
|
136
|
+
slice = bulk_fetch_issues(
|
|
137
|
+
issue_datas: slice, board: board, in_initial_query: true
|
|
138
|
+
)
|
|
139
|
+
slice.each do |data|
|
|
140
|
+
@file_system.save_json(
|
|
141
|
+
json: data.issue.raw, filename: data.cache_path
|
|
142
|
+
)
|
|
143
|
+
# Set the timestamp on the file to match the updated one so that we don't have
|
|
144
|
+
# to parse the file just to find the timestamp
|
|
145
|
+
@file_system.utime time: data.issue.updated, file: data.cache_path
|
|
146
|
+
|
|
147
|
+
issue = data.issue
|
|
148
|
+
next unless issue
|
|
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
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Remove all the ones we already downloaded
|
|
161
|
+
related_issue_keys.reject! { |key| issue_data_hash[key] }
|
|
162
|
+
|
|
163
|
+
related_issue_keys.each do |key|
|
|
164
|
+
data = DownloadIssueData.new key: key
|
|
165
|
+
data.found_in_primary_query = false
|
|
166
|
+
data.up_to_date = false
|
|
167
|
+
data.cache_path = File.join(path, "#{key}-#{board.id}.json")
|
|
168
|
+
issue_data_hash[key] = data
|
|
169
|
+
end
|
|
170
|
+
break if related_issue_keys.empty?
|
|
171
|
+
|
|
172
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
delete_issues_from_cache_that_are_not_in_server(
|
|
176
|
+
issue_data_hash: issue_data_hash, path: path
|
|
177
|
+
)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
|
|
181
|
+
# The gotcha with deleted issues is that they just stop being returned in queries
|
|
182
|
+
# and we have no way to know that they should be removed from our local cache.
|
|
183
|
+
# With the new approach, we ask for every issue that Jira knows about (within
|
|
184
|
+
# the parameters of the query) and then delete anything that's in our local cache
|
|
185
|
+
# but wasn't returned.
|
|
186
|
+
@file_system.foreach path do |file|
|
|
187
|
+
next if file.start_with? '.'
|
|
188
|
+
unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
|
|
189
|
+
raise "Unexpected filename in #{path}: #{file}"
|
|
190
|
+
end
|
|
191
|
+
next if issue_data_hash[key] # Still in Jira
|
|
192
|
+
|
|
193
|
+
file_to_delete = File.join(path, file)
|
|
194
|
+
log " Removing #{file_to_delete} from local cache"
|
|
195
|
+
file_system.unlink file_to_delete
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def last_modified filename:
|
|
200
|
+
File.mtime(filename) if File.exist?(filename)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
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: false, 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: Date.today
|
|
67
|
+
segments = []
|
|
68
|
+
segments << "filter=#{filter_id}"
|
|
69
|
+
|
|
70
|
+
start_date = @download_config.start_date today: today
|
|
71
|
+
|
|
72
|
+
if start_date
|
|
73
|
+
@download_date_range = start_date..today.to_date
|
|
74
|
+
|
|
75
|
+
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
76
|
+
# beginning of the full range.
|
|
77
|
+
@start_date_in_query = metadata['date_end'] || @download_date_range.begin
|
|
78
|
+
log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
|
|
79
|
+
|
|
80
|
+
# Catch-all to pick up anything that's been around since before the range started but hasn't
|
|
81
|
+
# had an update during the range.
|
|
82
|
+
catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
|
|
83
|
+
|
|
84
|
+
# Pick up any issues that had a status change in the range
|
|
85
|
+
start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
|
|
86
|
+
# find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
|
|
87
|
+
find_in_range = %(updated >= "#{start_date_text} 00:00")
|
|
88
|
+
|
|
89
|
+
segments << "(#{find_in_range} OR #{catch_all})"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
segments.join ' AND '
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -15,15 +15,6 @@ class Exporter
|
|
|
15
15
|
self.anonymize if anonymize
|
|
16
16
|
self.settings.merge! settings
|
|
17
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
|
|
26
|
-
|
|
27
18
|
boards.each_key do |board_id|
|
|
28
19
|
block = boards[board_id]
|
|
29
20
|
if block == :default
|
|
@@ -37,6 +28,15 @@ 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
|
+
end
|
|
39
|
+
|
|
40
40
|
issues.reject! do |issue|
|
|
41
41
|
ignore_types.include? issue.type
|
|
42
42
|
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -50,24 +50,29 @@ class Exporter
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
project.download_config.run
|
|
53
|
-
|
|
53
|
+
# load_jira_config(download_config.project_config.jira_config)
|
|
54
|
+
# @ignore_ssl_errors = download_config.project_config.settings['ignore_ssl_errors']
|
|
55
|
+
gateway = JiraGateway.new(
|
|
56
|
+
file_system: file_system, jira_config: project.jira_config, settings: project.settings
|
|
57
|
+
)
|
|
58
|
+
downloader = Downloader.create(
|
|
54
59
|
download_config: project.download_config,
|
|
55
60
|
file_system: file_system,
|
|
56
|
-
jira_gateway:
|
|
61
|
+
jira_gateway: gateway
|
|
57
62
|
)
|
|
58
63
|
downloader.run
|
|
59
64
|
end
|
|
60
65
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
61
66
|
end
|
|
62
67
|
|
|
63
|
-
def info
|
|
68
|
+
def info key, name_filter:
|
|
64
69
|
selected = []
|
|
65
70
|
each_project_config(name_filter: name_filter) do |project|
|
|
66
71
|
project.evaluate_next_level
|
|
67
72
|
|
|
68
73
|
project.run load_only: true
|
|
69
74
|
project.issues.each do |issue|
|
|
70
|
-
selected << [project, issue] if
|
|
75
|
+
selected << [project, issue] if key == issue.key
|
|
71
76
|
end
|
|
72
77
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
78
|
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
@@ -76,7 +81,7 @@ class Exporter
|
|
|
76
81
|
end
|
|
77
82
|
|
|
78
83
|
if selected.empty?
|
|
79
|
-
file_system.log "No issues found to match #{
|
|
84
|
+
file_system.log "No issues found to match #{key.inspect}"
|
|
80
85
|
else
|
|
81
86
|
selected.each do |project, issue|
|
|
82
87
|
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
@@ -5,6 +5,13 @@ require 'json'
|
|
|
5
5
|
class FileSystem
|
|
6
6
|
attr_accessor :logfile, :logfile_name
|
|
7
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
|
+
end
|
|
14
|
+
|
|
8
15
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
16
|
def load filename, supress_deprecation: false
|
|
10
17
|
if filename.end_with?('.json') && !supress_deprecation
|
|
@@ -31,6 +38,14 @@ class FileSystem
|
|
|
31
38
|
File.write(filename, content)
|
|
32
39
|
end
|
|
33
40
|
|
|
41
|
+
def mkdir path
|
|
42
|
+
FileUtils.mkdir_p path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def utime file:, time:
|
|
46
|
+
File.utime time, time, file
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def warning message, more: nil
|
|
35
50
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
51
|
end
|
|
@@ -66,7 +81,15 @@ class FileSystem
|
|
|
66
81
|
end
|
|
67
82
|
|
|
68
83
|
def file_exist? filename
|
|
69
|
-
File.exist? filename
|
|
84
|
+
File.exist?(filename) && File.file?(filename)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dir_exist? path
|
|
88
|
+
File.exist?(path) && File.directory?(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unlink filename
|
|
92
|
+
File.unlink filename
|
|
70
93
|
end
|
|
71
94
|
|
|
72
95
|
def deprecated message:, date:, depth: 2
|
|
@@ -40,7 +40,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
40
40
|
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
41
41
|
z: 1 // draw the grid lines on top of the bars
|
|
42
42
|
},
|
|
43
|
-
stacked:
|
|
43
|
+
stacked: false,
|
|
44
44
|
max: <%= (@max_age * 1.1).to_i %>
|
|
45
45
|
}
|
|
46
46
|
},
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
|
|
6
6
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
7
7
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
|
|
8
|
-
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.
|
|
8
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
|
|
9
9
|
<script type="text/javascript">
|
|
10
10
|
<%= javascript %>
|
|
11
11
|
</script>
|
|
@@ -51,7 +51,9 @@ class HtmlReportConfig
|
|
|
51
51
|
@file_config.project_config.all_boards.each_value do |board|
|
|
52
52
|
raise 'Multiple cycletimes not supported' if board.cycletime
|
|
53
53
|
|
|
54
|
-
board.cycletime = CycleTimeConfig.new(
|
|
54
|
+
board.cycletime = CycleTimeConfig.new(
|
|
55
|
+
parent_config: self, label: label, block: block, file_system: file_system, settings: settings
|
|
56
|
+
)
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -19,9 +19,10 @@ class Issue
|
|
|
19
19
|
|
|
20
20
|
# There are cases where we create an Issue of fragments like linked issues and those won't have
|
|
21
21
|
# changelogs.
|
|
22
|
-
|
|
22
|
+
load_history_into_changes if @raw['changelog']
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# As above with fragments, there may not be a fields section
|
|
25
|
+
return unless @raw['fields']
|
|
25
26
|
|
|
26
27
|
# If this is an older pull of data then comments may not be there.
|
|
27
28
|
load_comments_into_changes if @raw['fields']['comment']
|
|
@@ -152,7 +153,7 @@ class Issue
|
|
|
152
153
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
153
154
|
def currently_in_status *status_names
|
|
154
155
|
change = most_recent_status_change
|
|
155
|
-
return
|
|
156
|
+
return nil if change.nil?
|
|
156
157
|
|
|
157
158
|
change if change.current_status_matches(*status_names)
|
|
158
159
|
end
|
|
@@ -162,7 +163,7 @@ class Issue
|
|
|
162
163
|
category_ids = find_status_category_ids_by_names category_names
|
|
163
164
|
|
|
164
165
|
change = most_recent_status_change
|
|
165
|
-
return
|
|
166
|
+
return nil if change.nil?
|
|
166
167
|
|
|
167
168
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
168
169
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -212,7 +213,11 @@ class Issue
|
|
|
212
213
|
end
|
|
213
214
|
|
|
214
215
|
def parse_time text
|
|
215
|
-
|
|
216
|
+
if text.is_a? String
|
|
217
|
+
Time.parse(text).getlocal(@timezone_offset)
|
|
218
|
+
else
|
|
219
|
+
Time.at(text / 1000).getlocal(@timezone_offset)
|
|
220
|
+
end
|
|
216
221
|
end
|
|
217
222
|
|
|
218
223
|
def created
|
|
@@ -233,11 +238,11 @@ class Issue
|
|
|
233
238
|
end
|
|
234
239
|
|
|
235
240
|
def assigned_to
|
|
236
|
-
@raw['fields']
|
|
241
|
+
@raw['fields']['assignee']&.[]('displayName')
|
|
237
242
|
end
|
|
238
243
|
|
|
239
244
|
def assigned_to_icon_url
|
|
240
|
-
@raw['fields']
|
|
245
|
+
@raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
241
246
|
end
|
|
242
247
|
|
|
243
248
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -759,6 +764,9 @@ class Issue
|
|
|
759
764
|
first_status = nil
|
|
760
765
|
first_status_id = nil
|
|
761
766
|
|
|
767
|
+
# There won't be a created timestamp in cases where this was a linked issue
|
|
768
|
+
return unless @raw['fields']['created']
|
|
769
|
+
|
|
762
770
|
created_time = parse_time @raw['fields']['created']
|
|
763
771
|
first_change = @changes.find { |change| change.field == field_name }
|
|
764
772
|
if first_change.nil?
|
|
@@ -3,17 +3,55 @@
|
|
|
3
3
|
require 'cgi'
|
|
4
4
|
require 'json'
|
|
5
5
|
require 'English'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
class JiraGateway
|
|
8
|
-
attr_accessor :ignore_ssl_errors
|
|
9
|
+
attr_accessor :ignore_ssl_errors
|
|
10
|
+
attr_reader :jira_url, :settings, :file_system
|
|
9
11
|
|
|
10
|
-
def initialize file_system:
|
|
12
|
+
def initialize file_system:, jira_config:, settings:
|
|
11
13
|
@file_system = file_system
|
|
14
|
+
load_jira_config(jira_config)
|
|
15
|
+
@settings = settings
|
|
16
|
+
@ignore_ssl_errors = settings['ignore_ssl_errors']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def post_request relative_url:, payload:
|
|
20
|
+
command = make_curl_command url: "#{@jira_url}#{relative_url}", method: 'POST'
|
|
21
|
+
exec_and_parse_response command: command, stdin_data: payload
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exec_and_parse_response command:, stdin_data:
|
|
25
|
+
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
26
|
+
log_entry = sanitize_message log_entry
|
|
27
|
+
@file_system.log log_entry
|
|
28
|
+
|
|
29
|
+
stdout, stderr, status = capture3(command, stdin_data: stdin_data)
|
|
30
|
+
unless status.success?
|
|
31
|
+
@file_system.log "Failed call with exit status #{status.exitstatus}!"
|
|
32
|
+
@file_system.log "Returned (stdout): #{stdout.inspect}"
|
|
33
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}"
|
|
34
|
+
raise "Failed call with exit status #{status.exitstatus}. " \
|
|
35
|
+
"See #{@file_system.logfile_name} for details"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@file_system.log "Returned (stderr): #{stderr.inspect}" unless stderr == ''
|
|
39
|
+
raise 'no response from curl on stdout' if stdout == ''
|
|
40
|
+
|
|
41
|
+
parse_response(command: command, result: stdout)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capture3 command, stdin_data:
|
|
45
|
+
# In it's own method so we can mock it out in tests
|
|
46
|
+
Open3.capture3(command, stdin_data: stdin_data)
|
|
12
47
|
end
|
|
13
48
|
|
|
14
49
|
def call_url relative_url:
|
|
15
50
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
|
-
|
|
51
|
+
exec_and_parse_response command: command, stdin_data: nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse_response command:, result:
|
|
17
55
|
begin
|
|
18
56
|
json = JSON.parse(result)
|
|
19
57
|
rescue # rubocop:disable Style/RescueStandardError
|
|
@@ -31,21 +69,7 @@ class JiraGateway
|
|
|
31
69
|
token = @jira_api_token || @jira_personal_access_token
|
|
32
70
|
raise 'Neither Jira API Token or personal access token has been set' unless token
|
|
33
71
|
|
|
34
|
-
message.gsub(
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def call_command command
|
|
38
|
-
log_entry = " #{command.gsub(/\s+/, ' ')}"
|
|
39
|
-
log_entry = sanitize_message log_entry if @jira_api_token
|
|
40
|
-
@file_system.log log_entry
|
|
41
|
-
|
|
42
|
-
result = `#{command}`
|
|
43
|
-
@file_system.log result unless $CHILD_STATUS.success?
|
|
44
|
-
return result if $CHILD_STATUS.success?
|
|
45
|
-
|
|
46
|
-
@file_system.log "Failed call with exit status #{$CHILD_STATUS.exitstatus}."
|
|
47
|
-
raise "Failed call with exit status #{$CHILD_STATUS.exitstatus}. " \
|
|
48
|
-
"See #{@file_system.logfile_name} for details"
|
|
72
|
+
message.gsub(token, '[API_TOKEN]')
|
|
49
73
|
end
|
|
50
74
|
|
|
51
75
|
def load_jira_config jira_config
|
|
@@ -65,7 +89,7 @@ class JiraGateway
|
|
|
65
89
|
@cookies = (jira_config['cookies'] || []).collect { |key, value| "#{key}=#{value}" }.join(';')
|
|
66
90
|
end
|
|
67
91
|
|
|
68
|
-
def make_curl_command url:
|
|
92
|
+
def make_curl_command url:, method: 'GET'
|
|
69
93
|
command = +''
|
|
70
94
|
command << 'curl'
|
|
71
95
|
command << ' -L' # follow redirects
|
|
@@ -74,8 +98,13 @@ class JiraGateway
|
|
|
74
98
|
command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
|
|
75
99
|
command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
|
|
76
100
|
command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
|
|
77
|
-
command <<
|
|
101
|
+
command << " --request #{method}"
|
|
102
|
+
if method == 'POST'
|
|
103
|
+
command << ' --data @-'
|
|
104
|
+
command << ' --header "Content-Type: application/json"'
|
|
105
|
+
end
|
|
78
106
|
command << ' --header "Accept: application/json"'
|
|
107
|
+
command << ' --show-error --fail' # Better diagnostics when the server returns an error
|
|
79
108
|
command << " --url \"#{url}\""
|
|
80
109
|
command
|
|
81
110
|
end
|
|
@@ -549,6 +549,7 @@ class ProjectConfig
|
|
|
549
549
|
end
|
|
550
550
|
|
|
551
551
|
def discard_changes_before status_becomes: nil, &block
|
|
552
|
+
cycletimes_touched = Set.new
|
|
552
553
|
if status_becomes
|
|
553
554
|
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
554
555
|
|
|
@@ -581,6 +582,7 @@ class ProjectConfig
|
|
|
581
582
|
next if original_start_time.nil?
|
|
582
583
|
|
|
583
584
|
issue.discard_changes_before cutoff_time
|
|
585
|
+
cycletimes_touched << issue.board.cycletime
|
|
584
586
|
|
|
585
587
|
next unless cutoff_time
|
|
586
588
|
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
@@ -591,5 +593,7 @@ class ProjectConfig
|
|
|
591
593
|
issue: issue
|
|
592
594
|
}
|
|
593
595
|
end
|
|
596
|
+
|
|
597
|
+
cycletimes_touched.each { |c| c.flush_cache }
|
|
594
598
|
end
|
|
595
599
|
end
|
|
@@ -7,5 +7,7 @@
|
|
|
7
7
|
"flagged_means_blocked": true,
|
|
8
8
|
|
|
9
9
|
"expedited_priority_names": ["Critical", "Highest"],
|
|
10
|
-
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
|
|
10
|
+
"priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
|
|
11
|
+
|
|
12
|
+
"cache_cycletime_calculations": true
|
|
11
13
|
}
|
data/lib/jirametrics.rb
CHANGED
|
@@ -4,7 +4,7 @@ require 'thor'
|
|
|
4
4
|
require 'require_all'
|
|
5
5
|
|
|
6
6
|
# This one does need to be loaded early. The rest will be loaded later.
|
|
7
|
-
require '
|
|
7
|
+
require 'jirametrics/file_system'
|
|
8
8
|
|
|
9
9
|
class JiraMetrics < Thor
|
|
10
10
|
def self.exit_on_failure?
|
|
@@ -47,9 +47,9 @@ class JiraMetrics < Thor
|
|
|
47
47
|
|
|
48
48
|
option :config
|
|
49
49
|
desc 'info', 'Dump information about one issue'
|
|
50
|
-
def info
|
|
50
|
+
def info key
|
|
51
51
|
load_config options[:config]
|
|
52
|
-
Exporter.instance.info(
|
|
52
|
+
Exporter.instance.info(key, name_filter: options[:name] || '*')
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
no_commands do
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jirametrics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: '2.
|
|
4
|
+
version: '2.20'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Bowler
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: random-word
|
|
@@ -87,6 +87,8 @@ files:
|
|
|
87
87
|
- lib/jirametrics/dependency_chart.rb
|
|
88
88
|
- lib/jirametrics/download_config.rb
|
|
89
89
|
- lib/jirametrics/downloader.rb
|
|
90
|
+
- lib/jirametrics/downloader_for_cloud.rb
|
|
91
|
+
- lib/jirametrics/downloader_for_data_center.rb
|
|
90
92
|
- lib/jirametrics/estimate_accuracy_chart.rb
|
|
91
93
|
- lib/jirametrics/estimation_configuration.rb
|
|
92
94
|
- lib/jirametrics/examples/aggregated_project.rb
|
|
@@ -157,7 +159,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
157
159
|
- !ruby/object:Gem::Version
|
|
158
160
|
version: '0'
|
|
159
161
|
requirements: []
|
|
160
|
-
rubygems_version: 3.6.
|
|
162
|
+
rubygems_version: 3.6.9
|
|
161
163
|
specification_version: 4
|
|
162
164
|
summary: Extract Jira metrics
|
|
163
165
|
test_files: []
|