jirametrics 2.3 → 2.4pre2

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: 2e7264aee468cd14d42fa1c7ba8f737177ba99a78b3f7250d46771f687d4d619
4
- data.tar.gz: da813f37b2a6047d6ab844f483008ecdb8c579786bd7bba7f2c01f038a259949
3
+ metadata.gz: dd626d730d591c7cc7ec2a9245fa395bc1d8d7fc22d658b6663dce28aa40f351
4
+ data.tar.gz: b0437b25f7def75b2bd2f0fd889aead1104b65ec53e339f48b8f2e535dc84d96
5
5
  SHA512:
6
- metadata.gz: c234046627a2c92729c346f44cf78471e74545e01ce3a9810c6c22ed659f6479a0146303ac7fb0652512feafcf8077025577d2a4c039dddf2fd70d6899360adb
7
- data.tar.gz: 81f24c30b8d56f8811ed298ca17cebcee6cb32afdbea5cfd12332587aabaa2701b023dc620b651e111226bf66bac9285f84a893549c00c45bd0eb992694a2c64
6
+ metadata.gz: 3c8c7f1ce346a98f989641fe4cb962d4f3fd04eab690b861e296154564f2d145cfa705fbf8d590ec004a4b02f707799fb2c21eef5cddc7e4e00bcc090730bca7
7
+ data.tar.gz: 596c0b5559ae4a64d46e95f38fb13e0fdec5bc8c315195ea9a45d69abd26841d4d4aa28cee1fe46b769b596810129aaf79e9fb1052ec3827aaa89c092a5687e4
@@ -15,12 +15,12 @@ class BlockedStalledChange
15
15
  @time = time
16
16
  end
17
17
 
18
- def blocked? = @flag || blocked_by_status? || @blocking_issue_keys
19
- def stalled? = @stalled_days || stalled_by_status?
18
+ def blocked? = !!(@flag || blocked_by_status? || @blocking_issue_keys)
19
+ def stalled? = !!(@stalled_days || stalled_by_status?)
20
20
  def active? = !blocked? && !stalled?
21
21
 
22
- def blocked_by_status? = @status && @status_is_blocking
23
- def stalled_by_status? = @status && !@status_is_blocking
22
+ def blocked_by_status? = !!(@status && @status_is_blocking)
23
+ def stalled_by_status? = !!(@status && !@status_is_blocking)
24
24
 
25
25
  def reasons
26
26
  result = []
@@ -35,4 +35,24 @@ class BlockedStalledChange
35
35
  end
36
36
  result.join(', ')
37
37
  end
38
+
39
+ def as_symbol
40
+ if blocked?
41
+ :blocked
42
+ elsif stalled?
43
+ :stalled
44
+ else
45
+ :active
46
+ end
47
+ end
48
+
49
+ def inspect
50
+ text = +"BlockedStalledChange(time: '#{@time}', "
51
+ if active?
52
+ text << 'Active'
53
+ else
54
+ text << reasons
55
+ end
56
+ text << ')'
57
+ end
38
58
  end
@@ -5,7 +5,6 @@ class ChangeItem
5
5
  attr_accessor :value, :old_value, :time
6
6
 
7
7
  def initialize raw:, time:, author:, artificial: false
8
- # raw will only ever be nil in a test and in that case field and value should be passed in
9
8
  @raw = raw
10
9
  @time = time
11
10
  raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
@@ -36,16 +35,17 @@ class ChangeItem
36
35
  def link? = (field == 'Link')
37
36
 
38
37
  def to_s
39
- message = "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: \"#{@time}\""
40
- message += ', artificial' if artificial?
41
- message += ')'
38
+ message = +''
39
+ message << "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: #{time_to_s(@time).inspect}"
40
+ message << ', artificial' if artificial?
41
+ message << ')'
42
42
  message
43
43
  end
44
44
 
45
45
  def inspect = to_s
46
46
 
47
47
  def == other
48
- field.eql?(other.field) && value.eql?(other.value) && time.to_s.eql?(other.time.to_s)
48
+ field.eql?(other.field) && value.eql?(other.value) && time_to_s(time).eql?(time_to_s(other.time))
49
49
  end
50
50
 
51
51
  def current_status_matches *status_names_or_ids
@@ -77,4 +77,12 @@ class ChangeItem
77
77
  end
78
78
  end
79
79
  end
80
+
81
+ private
82
+
83
+ def time_to_s time
84
+ # MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
85
+ # format so that tests work under both environments.
86
+ time.strftime '%Y-%m-%d %H:%M:%S %z'
87
+ end
80
88
  end
@@ -34,6 +34,10 @@ class ColumnsConfig
34
34
  @columns << [:string, label, proc]
35
35
  end
36
36
 
37
+ def integer label, proc
38
+ @columns << [:integer, label, proc]
39
+ end
40
+
37
41
  def column_entry_times board_id: nil
38
42
  @file_config.project_config.find_board_by_id(board_id).visible_columns.each do |column|
39
43
  date column.name, first_time_in_status(*column.status_ids)
@@ -38,25 +38,12 @@ class DailyWipByBlockedStalledChart < DailyWipChart
38
38
  HTML
39
39
  end
40
40
 
41
- def key_blocked_stalled_change issue:, date:, end_time:
42
- stalled_change = nil
43
- blocked_change = nil
44
-
45
- issue.blocked_stalled_changes_on_date(date: date, end_time: end_time) do |change|
46
- blocked_change = change if change.blocked?
47
- stalled_change = change if change.stalled?
48
- end
49
-
50
- return blocked_change if blocked_change
51
- return stalled_change if stalled_change
52
-
53
- nil
54
- end
55
-
56
41
  def default_grouping_rules issue:, rules:
57
42
  started = issue.board.cycletime.started_time(issue)
58
43
  stopped_date = issue.board.cycletime.stopped_time(issue)&.to_date
59
- change = key_blocked_stalled_change issue: issue, date: rules.current_date, end_time: time_range.end
44
+
45
+ date = rules.current_date
46
+ change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
60
47
 
61
48
  stopped_today = stopped_date == rules.current_date
62
49
 
@@ -71,7 +71,10 @@ class DataQualityReport < ChartBase
71
71
 
72
72
  # Return a format that's easier to assert against
73
73
  def testable_entries
74
- @entries.collect { |entry| [entry.started.to_s, entry.stopped.to_s, entry.issue] }
74
+ format = '%Y-%m-%d %H:%M:%S %z'
75
+ @entries.collect do |entry|
76
+ [entry.started&.strftime(format) || '', entry.stopped&.strftime(format) || '', entry.issue]
77
+ end
75
78
  end
76
79
 
77
80
  def entries_with_problems
@@ -5,7 +5,7 @@ require 'fileutils'
5
5
  class Object
6
6
  def deprecated message:, date:
7
7
  text = +''
8
- text << "Deprecated(#{date}):"
8
+ text << "Deprecated(#{date}): "
9
9
  text << message
10
10
  text << "\n-> Called from #{caller(1..1).first}"
11
11
  warn text
@@ -13,7 +13,8 @@ class Object
13
13
  end
14
14
 
15
15
  class Exporter
16
- attr_reader :project_configs, :file_system
16
+ attr_reader :project_configs
17
+ attr_accessor :file_system
17
18
 
18
19
  def self.configure &block
19
20
  logfile_name = 'jirametrics.log'
@@ -99,7 +100,10 @@ class Exporter
99
100
  end
100
101
 
101
102
  def jira_config filename = nil
102
- @jira_config = file_system.load_json(filename) unless filename.nil?
103
+ if filename
104
+ @jira_config = file_system.load_json(filename)
105
+ @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
106
+ end
103
107
  @jira_config
104
108
  end
105
109
 
@@ -66,7 +66,9 @@ class FileConfig
66
66
  # is that all empty values in the first column should be at the bottom.
67
67
  def sort_output all_lines
68
68
  all_lines.sort do |a, b|
69
- if a[0].nil?
69
+ if a[0] == b[0]
70
+ a[1..] <=> b[1..]
71
+ elsif a[0].nil?
70
72
  1
71
73
  elsif b[0].nil?
72
74
  -1
@@ -110,6 +112,10 @@ class FileConfig
110
112
  object.to_s
111
113
  end
112
114
 
115
+ def to_integer object
116
+ object.to_i
117
+ end
118
+
113
119
  def file_suffix suffix = nil
114
120
  @file_suffix = suffix unless suffix.nil?
115
121
  @file_suffix
@@ -1,6 +1,7 @@
1
1
  <html>
2
2
  <head>
3
3
  <meta charset="UTF-8">
4
+ <link rel="icon" type="image/png" href="https://github.com/mikebowler/jirametrics/blob/main/favicon.png?raw=true" />
4
5
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
5
6
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
6
7
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
@@ -186,29 +186,62 @@ class Issue
186
186
  end
187
187
 
188
188
  def blocked_on_date? date, end_time:
189
- blocked_stalled_changes_on_date(date: date, end_time: end_time) do |change|
190
- return true if change.blocked?
191
- end
192
- false
193
- end
189
+ (blocked_stalled_by_date date_range: date..date, chart_end_time: end_time)[date].blocked?
190
+ end
191
+
192
+ # For any day in the day range...
193
+ # If the issue was blocked at any point in this day, the whole day is blocked.
194
+ # If the issue was active at any point in this day, the whole day is active
195
+ # If the day was stalled for the entire day then it's stalled
196
+ # If there was no activity at all on this day then the last change from the previous day carries over
197
+ def blocked_stalled_by_date date_range:, chart_end_time:, settings: nil
198
+ results = {}
199
+ current_date = nil
200
+ blocked_stalled_changes = blocked_stalled_changes(end_time: chart_end_time, settings: settings)
201
+ blocked_stalled_changes.each do |change|
202
+ current_date = change.time.to_date
203
+
204
+ winning_change, _last_change = results[current_date]
205
+ if winning_change.nil? ||
206
+ change.blocked? ||
207
+ (change.active? && (winning_change.active? || winning_change.stalled?)) ||
208
+ (change.stalled? && winning_change.stalled?)
209
+
210
+ winning_change = change
211
+ end
194
212
 
195
- def blocked_stalled_changes_on_date date:, end_time:
196
- next_day_start_time = (date + 1).to_time
197
- this_day_start_time = date.to_time
213
+ results[current_date] = [winning_change, change]
214
+ end
198
215
 
199
- # changes_affecting_date = []
200
- previous_change_time = nil
201
- blocked_stalled_changes(end_time: end_time).each do |change|
202
- if previous_change_time.nil?
203
- previous_change_time = change.time
204
- next
216
+ last_populated_date = nil
217
+ (results.keys.min..results.keys.max).each do |date|
218
+ if results.key? date
219
+ last_populated_date = date
220
+ else
221
+ _winner, last = results[last_populated_date]
222
+ results[date] = [last, last]
205
223
  end
206
-
207
- yield change if previous_change_time < next_day_start_time && change.time >= this_day_start_time
208
224
  end
225
+ results = results.transform_values(&:first)
226
+
227
+ # The requested date range may span outside the actual changes we find in the changelog
228
+ date_of_first_change = blocked_stalled_changes[0].time.to_date
229
+ date_of_last_change = blocked_stalled_changes[-1].time.to_date
230
+ date_range.each do |date|
231
+ results[date] = blocked_stalled_changes[0] if date < date_of_first_change
232
+ results[date] = blocked_stalled_changes[-1] if date > date_of_last_change
233
+ end
234
+
235
+ # To make the code simpler, we've been accumulating data for every date. Now remove anything
236
+ # that isn't in the requested date_range
237
+ results.select! { |date, _value| date_range.include? date }
238
+
239
+ results
209
240
  end
210
241
 
211
- def blocked_stalled_changes end_time:, settings: @board.project_config.settings
242
+ def blocked_stalled_changes end_time:, settings: nil
243
+ settings ||= @board.project_config.settings
244
+
212
245
  blocked_statuses = settings['blocked_statuses']
213
246
  stalled_statuses = settings['stalled_statuses']
214
247
  unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
@@ -222,7 +255,7 @@ class Issue
222
255
  blocking_issue_keys = []
223
256
 
224
257
  result = []
225
- previous_was_active = true
258
+ previous_was_active = false # Must start as false so that the creation will insert an :active
226
259
  previous_change_time = created
227
260
 
228
261
  blocking_status = nil
@@ -231,6 +264,7 @@ class Issue
231
264
  # This mock change is to force the writing of one last entry at the end of the time range.
232
265
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
233
266
  mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
267
+
234
268
  (changes + [mock_change]).each do |change|
235
269
  previous_was_active = false if check_for_stalled(
236
270
  change_time: change.time,
@@ -292,6 +326,7 @@ class Issue
292
326
  stalled_days: result[-1].stalled_days
293
327
  )
294
328
  end
329
+
295
330
  result
296
331
  end
297
332
 
@@ -301,6 +336,9 @@ class Issue
301
336
  # The most common case will be nothing to split so quick escape.
302
337
  return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
303
338
 
339
+ # If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
340
+ return false if blocking_stalled_changes[-1]&.blocked?
341
+
304
342
  list = [previous_change_time..change_time]
305
343
  all_subtask_activity_times.each do |time|
306
344
  matching_range = list.find { |range| time >= range.begin && time <= range.end }
@@ -457,7 +495,7 @@ class Issue
457
495
  value = change.value
458
496
  old_value = change.old_value
459
497
 
460
- message = " [change] #{change.time} [#{change.field}] "
498
+ message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
461
499
  message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
462
500
  message << compact_text(value).inspect
463
501
  message << " (#{change.author})"
@@ -511,7 +549,10 @@ class Issue
511
549
  # It's common that a resolved will happen at the same time as a status change.
512
550
  # Put them in a defined order so tests can be deterministic.
513
551
  compare = a.time <=> b.time
514
- compare = 1 if compare.zero? && a.resolution?
552
+ if compare.zero?
553
+ compare = 1 if a.resolution?
554
+ compare = -1 if b.resolution?
555
+ end
515
556
  compare
516
557
  end
517
558
  end
@@ -48,15 +48,17 @@ class JiraGateway
48
48
  end
49
49
 
50
50
  def make_curl_command url:
51
- command = 'curl'
52
- command += ' -s'
53
- command += ' -k' if @ignore_ssl_errors
54
- command += " --cookie #{@cookies.inspect}" unless @cookies.empty?
55
- command += " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
56
- command += " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
57
- command += ' --request GET'
58
- command += ' --header "Accept: application/json"'
59
- command += " --url \"#{url}\""
51
+ command = +''
52
+ command << 'curl'
53
+ command << ' -L' # follow redirects
54
+ command << ' -s' # silent
55
+ command << ' -k' if @ignore_ssl_errors # insecure
56
+ command << " --cookie #{@cookies.inspect}" unless @cookies.empty?
57
+ command << " --user #{@jira_email}:#{@jira_api_token}" if @jira_api_token
58
+ command << " -H \"Authorization: Bearer #{@jira_personal_access_token}\"" if @jira_personal_access_token
59
+ command << ' --request GET'
60
+ command << ' --header "Accept: application/json"'
61
+ command << " --url \"#{url}\""
60
62
  command
61
63
  end
62
64
  end
@@ -249,7 +249,7 @@ class ProjectConfig
249
249
 
250
250
  def to_time string, end_of_day: false
251
251
  time = end_of_day ? '23:59:59' : '00:00:00'
252
- string = "#{string}T#{time}#{@timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
252
+ string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
253
253
  Time.parse string
254
254
  end
255
255
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.3'
4
+ version: 2.4pre2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-06-03 00:00:00.000000000 Z
11
+ date: 2024-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: random-word
@@ -153,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
153
153
  - !ruby/object:Gem::Version
154
154
  version: '0'
155
155
  requirements: []
156
- rubygems_version: 3.5.11
156
+ rubygems_version: 3.5.15
157
157
  signing_key:
158
158
  specification_version: 4
159
159
  summary: Extract Jira metrics