jirametrics 2.23 → 2.24

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: 2145950e91bf010e3c2790151b32f859fc479a871002f2057e2070ef23a6e1ae
4
- data.tar.gz: 00bff5cffee6fc49862ae15fdc78f142982337a03abb4ea33735b07a2f0f3426
3
+ metadata.gz: 4ab33e299bd374f28754ecfbd750f6afd89861ecf64a323f28d4678d7a22959d
4
+ data.tar.gz: a0bf98e4d51b86eaa36aef8c6bfb69e8cdba958d3afeaf67da71a37ff6eb3ae5
5
5
  SHA512:
6
- metadata.gz: d16248d2502890619c3da712ebb7f0b6aa864994013a599d29e6795f687ccbd782d9e7f33f253f7f5db81a3c78f1be0898bf27cd4e8a981137f0a3ca55da8c8f
7
- data.tar.gz: ee3e5d4fdd783a025c9666a6080f587f82cd70f0fb6aeeda9a12a862c454baab64fb635bdf2727b821760e75822638378c87b18e59b68fa8903b3bd87a05f9f8
6
+ metadata.gz: 7c017b109ce143403190db03124148def9be7a92fe20961f6f7c42f459482a4056018601f131d6ca6275889e7231e54df54befbccb4279de0073838d50280ff4
7
+ data.tar.gz: 07b783818d028d3d95fd1cd861f272fe624c46fe3d4b671f3e2e6fede8fcdc694e5118da888c7c12113838e37711ccd6935a54261d4edf5ceaac4e29c6a08a73
@@ -15,7 +15,7 @@ class AgingWorkBarChart < ChartBase
15
15
  newest at the bottom.
16
16
  </p>
17
17
  <p>
18
- There are three bars for each issue, and hovering over any of the bars will provide more details.
18
+ There are <%= current_board.scrum? ? 'four' : 'three' %> bars for each issue, and hovering over any of the bars will provide more details.
19
19
  <ol>
20
20
  <li>Status: The status the issue was in at any time. The colour indicates the
21
21
  status category, which will be one of #{color_block '--status-category-todo-color'} To Do,
@@ -25,6 +25,9 @@ class AgingWorkBarChart < ChartBase
25
25
  or #{color_block '--stalled-color'} stalled.</li>
26
26
  <li>Priority: This shows the priority over time. If one of these priorities is considered expedited
27
27
  then it will be drawn with diagonal lines.</li>
28
+ <% if current_board.scrum? %>
29
+ <li>Sprints: The sprints that the issue was in.</li>
30
+ <% end %>
28
31
  </ol>
29
32
  </p>
30
33
  #{describe_non_working_days}
@@ -4,19 +4,18 @@ class Board
4
4
  attr_reader :visible_columns, :raw, :possible_statuses, :sprints
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses:, features_raw: nil
7
+ def initialize raw:, possible_statuses:, features: []
8
8
  @raw = raw
9
9
  @possible_statuses = possible_statuses
10
10
  @sprints = []
11
- @features_raw = features_raw
11
+ @features = features
12
12
 
13
13
  columns = raw['columnConfig']['columns']
14
14
  ensure_uniqueness_of_column_names! columns
15
15
 
16
- # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
17
- # visible on the board. If the board is configured to have a kanban backlog then it will have
18
- # statuses matched to it and otherwise, there will be no statuses.
19
- columns = columns.drop(1) if kanban?
16
+ # For a classic Kanban board (type 'kanban'), the first column will always be called 'Backlog'
17
+ # and will NOT be visible on the board. This does not apply to team-managed boards (type 'simple').
18
+ columns = columns.drop(1) if board_type == 'kanban'
20
19
 
21
20
  @backlog_statuses = []
22
21
  @visible_columns = columns.filter_map do |column|
@@ -26,7 +25,7 @@ class Board
26
25
  end
27
26
 
28
27
  def backlog_statuses
29
- if @backlog_statuses.empty? && kanban?
28
+ if @backlog_statuses.empty? && board_type == 'kanban'
30
29
  status_ids = status_ids_from_column raw['columnConfig']['columns'].first
31
30
  @backlog_statuses = status_ids.filter_map do |id|
32
31
  @possible_statuses.find_by_id id
@@ -73,8 +72,7 @@ class Board
73
72
  return true if board_type == 'scrum'
74
73
  return false unless board_type == 'simple'
75
74
 
76
- @features_raw&.[]('features')
77
- &.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' } || false
75
+ @features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
78
76
  end
79
77
 
80
78
  def kanban?
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardFeature
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def name = @raw['feature']
9
+ def enabled? = (@raw['state'] == 'ENABLED')
10
+
11
+ def self.from_raw features_json
12
+ features_json['features']&.map { |f| new(raw: f) } || []
13
+ end
14
+ end
@@ -305,6 +305,13 @@ class ChartBase
305
305
  "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
306
306
  end
307
307
 
308
+ def not_visible_text issue
309
+ reasons = issue.reasons_not_visible_on_board
310
+ return nil if reasons.empty?
311
+
312
+ "<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
313
+ end
314
+
308
315
  def status_category_color status
309
316
  case status.category.key
310
317
  when 'new' then CssVariable['--status-category-todo-color']
@@ -16,7 +16,7 @@ class CssVariable
16
16
  end
17
17
 
18
18
  def to_json(*_args)
19
- "getComputedStyle(document.body).getPropertyValue('#{@name}')"
19
+ "getComputedStyle(document.documentElement).getPropertyValue('#{@name}').trim()"
20
20
  end
21
21
 
22
22
  def to_s
@@ -9,7 +9,8 @@ class DailyView < ChartBase
9
9
  header_text 'Daily View'
10
10
  description_text <<-HTML
11
11
  <div class="p">
12
- This view shows all the items you'll want to discuss during your daily coordination meeting
12
+ This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
13
+ coordination meeting
13
14
  (aka daily scrum, standup), in the order that you should be discussing them. The most important
14
15
  items are at the top, and the least at the bottom.
15
16
  </div>
@@ -86,9 +87,14 @@ class DailyView < ChartBase
86
87
  lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
87
88
  lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
88
89
  blocked_stalled.blocking_issue_keys&.each do |key|
89
- lines << ["#{marker} Blocked by issue: #{key}"]
90
90
  blocking_issue = issues.find { |i| i.key == key }
91
- lines << blocking_issue if blocking_issue
91
+ if blocking_issue
92
+ lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: #{key}</div>"
93
+ lines << blocking_issue
94
+ lines << '</section>'
95
+ else
96
+ lines << ["#{marker} Blocked by issue: #{key}"]
97
+ end
92
98
  end
93
99
  elsif blocked_stalled.stalled_by_status?
94
100
  lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
@@ -146,7 +152,18 @@ class DailyView < ChartBase
146
152
  line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
147
153
  end
148
154
 
149
- line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
155
+ if issue.due_date
156
+ today = date_range.end
157
+ days = (issue.due_date - today).to_i
158
+ relative =
159
+ if days.zero? then 'today'
160
+ elsif days.positive? then "in #{label_days days}"
161
+ else "#{label_days(-days)} ago"
162
+ end
163
+ content = "#{issue.due_date} (#{relative})"
164
+ content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
165
+ line << "Due: <b>#{content}</b>"
166
+ end
150
167
 
151
168
  block = lambda do |collection, label|
152
169
  unless collection.empty?
@@ -166,7 +183,7 @@ class DailyView < ChartBase
166
183
 
167
184
  return lines if subtasks.empty?
168
185
 
169
- lines << '<section><div class="foldable">Child issues</div>'
186
+ lines << "<section><div class=\"foldable startFolded\">Child issues (#{subtasks.count})</div>"
170
187
  lines += subtasks
171
188
  lines << '</section>'
172
189
 
@@ -237,9 +254,10 @@ class DailyView < ChartBase
237
254
 
238
255
  def make_description_lines issue
239
256
  description = issue.raw['fields']['description']
240
- result = []
241
- result << [atlassian_document_format.to_html(description)] if description
242
- result
257
+ return [] unless description
258
+
259
+ text = "<div class='foldable startFolded'>Description</div><div>#{atlassian_document_format.to_html(description)}</div>"
260
+ [[text]]
243
261
  end
244
262
 
245
263
  def assemble_issue_lines issue, child:
@@ -247,6 +265,7 @@ class DailyView < ChartBase
247
265
 
248
266
  lines = []
249
267
  lines << [make_title_line(issue: issue, done: done)]
268
+ lines << make_not_visible_line(issue)
250
269
  lines += make_parent_lines(issue) unless child
251
270
  lines += make_stats_lines(issue: issue, done: done)
252
271
  unless done
@@ -256,7 +275,7 @@ class DailyView < ChartBase
256
275
  lines += make_child_lines(issue)
257
276
  lines += make_history_lines(issue)
258
277
  end
259
- lines
278
+ lines.compact
260
279
  end
261
280
 
262
281
  def render_issue issue, child:
@@ -278,4 +297,8 @@ class DailyView < ChartBase
278
297
  end
279
298
  result << '</div>'
280
299
  end
300
+
301
+ def make_not_visible_line issue
302
+ not_visible_text issue
303
+ end
281
304
  end
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
45
45
  scan_for_completed_issues_without_a_start_time entry: entry
46
46
  scan_for_status_change_after_done entry: entry
47
47
  scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
48
+ scan_for_issue_not_in_active_sprint entry: entry
48
49
  scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
49
50
  scan_for_stopped_before_started entry: entry
50
51
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
68
69
  result << render_problem_type(:status_changes_after_done)
69
70
  result << render_problem_type(:backwards_through_status_categories)
70
71
  result << render_problem_type(:backwords_through_statuses)
71
- result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:issue_not_visible_on_board)
72
73
  result << render_problem_type(:created_in_wrong_status)
73
74
  result << render_problem_type(:stopped_before_started)
74
75
  result << render_problem_type(:issue_not_started_but_subtasks_have)
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
194
195
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
195
196
  detail = nil if backlog_statuses.any? { |s| s.name == change.value }
196
197
 
197
- entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
198
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
198
199
  elsif change.old_value.nil?
199
200
  # Do nothing
200
201
  elsif index < last_index
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
223
224
  end
224
225
  end
225
226
 
227
+ def scan_for_issue_not_in_active_sprint entry:
228
+ issue = entry.issue
229
+ return unless issue.board.scrum?
230
+ return if issue.sprints.any?(&:active?)
231
+
232
+ entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
233
+ end
234
+
235
+ def scan_for_issue_never_visible_on_board entry:
236
+ issue = entry.issue
237
+ ever_visible = issue.changes.any? do |change|
238
+ next unless change.status?
239
+
240
+ issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
241
+ end
242
+ return if ever_visible
243
+
244
+ entry.report(
245
+ problem_key: :issue_not_visible_on_board,
246
+ detail: 'Issue has never been in a status mapped to a visible column on the board'
247
+ )
248
+ end
249
+
226
250
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
251
  creation_change = entry.issue.changes.find { |issue| issue.status? }
228
252
 
@@ -295,7 +319,7 @@ class DataQualityReport < ChartBase
295
319
  return "#{delta} hours" if delta < 24
296
320
 
297
321
  delta /= 24
298
- "#{delta} days"
322
+ label_days delta
299
323
  end
300
324
 
301
325
  def scan_for_incomplete_subtasks_when_issue_done entry:
@@ -409,12 +433,12 @@ class DataQualityReport < ChartBase
409
433
  HTML
410
434
  end
411
435
 
412
- def render_status_not_on_board problems
436
+ def render_issue_not_visible_on_board problems
413
437
  <<-HTML
414
438
  #{label_issues problems.size} were not visible on the board for some period of time. This may impact
415
- timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
416
- mean in this context? The issue was in a status that is not mapped to any visible column on the board.
417
- Look in "unmapped statuses" on your board.
439
+ timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
440
+ for two reasons: the issue was in a status that is not mapped to any visible column on the board
441
+ (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
418
442
  HTML
419
443
  end
420
444
 
@@ -170,7 +170,7 @@ class Downloader
170
170
 
171
171
  if json['type'] == 'simple'
172
172
  features_json = download_features board_id: board_id
173
- if features_json['features']&.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' }
173
+ if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
174
174
  download_sprints board_id: board_id
175
175
  end
176
176
  end
@@ -30,7 +30,7 @@ class EstimateAccuracyChart < ChartBase
30
30
  HTML
31
31
 
32
32
  @x_axis_title = 'Cycletime (days)'
33
- @y_axis_title = 'Count of items'
33
+ @y_axis_title = 'Estimate'
34
34
 
35
35
  @y_axis_type = 'linear'
36
36
  @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
@@ -12,7 +12,7 @@ class Exporter
12
12
  project name: name do
13
13
  puts name
14
14
  file_prefix name
15
- self.settings.merge! settings
15
+ self.settings.merge! stringify_keys(settings)
16
16
 
17
17
  aggregate do
18
18
  project_names.each do |project_name|
@@ -13,7 +13,7 @@ class Exporter
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
16
- self.settings.merge! settings
16
+ self.settings.merge! stringify_keys(settings)
17
17
 
18
18
  boards.each_key do |board_id|
19
19
  block = boards[board_id]
@@ -73,10 +73,11 @@ class Exporter
73
73
  header_text nil
74
74
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
75
75
  grouping_rules do |issue, rules|
76
- if issue.resolution
77
- rules.label = "#{issue.status.name}:#{issue.resolution}"
76
+ status, resolution = issue.status_resolution_at_done
77
+ if resolution
78
+ rules.label = "#{status.name}:#{resolution}"
78
79
  else
79
- rules.label = issue.status.name
80
+ rules.label = status.name
80
81
  end
81
82
  end
82
83
  end
@@ -92,6 +92,13 @@ class GithubGateway
92
92
 
93
93
  def run_command args
94
94
  stdout, stderr, status = Open3.capture3('gh', *args)
95
+
96
+ # This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
97
+ if stderr.include?('SAML enforcement')
98
+ raise "GitHub CLI is not authorized to access #{@repo}. " \
99
+ "Run: gh auth refresh -h github.com -s read:org"
100
+ end
101
+
95
102
  raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
96
103
 
97
104
  JSON.parse(stdout)
@@ -13,7 +13,25 @@ class GroupingRules < Rules
13
13
  end
14
14
 
15
15
  def color= color
16
- color = CssVariable[color] unless color.is_a?(CssVariable)
17
- @color = color
16
+ if color.is_a?(Array)
17
+ raise ArgumentError, 'Color pair must have exactly two elements: [light_color, dark_color]' unless color.size == 2
18
+ raise ArgumentError, 'Color pair elements must be strings' unless color.all?(String)
19
+
20
+ if color.any? { |c| c.start_with?('--') }
21
+ raise ArgumentError,
22
+ 'CSS variable references are not supported as color pair elements; use a literal color value instead'
23
+ end
24
+
25
+ light, dark = color
26
+ @color = RawJavascript.new(
27
+ "(document.documentElement.dataset.theme === 'dark' || " \
28
+ '(!document.documentElement.dataset.theme && ' \
29
+ "window.matchMedia('(prefers-color-scheme: dark)').matches)) " \
30
+ "? #{dark.to_json} : #{light.to_json}"
31
+ )
32
+ else
33
+ color = CssVariable[color] unless color.is_a?(CssVariable)
34
+ @color = color
35
+ end
18
36
  end
19
37
  end
@@ -41,6 +41,9 @@
41
41
  <%= link_to_issue parent, style: "color: #{color}" %>
42
42
  </span>
43
43
  <i><%= parent.summary.strip.inspect %></i>
44
+ <% if parent == issue && (text = not_visible_text(issue)) %>
45
+ <br /><%= text %>
46
+ <% end %>
44
47
  </div>
45
48
  <% end %>
46
49
  </td>
@@ -214,6 +214,120 @@ div.child_issue {
214
214
  padding: 0.5em;
215
215
  }
216
216
 
217
+ /* Dark CSS variables — shared by the media query and the forced dark theme */
218
+ html[data-theme="dark"] {
219
+ --warning-banner: #9F2B00;
220
+ --non-working-days-color: #2f2f2f;
221
+ --type-story-color: #6fb86f;
222
+ --type-task-color: #0021b3;
223
+ --type-bug-color: #bb5603;
224
+ --body-background: #343434;
225
+ --default-text-color: #aaa;
226
+ --grid-line-color: #424242;
227
+ --expedited-color: #b90000;
228
+ --blocked-color: #c75b02;
229
+ --stalled-color: #ae7202;
230
+ --wip-chart-active-color: #2551c1;
231
+ --status-category-inprogress-color: #1c49bb;
232
+ --hierarchy-table-inactive-item-text-color: #939393;
233
+ --wip-chart-completed-color: #03cb03;
234
+ --wip-chart-duration-less-than-day-color: #d2d988;
235
+ --wip-chart-duration-week-or-less-color: #dfcd00;
236
+ --wip-chart-duration-two-weeks-or-less-color: #cf9400;
237
+ --wip-chart-duration-four-weeks-or-less-color: #c25e00;
238
+ --wip-chart-duration-more-than-four-weeks-color: #8e0000;
239
+ --daily-view-selected-issue-background: #474747;
240
+ --priority-color-highest: #ef4444;
241
+ --priority-color-high: #f97316;
242
+ --priority-color-low: #06b6d4;
243
+ --priority-color-lowest: #94a3b8;
244
+
245
+ a[href] { color: #1e8ad6; }
246
+ a[href]:hover { color: #3ba0e6; }
247
+ .chart { background: var(--body-background); }
248
+ table.standard {
249
+ th { background: var(--body-background); }
250
+ tr:nth-child(odd) { background-color: #656565; }
251
+ }
252
+ div.color_block { border: 1px solid lightgray; }
253
+ div.daily_issue {
254
+ .field { color: var(--default-text-color); }
255
+ }
256
+ }
257
+
258
+ /* Force light mode — overrides the dark media query when user explicitly picks light */
259
+ html[data-theme="light"] {
260
+ --warning-banner: yellow;
261
+ --non-working-days-color: #F0F0F0;
262
+ --type-story-color: #4bc14b;
263
+ --type-task-color: blue;
264
+ --type-bug-color: orange;
265
+ --body-background: white;
266
+ --default-text-color: black;
267
+ --grid-line-color: lightgray;
268
+ --expedited-color: red;
269
+ --blocked-color: #FF7400;
270
+ --stalled-color: orange;
271
+ --wip-chart-active-color: #326cff;
272
+ --status-category-inprogress-color: #2663ff;
273
+ --hierarchy-table-inactive-item-text-color: gray;
274
+ --wip-chart-completed-color: #00ff00;
275
+ --wip-chart-duration-less-than-day-color: #ffef41;
276
+ --wip-chart-duration-week-or-less-color: #dcc900;
277
+ --wip-chart-duration-two-weeks-or-less-color: #dfa000;
278
+ --wip-chart-duration-four-weeks-or-less-color: #eb7200;
279
+ --wip-chart-duration-more-than-four-weeks-color: #e70000;
280
+ --daily-view-selected-issue-background: lightgray;
281
+ --priority-color-highest: #dc2626;
282
+ --priority-color-high: #ea580c;
283
+ --priority-color-low: #0891b2;
284
+ --priority-color-lowest: #64748b;
285
+
286
+ a[href] { color: revert; }
287
+ a[href]:hover { color: revert; }
288
+ .chart { background: white; }
289
+ table.standard {
290
+ th { background: white; }
291
+ tr:nth-child(odd) { background-color: #eee; }
292
+ }
293
+ div.color_block { border: 1px solid black; }
294
+ }
295
+
296
+ /* Theme toggle widget */
297
+ #theme-toggle {
298
+ position: fixed;
299
+ top: 0.5rem;
300
+ right: 0.5rem;
301
+ display: flex;
302
+ gap: 2px;
303
+ background: var(--body-background);
304
+ border: 1px solid var(--grid-line-color);
305
+ border-radius: 6px;
306
+ padding: 3px;
307
+ z-index: 1000;
308
+
309
+ button {
310
+ background: none;
311
+ border: none;
312
+ cursor: pointer;
313
+ padding: 2px 6px;
314
+ border-radius: 4px;
315
+ font-size: 1rem;
316
+ color: var(--default-text-color);
317
+ opacity: 0.5;
318
+ }
319
+
320
+ button:hover {
321
+ opacity: 1;
322
+ background: var(--grid-line-color);
323
+ }
324
+
325
+ button.active {
326
+ opacity: 1;
327
+ background: var(--grid-line-color);
328
+ }
329
+ }
330
+
217
331
  @media screen and (prefers-color-scheme: dark) {
218
332
  :root {
219
333
  --warning-banner: #9F2B00;
@@ -17,6 +17,11 @@
17
17
  </script>
18
18
  </head>
19
19
  <body>
20
+ <div id="theme-toggle">
21
+ <button id="theme-btn-system" title="Use system preference">⊙</button>
22
+ <button id="theme-btn-light" title="Light mode">☀</button>
23
+ <button id="theme-btn-dark" title="Dark mode">☾</button>
24
+ </div>
20
25
  <noscript>
21
26
  <div style="padding: 1em; background: red; color: white; font-size: 2em;">
22
27
  Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
@@ -1,3 +1,12 @@
1
+ // Apply saved theme immediately (before Chart.js reads CSS variables) so charts
2
+ // initialize with the correct colour scheme.
3
+ (function () {
4
+ const saved = localStorage.getItem('jirametrics:theme');
5
+ if (saved) {
6
+ document.documentElement.setAttribute('data-theme', saved);
7
+ }
8
+ }());
9
+
1
10
  function makeFoldable() {
2
11
  // Get all elements with the "foldable" class
3
12
  const foldableElements = document.querySelectorAll('.foldable');
@@ -77,16 +86,57 @@ function makeFoldable() {
77
86
  });
78
87
  }
79
88
 
89
+ function initThemeToggle() {
90
+ const html = document.documentElement;
91
+ const saved = localStorage.getItem('jirametrics:theme');
92
+ if (saved) {
93
+ html.setAttribute('data-theme', saved);
94
+ }
95
+
96
+ function updateActiveButton(theme) {
97
+ ['system', 'light', 'dark'].forEach(t => {
98
+ const btn = document.getElementById(`theme-btn-${t}`);
99
+ if (btn) {
100
+ btn.classList.toggle('active', t === theme);
101
+ }
102
+ });
103
+ }
104
+
105
+ function setTheme(theme) {
106
+ if (theme === 'system') {
107
+ html.removeAttribute('data-theme');
108
+ localStorage.removeItem('jirametrics:theme');
109
+ } else {
110
+ html.setAttribute('data-theme', theme);
111
+ localStorage.setItem('jirametrics:theme', theme);
112
+ }
113
+ updateActiveButton(theme);
114
+ location.reload();
115
+ }
116
+
117
+ updateActiveButton(saved || 'system');
118
+
119
+ ['system', 'light', 'dark'].forEach(theme => {
120
+ const btn = document.getElementById(`theme-btn-${theme}`);
121
+ if (btn) {
122
+ btn.addEventListener('click', () => setTheme(theme));
123
+ }
124
+ });
125
+ }
126
+
80
127
  // Auto-initialize when DOM is loaded
81
128
  document.addEventListener('DOMContentLoaded', function() {
82
129
  makeFoldable();
130
+ initThemeToggle();
83
131
  });
84
132
 
85
133
 
86
134
  // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
87
- // in the other colour scheme.
135
+ // in the other colour scheme. Skip reload if a manual theme override is set.
88
136
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
89
- location.reload()
137
+ if (!document.documentElement.hasAttribute('data-theme')) {
138
+ location.reload();
139
+ }
90
140
  })
91
141
 
92
142
  // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
@@ -41,6 +41,7 @@ class HtmlReportConfig < HtmlGenerator
41
41
  deprecated_warning: 'Renamed to estimate_accuracy_chart. Please use that one', deprecated_date: '2024-05-23'
42
42
 
43
43
  def initialize file_config:, block:
44
+ super()
44
45
  @file_config = file_config
45
46
  @block = block
46
47
  @sections = [] # Where we store the chunks of text that will be assembled into the HTML
@@ -213,6 +213,19 @@ class Issue
213
213
  first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
214
214
  end
215
215
 
216
+ def reasons_not_visible_on_board
217
+ reasons = []
218
+ reasons << 'Not in an active sprint' if board.scrum? && sprints.none?(&:active?)
219
+ unless board.visible_columns.any? { |c| c.status_ids.include?(status.id) }
220
+ reasons << 'Status is not configured for any visible column on the board'
221
+ end
222
+ reasons
223
+ end
224
+
225
+ def visible_on_board?
226
+ reasons_not_visible_on_board.empty?
227
+ end
228
+
216
229
  # If this issue will ever be in an active sprint then return the time that it
217
230
  # was first added to that sprint, whether or not the sprint was active at that
218
231
  # time. Although it seems like an odd thing to calculate, it's a reasonable proxy
@@ -395,21 +408,11 @@ class Issue
395
408
  results
396
409
  end
397
410
 
398
- def blocked_stalled_statuses settings
399
- blocked_statuses = settings['blocked_statuses']
400
- stalled_statuses = settings['stalled_statuses']
401
- unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
402
- raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
403
- "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
404
- end
405
-
406
- [blocked_statuses, stalled_statuses]
407
- end
408
-
409
411
  def blocked_stalled_changes end_time:, settings: nil
410
412
  settings ||= @board.project_config.settings
411
413
 
412
- blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
414
+ blocked_statuses = settings['blocked_statuses']
415
+ stalled_statuses = settings['stalled_statuses']
413
416
 
414
417
  blocked_link_texts = settings['blocked_link_text']
415
418
  stalled_threshold = settings['stalled_threshold_days']
@@ -422,6 +425,7 @@ class Issue
422
425
  previous_change_time = created
423
426
 
424
427
  blocking_status = nil
428
+ blocking_is_blocked = false
425
429
  flag = nil
426
430
  flag_reason = nil
427
431
 
@@ -441,7 +445,11 @@ class Issue
441
445
  flag, flag_reason = blocked_stalled_changes_flag_logic change
442
446
  elsif change.status?
443
447
  blocking_status = nil
444
- if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
448
+ blocking_is_blocked = false
449
+ if blocked_statuses.find_by_id(change.value_id)
450
+ blocking_status = change.value
451
+ blocking_is_blocked = true
452
+ elsif stalled_statuses.find_by_id(change.value_id)
445
453
  blocking_status = change.value
446
454
  end
447
455
  elsif change.link?
@@ -464,7 +472,7 @@ class Issue
464
472
  flagged: flag,
465
473
  flag_reason: flag_reason,
466
474
  status: blocking_status,
467
- status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
475
+ status_is_blocking: blocking_status.nil? || blocking_is_blocked,
468
476
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
469
477
  time: change.time
470
478
  )
@@ -749,6 +757,24 @@ class Issue
749
757
  @changes.select { |change| change.status? }
750
758
  end
751
759
 
760
+ def status_resolution_at_done
761
+ done_time = board.cycletime.started_stopped_times(self).last
762
+ return [nil, nil] if done_time.nil?
763
+
764
+ status_change = nil
765
+ resolution = nil
766
+
767
+ @changes.each do |change|
768
+ break if change.time > done_time
769
+
770
+ status_change = change if change.status?
771
+ resolution = change.value if change.resolution?
772
+ end
773
+
774
+ status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
775
+ [status, resolution]
776
+ end
777
+
752
778
  def sprints
753
779
  sprint_ids = []
754
780
 
@@ -769,13 +795,13 @@ class Issue
769
795
  def compact_text text, max: 60
770
796
  return '' if text.nil?
771
797
 
772
- if text.is_a? Hash
773
- # We can't effectively compact it but we can convert it into a string.
774
- text = @board.project_config.atlassian_document_format.to_html(text)
775
- else
776
- text = text.gsub(/\s+/, ' ').strip
777
- text = "#{text[0...max]}..." if text.length > max
778
- end
798
+ text = if text.is_a? Hash
799
+ @board.project_config.atlassian_document_format.to_text(text)
800
+ else
801
+ text
802
+ end
803
+ text = text.gsub(/\s+/, ' ').strip
804
+ text = "#{text[0...max]}..." if text.length > max
779
805
  text
780
806
  end
781
807
 
@@ -28,9 +28,12 @@ class JiraGateway
28
28
 
29
29
  stdout, stderr, status = capture3(command, stdin_data: stdin_data)
30
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}"
31
+ @file_system.error "Failed call with exit status #{status.exitstatus}!"
32
+ @file_system.error "Returned (stdout): #{stdout.inspect}"
33
+ @file_system.error "Returned (stderr): #{stderr.inspect}"
34
+ if stderr.include?('401')
35
+ raise 'The request was not authorized. Verify that your authentication token hasn\'t expired'
36
+ end
34
37
  raise "Failed call with exit status #{status.exitstatus}. " \
35
38
  "See #{@file_system.logfile_name} for details"
36
39
  end
@@ -43,6 +43,7 @@ class ProjectConfig
43
43
  load_sprints
44
44
  load_fix_versions
45
45
  load_users
46
+ resolve_blocked_stalled_status_settings
46
47
  end
47
48
 
48
49
  def run load_only: false
@@ -70,7 +71,10 @@ class ProjectConfig
70
71
  file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
71
72
  end
72
73
 
73
- settings
74
+ settings['blocked_statuses'] = StatusCollection.new
75
+ settings['stalled_statuses'] = StatusCollection.new
76
+
77
+ stringify_keys(settings)
74
78
  end
75
79
 
76
80
  def guess_project_id
@@ -273,9 +277,13 @@ class ProjectConfig
273
277
  raw = file_system.load_json(filename)
274
278
 
275
279
  features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
276
- features_raw = file_system.load_json(features_filename) if file_system.file_exist?(features_filename)
280
+ features = if file_system.file_exist?(features_filename)
281
+ BoardFeature.from_raw(file_system.load_json(features_filename))
282
+ else
283
+ []
284
+ end
277
285
 
278
- board = Board.new(raw: raw, possible_statuses: @possible_statuses, features_raw: features_raw)
286
+ board = Board.new(raw: raw, possible_statuses: @possible_statuses, features: features)
279
287
  board.project_config = self
280
288
  @all_boards[board_id] = board
281
289
  end
@@ -633,4 +641,29 @@ class ProjectConfig
633
641
 
634
642
  cycletimes_touched.each { |c| c.flush_cache }
635
643
  end
644
+
645
+ def stringify_keys value
646
+ case value
647
+ when Hash then value.transform_keys(&:to_s).transform_values { |v| stringify_keys(v) }
648
+ when Array then value.map { |v| stringify_keys(v) }
649
+ else value
650
+ end
651
+ end
652
+
653
+ def resolve_blocked_stalled_status_settings
654
+ %w[blocked_statuses stalled_statuses].each do |key|
655
+ next if @settings[key].is_a?(StatusCollection)
656
+
657
+ collection = StatusCollection.new
658
+ @settings[key].each do |identifier|
659
+ statuses = @possible_statuses.find_all_by_name(identifier)
660
+ if statuses.empty?
661
+ file_system.warning "Status #{identifier.inspect} in #{key} not found. Ignoring."
662
+ else
663
+ statuses.each { |status| collection << status }
664
+ end
665
+ end
666
+ @settings[key] = collection
667
+ end
668
+ end
636
669
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.23'
4
+ version: '2.24'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -71,6 +71,7 @@ files:
71
71
  - lib/jirametrics/board.rb
72
72
  - lib/jirametrics/board_column.rb
73
73
  - lib/jirametrics/board_config.rb
74
+ - lib/jirametrics/board_feature.rb
74
75
  - lib/jirametrics/board_movement_calculator.rb
75
76
  - lib/jirametrics/change_item.rb
76
77
  - lib/jirametrics/chart_base.rb
@@ -169,7 +170,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
169
170
  - !ruby/object:Gem::Version
170
171
  version: '0'
171
172
  requirements: []
172
- rubygems_version: 4.0.7
173
+ rubygems_version: 4.0.8
173
174
  specification_version: 4
174
175
  summary: Extract Jira metrics
175
176
  test_files: []