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 +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +4 -1
- data/lib/jirametrics/board.rb +7 -9
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/chart_base.rb +7 -0
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/daily_view.rb +32 -9
- data/lib/jirametrics/data_quality_report.rb +31 -7
- data/lib/jirametrics/downloader.rb +1 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -1
- data/lib/jirametrics/examples/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +5 -4
- data/lib/jirametrics/github_gateway.rb +7 -0
- data/lib/jirametrics/grouping_rules.rb +20 -2
- data/lib/jirametrics/html/aging_work_table.erb +3 -0
- data/lib/jirametrics/html/index.css +114 -0
- data/lib/jirametrics/html/index.erb +5 -0
- data/lib/jirametrics/html/index.js +52 -2
- data/lib/jirametrics/html_report_config.rb +1 -0
- data/lib/jirametrics/issue.rb +47 -21
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +36 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4ab33e299bd374f28754ecfbd750f6afd89861ecf64a323f28d4678d7a22959d
|
|
4
|
+
data.tar.gz: a0bf98e4d51b86eaa36aef8c6bfb69e8cdba958d3afeaf67da71a37ff6eb3ae5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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}
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -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:,
|
|
7
|
+
def initialize raw:, possible_statuses:, features: []
|
|
8
8
|
@raw = raw
|
|
9
9
|
@possible_statuses = possible_statuses
|
|
10
10
|
@sprints = []
|
|
11
|
-
@
|
|
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
|
|
17
|
-
# visible on the board.
|
|
18
|
-
|
|
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
|
-
@
|
|
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']
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 <<
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(:
|
|
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: :
|
|
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
|
-
|
|
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
|
|
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.
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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 = '
|
|
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 }
|
|
@@ -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
|
-
|
|
77
|
-
|
|
76
|
+
status, resolution = issue.status_resolution_at_done
|
|
77
|
+
if resolution
|
|
78
|
+
rules.label = "#{status.name}:#{resolution}"
|
|
78
79
|
else
|
|
79
|
-
rules.label =
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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? ||
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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.
|
|
32
|
-
@file_system.
|
|
33
|
-
@file_system.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
173
|
+
rubygems_version: 4.0.8
|
|
173
174
|
specification_version: 4
|
|
174
175
|
summary: Extract Jira metrics
|
|
175
176
|
test_files: []
|