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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8045db95ffbb1368c3df8be3ee88c88ddb1a3d2fee46c989ba9d94e8fdb4ac3
4
- data.tar.gz: a4a245da182c96238ec99918eefaf097c3fcc3cd3f4a09f85d4fbdd25020b713
3
+ metadata.gz: ba3c618918a65132645f5c74d55695694bf54514b60c9a3d42b666592c1d1e39
4
+ data.tar.gz: 39436664f71ad5814f90bd3549520f76d8a1f9d44270338ef3642b5825ad9c76
5
5
  SHA512:
6
- metadata.gz: cb301db36a63ec0e2da49c5f7ce1f7c58ee44f9c1ab1dad7811667a72d598a8a36282bccc795bebf755706ea1b0d1f0ebdf092f75fe89dc128203645182b9a30
7
- data.tar.gz: e1d08efa142f9d824621831cfc465fd6d17ba3c374f0259d860798feec2706214989c9eac9e41c5eafbd66be0d4c147c29bdec638eec7e956b87858384bf6bb8
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
- @file_system.log "Shifting all dates by #{@date_adjustment} days"
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 + @date_adjustment
139
+ change.time = change.time + adjustment_in_seconds
138
140
  end
139
141
 
140
- issue.raw['fields']['updated'] = (issue.updated + @date_adjustment).to_s
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 + @date_adjustment)..(range.end + @date_adjustment)
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(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
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, :time, :author_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 designated as Backlog. This will impact
420
- the measurement of start times and will therefore impact whether it's shown as in progress or not.
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 = 4
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
@@ -50,24 +50,29 @@ class Exporter
50
50
  end
51
51
 
52
52
  project.download_config.run
53
- downloader = Downloader.new(
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: JiraGateway.new(file_system: file_system)
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 keys, name_filter:
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 keys.include? issue.key
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 #{keys.collect(&:inspect).join(', ')}"
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: true,
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.2.2/chartjs-plugin-annotation.min.js" integrity="sha512-HycvvBSFvDEVyJ0tjE2rPmymkt6XqsP/Zo96XgLRjXwn6SecQqsn+6V/7KYev66OshZZ9+f9AttCGmYqmzytiw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
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(parent_config: self, label: label, block: block, file_system: file_system)
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
 
@@ -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
- return unless @raw['changelog']
22
+ load_history_into_changes if @raw['changelog']
23
23
 
24
- load_history_into_changes
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 false if change.nil?
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 false if change.nil?
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
- Time.parse(text).getlocal(@timezone_offset)
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']&.[]('assignee')&.[]('displayName')
241
+ @raw['fields']['assignee']&.[]('displayName')
237
242
  end
238
243
 
239
244
  def assigned_to_icon_url
240
- @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
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, :jira_url
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
- result = call_command command
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(@jira_api_token, '[API_TOKEN]')
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 << ' --request GET'
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 './lib/jirametrics/file_system'
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 keys
50
+ def info key
51
51
  load_config options[:config]
52
- Exporter.instance.info(keys, name_filter: options[:name] || '*')
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.15'
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: 2025-09-08 00:00:00.000000000 Z
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.2
162
+ rubygems_version: 3.6.9
161
163
  specification_version: 4
162
164
  summary: Extract Jira metrics
163
165
  test_files: []