jirametrics 2.22 → 2.23
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/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +11 -0
- data/lib/jirametrics/aging_work_table.rb +1 -1
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +104 -93
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +17 -3
- data/lib/jirametrics/change_item.rb +4 -3
- data/lib/jirametrics/chart_base.rb +80 -1
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
- data/lib/jirametrics/daily_wip_chart.rb +27 -7
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +76 -5
- data/lib/jirametrics/downloader_for_cloud.rb +39 -0
- data/lib/jirametrics/downloader_for_data_center.rb +2 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/standard_project.rb +15 -5
- data/lib/jirametrics/expedited_chart.rb +2 -0
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
- data/lib/jirametrics/github_gateway.rb +99 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -0
- data/lib/jirametrics/grouping_rules.rb +1 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
- data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
- data/lib/jirametrics/html/expedited_chart.erb +3 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
- data/lib/jirametrics/html/sprint_burndown.erb +7 -13
- data/lib/jirametrics/html/throughput_chart.erb +5 -8
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
- data/lib/jirametrics/html_report_config.rb +1 -0
- data/lib/jirametrics/issue.rb +37 -74
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/project_config.rb +32 -5
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +4 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint_burndown.rb +2 -0
- data/lib/jirametrics/stitcher.rb +2 -1
- data/lib/jirametrics/throughput_chart.rb +7 -1
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- metadata +11 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2145950e91bf010e3c2790151b32f859fc479a871002f2057e2070ef23a6e1ae
|
|
4
|
+
data.tar.gz: 00bff5cffee6fc49862ae15fdc78f142982337a03abb4ea33735b07a2f0f3426
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d16248d2502890619c3da712ebb7f0b6aa864994013a599d29e6795f687ccbd782d9e7f33f253f7f5db81a3c78f1be0898bf27cd4e8a981137f0a3ca55da8c8f
|
|
7
|
+
data.tar.gz: ee3e5d4fdd783a025c9666a6080f587f82cd70f0fb6aeeda9a12a862c454baab64fb635bdf2727b821760e75822638378c87b18e59b68fa8903b3bd87a05f9f8
|
|
@@ -65,8 +65,16 @@ class AggregateConfig
|
|
|
65
65
|
|
|
66
66
|
if issues.nil?
|
|
67
67
|
file_system.warning "No issues found for #{project_name}"
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@project_config.add_issues issues
|
|
72
|
+
|
|
73
|
+
# Bring fix versions over
|
|
74
|
+
project.fix_versions.each do |fix_version|
|
|
75
|
+
unless @project_config.fix_versions.find { |fv| fv.id == fix_version.id }
|
|
76
|
+
@project_config.fix_versions << fix_version
|
|
77
|
+
end
|
|
70
78
|
end
|
|
71
79
|
end
|
|
72
80
|
|
|
@@ -83,6 +83,8 @@ class AgingWorkBarChart < ChartBase
|
|
|
83
83
|
]
|
|
84
84
|
bar_data << ['sprints', collect_sprint_ranges(issue: issue)] if current_board.scrum?
|
|
85
85
|
|
|
86
|
+
bar_data.each { |entry| clip_ranges_to_start_time(ranges: entry.last, issue_start_time: issue_start_time) }
|
|
87
|
+
|
|
86
88
|
issue_label = "[#{label_days cycletime.age(issue, today: today)}] #{issue.key}: #{issue.summary}"[0..60]
|
|
87
89
|
bar_data.collect do |stack, ranges|
|
|
88
90
|
bar_chart_range_to_data_set y_value: issue_label, ranges: ranges, stack: stack, issue_start_time: issue_start_time
|
|
@@ -114,6 +116,13 @@ class AgingWorkBarChart < ChartBase
|
|
|
114
116
|
@canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
|
|
115
117
|
end
|
|
116
118
|
|
|
119
|
+
def clip_ranges_to_start_time ranges:, issue_start_time:
|
|
120
|
+
return if issue_start_time.nil?
|
|
121
|
+
|
|
122
|
+
ranges.each { |range| range.start = issue_start_time if range.start < issue_start_time }
|
|
123
|
+
ranges.reject! { |range| range.start >= range.stop }
|
|
124
|
+
end
|
|
125
|
+
|
|
117
126
|
def collect_status_ranges issue:, now:
|
|
118
127
|
ranges = []
|
|
119
128
|
issue_started_time = issue.board.cycletime.started_stopped_times(issue).first
|
|
@@ -263,6 +272,8 @@ class AgingWorkBarChart < ChartBase
|
|
|
263
272
|
end
|
|
264
273
|
|
|
265
274
|
open_sprints.each_value do |data|
|
|
275
|
+
next if data[:sprint].future?
|
|
276
|
+
|
|
266
277
|
stop = data[:sprint].completed_time || time_range.end
|
|
267
278
|
results << BarChartRange.new(
|
|
268
279
|
start: data[:start_time], stop: stop,
|
|
@@ -174,6 +174,6 @@ class AgingWorkTable < ChartBase
|
|
|
174
174
|
end
|
|
175
175
|
|
|
176
176
|
def priority_text issue
|
|
177
|
-
"<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' />"
|
|
177
|
+
"<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' style='max-width: 1em;'/>"
|
|
178
178
|
end
|
|
179
179
|
end
|
|
@@ -21,6 +21,10 @@ class Anonymizer < ChartBase
|
|
|
21
21
|
anonymize_column_names
|
|
22
22
|
# anonymize_issue_statuses
|
|
23
23
|
anonymize_board_names
|
|
24
|
+
anonymize_labels_and_components
|
|
25
|
+
anonymize_sprints
|
|
26
|
+
anonymize_fix_versions
|
|
27
|
+
anonymize_server_url
|
|
24
28
|
shift_all_dates unless @date_adjustment.zero?
|
|
25
29
|
@file_system.log 'Anonymize done'
|
|
26
30
|
end
|
|
@@ -38,13 +42,25 @@ class Anonymizer < ChartBase
|
|
|
38
42
|
|
|
39
43
|
def anonymize_issue_keys_and_titles issues: @issues
|
|
40
44
|
counter = 0
|
|
45
|
+
seen_author_raws = {}
|
|
41
46
|
issues.each do |issue|
|
|
42
47
|
new_key = "ANON-#{counter += 1}"
|
|
43
48
|
|
|
44
49
|
issue.raw['key'] = new_key
|
|
45
50
|
issue.raw['fields']['summary'] = random_phrase
|
|
51
|
+
issue.raw['fields']['description'] = nil
|
|
46
52
|
issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
|
|
47
53
|
|
|
54
|
+
anonymize_author_raw(issue.raw['fields']['creator'], seen_author_raws)
|
|
55
|
+
|
|
56
|
+
issue.changes.each do |change|
|
|
57
|
+
anonymize_author_raw(change.author_raw, seen_author_raws)
|
|
58
|
+
if change.comment? || change.description?
|
|
59
|
+
change.value = nil
|
|
60
|
+
change.old_value = nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
48
64
|
issue.issue_links.each do |link|
|
|
49
65
|
other_issue = link.other_issue
|
|
50
66
|
next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
|
|
@@ -55,6 +71,49 @@ class Anonymizer < ChartBase
|
|
|
55
71
|
end
|
|
56
72
|
end
|
|
57
73
|
|
|
74
|
+
def anonymize_labels_and_components
|
|
75
|
+
@issues.each do |issue|
|
|
76
|
+
issue.raw['fields']['labels'] = []
|
|
77
|
+
issue.raw['fields']['components'] = []
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def anonymize_sprints
|
|
82
|
+
sprint_counter = 0
|
|
83
|
+
sprint_name_map = {}
|
|
84
|
+
@all_boards.each_value do |board|
|
|
85
|
+
board.sprints.each do |sprint|
|
|
86
|
+
name = sprint.raw['name']
|
|
87
|
+
unless sprint_name_map[name]
|
|
88
|
+
sprint_counter += 1
|
|
89
|
+
sprint_name_map[name] = "Sprint-#{sprint_counter}"
|
|
90
|
+
end
|
|
91
|
+
sprint.raw['name'] = sprint_name_map[name]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def anonymize_fix_versions
|
|
97
|
+
version_counter = 0
|
|
98
|
+
version_name_map = {}
|
|
99
|
+
@issues.each do |issue|
|
|
100
|
+
issue.raw['fields']['fixVersions']&.each do |fix_version|
|
|
101
|
+
name = fix_version['name']
|
|
102
|
+
unless version_name_map[name]
|
|
103
|
+
version_counter += 1
|
|
104
|
+
version_name_map[name] = "Version-#{version_counter}"
|
|
105
|
+
end
|
|
106
|
+
fix_version['name'] = version_name_map[name]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def anonymize_server_url
|
|
112
|
+
@all_boards.each_value do |board|
|
|
113
|
+
board.raw['self'] = board.raw['self']&.sub(/^https?:\/\/[^\/]+/, 'https://anon.example.com')
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
58
117
|
def anonymize_column_names
|
|
59
118
|
@all_boards.each_key do |board_id|
|
|
60
119
|
@file_system.log "Anonymizing column names for board #{board_id}"
|
|
@@ -143,7 +202,7 @@ class Anonymizer < ChartBase
|
|
|
143
202
|
end
|
|
144
203
|
|
|
145
204
|
range = @project_config.time_range
|
|
146
|
-
@project_config.time_range = (range.begin +
|
|
205
|
+
@project_config.time_range = (range.begin + adjustment_in_seconds)..(range.end + adjustment_in_seconds)
|
|
147
206
|
end
|
|
148
207
|
|
|
149
208
|
def random_name
|
|
@@ -186,4 +245,18 @@ class Anonymizer < ChartBase
|
|
|
186
245
|
board.raw['name'] = "#{random_phrase} board"
|
|
187
246
|
end
|
|
188
247
|
end
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def anonymize_author_raw author_raw, seen
|
|
252
|
+
return unless author_raw
|
|
253
|
+
return if seen[author_raw.object_id]
|
|
254
|
+
|
|
255
|
+
seen[author_raw.object_id] = true
|
|
256
|
+
name = random_name
|
|
257
|
+
author_raw['displayName'] = name
|
|
258
|
+
author_raw['name'] = name
|
|
259
|
+
author_raw.delete('emailAddress')
|
|
260
|
+
author_raw.delete('avatarUrls')
|
|
261
|
+
end
|
|
189
262
|
end
|
|
@@ -23,105 +23,106 @@ class AtlassianDocumentFormat
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def to_text input
|
|
27
|
+
if input.is_a? String
|
|
28
|
+
input
|
|
29
|
+
elsif input&.[]('content')
|
|
30
|
+
input['content'].collect { |element| adf_node_to_text element }.join
|
|
31
|
+
else
|
|
32
|
+
''
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
# ADF is Atlassian Document Format
|
|
27
37
|
# https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
|
|
28
38
|
def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
closing_tag = '</div>'
|
|
74
|
-
when 'mention'
|
|
75
|
-
user = node_attrs['text']
|
|
76
|
-
result << "<b>#{user}</b>"
|
|
77
|
-
when 'orderedList'
|
|
78
|
-
result << '<ol>'
|
|
79
|
-
closing_tag = '</ol>'
|
|
80
|
-
when 'panel'
|
|
81
|
-
type = node_attrs['panelType']
|
|
82
|
-
result << "<div>#{type.upcase}</div>"
|
|
83
|
-
when 'paragraph'
|
|
84
|
-
result << '<p>'
|
|
85
|
-
closing_tag = '</p>'
|
|
86
|
-
when 'rule'
|
|
87
|
-
result << '<hr />'
|
|
88
|
-
when 'status'
|
|
89
|
-
text = node_attrs['text']
|
|
90
|
-
result << text
|
|
91
|
-
when 'table'
|
|
92
|
-
result << '<table>'
|
|
93
|
-
closing_tag = '</table>'
|
|
94
|
-
when 'tableCell'
|
|
95
|
-
result << '<td>'
|
|
96
|
-
closing_tag = '</td>'
|
|
97
|
-
when 'tableHeader'
|
|
98
|
-
result << '<th>'
|
|
99
|
-
closing_tag = '</th>'
|
|
100
|
-
when 'tableRow'
|
|
101
|
-
result << '<tr>'
|
|
102
|
-
closing_tag = '</tr>'
|
|
103
|
-
when 'text'
|
|
104
|
-
marks = adf_marks_to_html node['marks']
|
|
105
|
-
result << marks.collect(&:first).join
|
|
106
|
-
result << node['text']
|
|
107
|
-
result << marks.collect(&:last).join
|
|
108
|
-
when 'taskItem'
|
|
109
|
-
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
110
|
-
result << "<li>#{state} "
|
|
111
|
-
closing_tag = '</li>'
|
|
112
|
-
when 'taskList'
|
|
113
|
-
result << "<ul class='taskList'>"
|
|
114
|
-
closing_tag = '</ul>'
|
|
115
|
-
else
|
|
116
|
-
result << "<p>Unparseable section: #{node['type']}</p>"
|
|
39
|
+
adf_node_render(node) do |n|
|
|
40
|
+
node_attrs = n['attrs']
|
|
41
|
+
case n['type']
|
|
42
|
+
when 'blockquote' then ['<blockquote>', '</blockquote>']
|
|
43
|
+
when 'bulletList' then ['<ul>', '</ul>']
|
|
44
|
+
when 'codeBlock' then ['<code>', '</code>']
|
|
45
|
+
when 'date'
|
|
46
|
+
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
47
|
+
when 'decisionItem' then ['<li>', '</li>']
|
|
48
|
+
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
49
|
+
when 'emoji' then [node_attrs['text'], nil]
|
|
50
|
+
when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
|
|
51
|
+
when 'hardBreak' then ['<br />', nil]
|
|
52
|
+
when 'heading'
|
|
53
|
+
level = node_attrs['level']
|
|
54
|
+
["<h#{level}>", "</h#{level}>"]
|
|
55
|
+
when 'inlineCard'
|
|
56
|
+
url = node_attrs['url']
|
|
57
|
+
["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
|
|
58
|
+
when 'listItem' then ['<li>', '</li>']
|
|
59
|
+
when 'media'
|
|
60
|
+
text = node_attrs['alt'] || node_attrs['id']
|
|
61
|
+
["Media: #{text}", nil]
|
|
62
|
+
when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
|
|
63
|
+
when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
|
|
64
|
+
when 'orderedList' then ['<ol>', '</ol>']
|
|
65
|
+
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
66
|
+
when 'paragraph' then ['<p>', '</p>']
|
|
67
|
+
when 'rule' then ['<hr />', nil]
|
|
68
|
+
when 'status' then [node_attrs['text'], nil]
|
|
69
|
+
when 'table' then ['<table>', '</table>']
|
|
70
|
+
when 'tableCell' then ['<td>', '</td>']
|
|
71
|
+
when 'tableHeader' then ['<th>', '</th>']
|
|
72
|
+
when 'tableRow' then ['<tr>', '</tr>']
|
|
73
|
+
when 'text'
|
|
74
|
+
marks = adf_marks_to_html(n['marks'])
|
|
75
|
+
[marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
|
|
76
|
+
when 'taskItem'
|
|
77
|
+
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
78
|
+
["<li>#{state} ", '</li>']
|
|
79
|
+
when 'taskList' then ["<ul class='taskList'>", '</ul>']
|
|
80
|
+
else
|
|
81
|
+
["<p>Unparseable section: #{n['type']}</p>", nil]
|
|
82
|
+
end
|
|
117
83
|
end
|
|
84
|
+
end
|
|
118
85
|
|
|
119
|
-
|
|
120
|
-
|
|
86
|
+
def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
|
|
87
|
+
adf_node_render(node) do |n|
|
|
88
|
+
node_attrs = n['attrs']
|
|
89
|
+
case n['type']
|
|
90
|
+
when 'blockquote' then ['', nil]
|
|
91
|
+
when 'bulletList' then ['', nil]
|
|
92
|
+
when 'codeBlock' then ['', nil]
|
|
93
|
+
when 'date'
|
|
94
|
+
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
95
|
+
when 'decisionItem' then ['- ', "\n"]
|
|
96
|
+
when 'decisionList' then ["Decisions:\n", nil]
|
|
97
|
+
when 'emoji' then [node_attrs['text'], nil]
|
|
98
|
+
when 'expand' then ["#{node_attrs['title']}\n", nil]
|
|
99
|
+
when 'hardBreak' then ["\n", nil]
|
|
100
|
+
when 'heading' then ['', "\n"]
|
|
101
|
+
when 'inlineCard' then [node_attrs['url'], nil]
|
|
102
|
+
when 'listItem' then ['- ', nil]
|
|
103
|
+
when 'media'
|
|
104
|
+
text = node_attrs['alt'] || node_attrs['id']
|
|
105
|
+
["Media: #{text}", nil]
|
|
106
|
+
when 'mediaSingle', 'mediaGroup' then ['', nil]
|
|
107
|
+
when 'mention' then [node_attrs['text'], nil]
|
|
108
|
+
when 'orderedList' then ['', nil]
|
|
109
|
+
when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
|
|
110
|
+
when 'paragraph' then ['', "\n"]
|
|
111
|
+
when 'rule' then ["---\n", nil]
|
|
112
|
+
when 'status' then [node_attrs['text'], nil]
|
|
113
|
+
when 'table' then ['', nil]
|
|
114
|
+
when 'tableCell' then ['', "\t"]
|
|
115
|
+
when 'tableHeader' then ['', "\t"]
|
|
116
|
+
when 'tableRow' then ['', "\n"]
|
|
117
|
+
when 'text' then [n['text'], nil]
|
|
118
|
+
when 'taskItem'
|
|
119
|
+
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
120
|
+
["#{state} ", "\n"]
|
|
121
|
+
when 'taskList' then ['', nil]
|
|
122
|
+
else
|
|
123
|
+
["[Unparseable: #{n['type']}]\n", nil]
|
|
124
|
+
end
|
|
121
125
|
end
|
|
122
|
-
|
|
123
|
-
result << closing_tag if closing_tag
|
|
124
|
-
result
|
|
125
126
|
end
|
|
126
127
|
|
|
127
128
|
def adf_marks_to_html list
|
|
@@ -157,4 +158,14 @@ class AtlassianDocumentFormat
|
|
|
157
158
|
text = "@#{user.display_name}" if user
|
|
158
159
|
"<span class='account_id'>#{text}</span>"
|
|
159
160
|
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def adf_node_render node, &render_node
|
|
165
|
+
prefix, suffix = render_node.call(node)
|
|
166
|
+
result = +(prefix || '')
|
|
167
|
+
node['content']&.each { |child| result << adf_node_render(child, &render_node) }
|
|
168
|
+
result << suffix if suffix
|
|
169
|
+
result
|
|
170
|
+
end
|
|
160
171
|
end
|
|
@@ -4,10 +4,12 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class BlockedStalledChange
|
|
6
6
|
include ValueEquality
|
|
7
|
-
attr_reader :time, :blocking_issue_keys, :flag, :status, :stalled_days, :status_is_blocking
|
|
7
|
+
attr_reader :time, :blocking_issue_keys, :flag, :flag_reason, :status, :stalled_days, :status_is_blocking
|
|
8
8
|
|
|
9
|
-
def initialize time:, flagged: nil,
|
|
9
|
+
def initialize time:, flagged: nil, flag_reason: nil, status: nil, status_is_blocking: true,
|
|
10
|
+
blocking_issue_keys: nil, stalled_days: nil
|
|
10
11
|
@flag = flagged
|
|
12
|
+
@flag_reason = flag_reason
|
|
11
13
|
@status = status
|
|
12
14
|
@status_is_blocking = status_is_blocking
|
|
13
15
|
@blocking_issue_keys = blocking_issue_keys
|
|
@@ -25,7 +27,7 @@ class BlockedStalledChange
|
|
|
25
27
|
def reasons
|
|
26
28
|
result = []
|
|
27
29
|
if blocked?
|
|
28
|
-
result << 'Blocked by flag' if @flag
|
|
30
|
+
result << (@flag_reason ? "Blocked by flag: #{@flag_reason}" : 'Blocked by flag') if @flag
|
|
29
31
|
result << "Blocked by status: #{@status}" if blocked_by_status?
|
|
30
32
|
result << "Blocked by issues: #{@blocking_issue_keys.join(', ')}" if @blocking_issue_keys
|
|
31
33
|
elsif stalled_by_status?
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -4,10 +4,11 @@ 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_raw: nil
|
|
8
8
|
@raw = raw
|
|
9
9
|
@possible_statuses = possible_statuses
|
|
10
10
|
@sprints = []
|
|
11
|
+
@features_raw = features_raw
|
|
11
12
|
|
|
12
13
|
columns = raw['columnConfig']['columns']
|
|
13
14
|
ensure_uniqueness_of_column_names! columns
|
|
@@ -67,8 +68,21 @@ class Board
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def board_type = raw['type']
|
|
70
|
-
|
|
71
|
-
def scrum?
|
|
71
|
+
|
|
72
|
+
def scrum?
|
|
73
|
+
return true if board_type == 'scrum'
|
|
74
|
+
return false unless board_type == 'simple'
|
|
75
|
+
|
|
76
|
+
@features_raw&.[]('features')
|
|
77
|
+
&.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' } || false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kanban?
|
|
81
|
+
return true if board_type == 'kanban'
|
|
82
|
+
return false unless board_type == 'simple'
|
|
83
|
+
|
|
84
|
+
!scrum?
|
|
85
|
+
end
|
|
72
86
|
|
|
73
87
|
def id
|
|
74
88
|
@raw['id'].to_i
|
|
@@ -18,7 +18,7 @@ class ChangeItem
|
|
|
18
18
|
@value_id = @raw['to'].split(', ').collect(&:to_i)
|
|
19
19
|
@old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
|
|
20
20
|
else
|
|
21
|
-
@value_id = @raw['to']
|
|
21
|
+
@value_id = @raw['to']&.to_i
|
|
22
22
|
@old_value_id = @raw['from']&.to_i
|
|
23
23
|
end
|
|
24
24
|
@field_id = @raw['fieldId']
|
|
@@ -46,6 +46,7 @@ class ChangeItem
|
|
|
46
46
|
def resolution? = (field == 'resolution')
|
|
47
47
|
def sprint? = (field == 'Sprint')
|
|
48
48
|
def status? = (field == 'status')
|
|
49
|
+
def fix_version? = (field == 'Fix Version')
|
|
49
50
|
|
|
50
51
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
51
52
|
def to_time = @time
|
|
@@ -54,10 +55,10 @@ class ChangeItem
|
|
|
54
55
|
message = +''
|
|
55
56
|
message << "ChangeItem(field: #{field.inspect}"
|
|
56
57
|
message << ", value: #{value.inspect}"
|
|
57
|
-
message << ':' << value_id.inspect if
|
|
58
|
+
message << ':' << value_id.inspect if value_id
|
|
58
59
|
if old_value
|
|
59
60
|
message << ", old_value: #{old_value.inspect}"
|
|
60
|
-
message << ':' << old_value_id.inspect if
|
|
61
|
+
message << ':' << old_value_id.inspect if old_value_id
|
|
61
62
|
end
|
|
62
63
|
message << ", time: #{time_to_s(@time).inspect}"
|
|
63
64
|
message << ", field_id: #{@field_id.inspect}" if @field_id
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
5
|
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system,
|
|
6
|
-
:atlassian_document_format
|
|
6
|
+
:atlassian_document_format, :x_axis_title, :y_axis_title, :fix_versions
|
|
7
7
|
attr_writer :aggregated_project
|
|
8
8
|
attr_reader :canvas_width, :canvas_height
|
|
9
9
|
|
|
@@ -80,10 +80,20 @@ class ChartBase
|
|
|
80
80
|
"#{days} day#{'s' unless days == 1}"
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
def label_hours hours
|
|
84
|
+
return 'unknown' if hours.nil?
|
|
85
|
+
|
|
86
|
+
"#{hours} hour#{'s' unless hours == 1}"
|
|
87
|
+
end
|
|
88
|
+
|
|
83
89
|
def label_issues count
|
|
84
90
|
"#{count} issue#{'s' unless count == 1}"
|
|
85
91
|
end
|
|
86
92
|
|
|
93
|
+
def to_human_readable number
|
|
94
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
88
98
|
{
|
|
89
99
|
type: 'bar',
|
|
@@ -155,6 +165,56 @@ class ChartBase
|
|
|
155
165
|
end.join
|
|
156
166
|
end
|
|
157
167
|
|
|
168
|
+
LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
|
|
169
|
+
|
|
170
|
+
def date_annotation
|
|
171
|
+
annotations = settings['date_annotations'] || []
|
|
172
|
+
in_range = annotations
|
|
173
|
+
.map { |a| [a, normalize_annotation_datetime(a['date'])] }
|
|
174
|
+
.select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
|
|
175
|
+
.sort_by { |(_, dt)| dt }
|
|
176
|
+
|
|
177
|
+
positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
|
|
178
|
+
|
|
179
|
+
in_range.each_with_index.collect do |(a, normalized), index|
|
|
180
|
+
<<~TEXT
|
|
181
|
+
dateAnnotation#{index}: {
|
|
182
|
+
type: 'line',
|
|
183
|
+
xMin: #{normalized.to_json},
|
|
184
|
+
xMax: #{normalized.to_json},
|
|
185
|
+
borderColor: 'rgba(0,0,0,0.7)',
|
|
186
|
+
borderWidth: 1,
|
|
187
|
+
label: {
|
|
188
|
+
display: true,
|
|
189
|
+
content: #{a['label'].to_json},
|
|
190
|
+
position: #{positions[index].to_json}
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
TEXT
|
|
194
|
+
end.join
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def stagger_label_positions datetimes
|
|
198
|
+
return [] if datetimes.empty?
|
|
199
|
+
|
|
200
|
+
threshold_days = (date_range.end - date_range.begin).to_f / 5.0
|
|
201
|
+
slot = 0
|
|
202
|
+
[LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
|
|
203
|
+
days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
|
|
204
|
+
slot = days_apart < threshold_days ? slot + 1 : 0
|
|
205
|
+
LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def normalize_annotation_datetime value
|
|
210
|
+
offset = timezone_offset || '+00:00'
|
|
211
|
+
if value.include?('T')
|
|
212
|
+
value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
|
|
213
|
+
else
|
|
214
|
+
"#{value}T00:00:00#{offset}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
158
218
|
# Return only the board columns for the current board.
|
|
159
219
|
def current_board
|
|
160
220
|
if @board_id.nil?
|
|
@@ -310,4 +370,23 @@ class ChartBase
|
|
|
310
370
|
def seam_end type = 'chart'
|
|
311
371
|
"\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
|
|
312
372
|
end
|
|
373
|
+
|
|
374
|
+
def render_axis_title axis_direction
|
|
375
|
+
text = case axis_direction
|
|
376
|
+
when :x
|
|
377
|
+
x_axis_title
|
|
378
|
+
when :y
|
|
379
|
+
y_axis_title
|
|
380
|
+
else
|
|
381
|
+
raise "Unexpected axis_direction: #{axis_direction}"
|
|
382
|
+
end
|
|
383
|
+
return '' unless text
|
|
384
|
+
|
|
385
|
+
<<~CONTENT
|
|
386
|
+
title: {
|
|
387
|
+
display: true,
|
|
388
|
+
text: "#{text}"
|
|
389
|
+
},
|
|
390
|
+
CONTENT
|
|
391
|
+
end
|
|
313
392
|
end
|
|
@@ -6,10 +6,9 @@ require 'date'
|
|
|
6
6
|
class CycleTimeConfig
|
|
7
7
|
include SelfOrIssueDispatcher
|
|
8
8
|
|
|
9
|
-
attr_reader :label, :
|
|
9
|
+
attr_reader :label, :settings, :file_system
|
|
10
10
|
|
|
11
11
|
def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
|
|
12
|
-
|
|
13
12
|
@possible_statuses = possible_statuses
|
|
14
13
|
@label = label
|
|
15
14
|
@today = today
|