jirametrics 2.14 → 2.30
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/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
- data/lib/jirametrics/aging_work_table.rb +9 -7
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +96 -96
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +32 -8
- data/lib/jirametrics/board_config.rb +3 -1
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +2 -2
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +14 -6
- data/lib/jirametrics/chart_base.rb +139 -3
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +42 -31
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +117 -100
- data/lib/jirametrics/downloader_for_cloud.rb +287 -0
- data/lib/jirametrics/downloader_for_data_center.rb +95 -0
- data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +41 -28
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +26 -6
- data/lib/jirametrics/file_config.rb +9 -11
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +244 -69
- data/lib/jirametrics/html/index.erb +9 -35
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -57
- data/lib/jirametrics/issue.rb +302 -98
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +77 -17
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +108 -9
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +5 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +10 -4
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +81 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +73 -23
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +107 -0
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -69
- metadata +60 -6
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require 'random-word'
|
|
4
4
|
|
|
5
|
-
class Anonymizer
|
|
5
|
+
class Anonymizer < ChartBase
|
|
6
6
|
# needed for testing
|
|
7
7
|
attr_reader :project_config, :issues
|
|
8
8
|
|
|
9
9
|
def initialize project_config:, date_adjustment: -200
|
|
10
|
+
super()
|
|
10
11
|
@project_config = project_config
|
|
11
12
|
@issues = @project_config.issues
|
|
12
13
|
@all_boards = @project_config.all_boards
|
|
@@ -20,6 +21,10 @@ class Anonymizer
|
|
|
20
21
|
anonymize_column_names
|
|
21
22
|
# anonymize_issue_statuses
|
|
22
23
|
anonymize_board_names
|
|
24
|
+
anonymize_labels_and_components
|
|
25
|
+
anonymize_sprints
|
|
26
|
+
anonymize_fix_versions
|
|
27
|
+
anonymize_server_url
|
|
23
28
|
shift_all_dates unless @date_adjustment.zero?
|
|
24
29
|
@file_system.log 'Anonymize done'
|
|
25
30
|
end
|
|
@@ -37,13 +42,25 @@ class Anonymizer
|
|
|
37
42
|
|
|
38
43
|
def anonymize_issue_keys_and_titles issues: @issues
|
|
39
44
|
counter = 0
|
|
45
|
+
seen_author_raws = {}
|
|
40
46
|
issues.each do |issue|
|
|
41
47
|
new_key = "ANON-#{counter += 1}"
|
|
42
48
|
|
|
43
49
|
issue.raw['key'] = new_key
|
|
44
50
|
issue.raw['fields']['summary'] = random_phrase
|
|
51
|
+
issue.raw['fields']['description'] = nil
|
|
45
52
|
issue.raw['fields']['assignee']['displayName'] = random_name unless issue.raw['fields']['assignee'].nil?
|
|
46
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
|
+
|
|
47
64
|
issue.issue_links.each do |link|
|
|
48
65
|
other_issue = link.other_issue
|
|
49
66
|
next if other_issue.key.match?(/^ANON-\d+$/) # Already anonymized?
|
|
@@ -54,6 +71,49 @@ class Anonymizer
|
|
|
54
71
|
end
|
|
55
72
|
end
|
|
56
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
|
+
|
|
57
117
|
def anonymize_column_names
|
|
58
118
|
@all_boards.each_key do |board_id|
|
|
59
119
|
@file_system.log "Anonymizing column names for board #{board_id}"
|
|
@@ -130,18 +190,19 @@ class Anonymizer
|
|
|
130
190
|
end
|
|
131
191
|
end
|
|
132
192
|
|
|
133
|
-
def shift_all_dates
|
|
134
|
-
|
|
193
|
+
def shift_all_dates date_adjustment: @date_adjustment
|
|
194
|
+
adjustment_in_seconds = 60 * 60 * 24 * date_adjustment
|
|
195
|
+
@file_system.log "Shifting all dates by #{label_days date_adjustment}"
|
|
135
196
|
@issues.each do |issue|
|
|
136
197
|
issue.changes.each do |change|
|
|
137
|
-
change.time = change.time +
|
|
198
|
+
change.time = change.time + adjustment_in_seconds
|
|
138
199
|
end
|
|
139
200
|
|
|
140
|
-
issue.raw['fields']['updated'] = (issue.updated +
|
|
201
|
+
issue.raw['fields']['updated'] = (issue.updated + adjustment_in_seconds).to_s
|
|
141
202
|
end
|
|
142
203
|
|
|
143
204
|
range = @project_config.time_range
|
|
144
|
-
@project_config.time_range = (range.begin +
|
|
205
|
+
@project_config.time_range = (range.begin + adjustment_in_seconds)..(range.end + adjustment_in_seconds)
|
|
145
206
|
end
|
|
146
207
|
|
|
147
208
|
def random_name
|
|
@@ -184,4 +245,18 @@ class Anonymizer
|
|
|
184
245
|
board.raw['name'] = "#{random_phrase} board"
|
|
185
246
|
end
|
|
186
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
|
|
187
262
|
end
|
|
@@ -13,9 +13,9 @@ class AtlassianDocumentFormat
|
|
|
13
13
|
input
|
|
14
14
|
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
15
|
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
16
|
-
.gsub(/\[([
|
|
16
|
+
.gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
17
17
|
.gsub("\n", '<br />')
|
|
18
|
-
elsif input['content'
|
|
18
|
+
elsif input&.[]('content')
|
|
19
19
|
input['content'].collect { |element| adf_node_to_html element }.join("\n")
|
|
20
20
|
else
|
|
21
21
|
# We have an actual ADF document with no content.
|
|
@@ -23,105 +23,95 @@ 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
|
-
when 'mediaSingle', 'mediaGroup'
|
|
72
|
-
result << '<div>'
|
|
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', 'listItem' then ['<li>', '</li>']
|
|
48
|
+
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
49
|
+
when 'emoji', 'status' 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 'media'
|
|
59
|
+
text = node_attrs['alt'] || node_attrs['id']
|
|
60
|
+
["Media: #{text}", nil]
|
|
61
|
+
when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
|
|
62
|
+
when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
|
|
63
|
+
when 'orderedList' then ['<ol>', '</ol>']
|
|
64
|
+
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
65
|
+
when 'paragraph' then ['<p>', '</p>']
|
|
66
|
+
when 'rule' then ['<hr />', nil]
|
|
67
|
+
when 'table' then ['<table>', '</table>']
|
|
68
|
+
when 'tableCell' then ['<td>', '</td>']
|
|
69
|
+
when 'tableHeader' then ['<th>', '</th>']
|
|
70
|
+
when 'tableRow' then ['<tr>', '</tr>']
|
|
71
|
+
when 'text'
|
|
72
|
+
marks = adf_marks_to_html(n['marks'])
|
|
73
|
+
[marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
|
|
74
|
+
when 'taskItem'
|
|
75
|
+
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
76
|
+
["<li>#{state} ", '</li>']
|
|
77
|
+
when 'taskList' then ["<ul class='taskList'>", '</ul>']
|
|
78
|
+
else
|
|
79
|
+
["<p>Unparseable section: #{n['type']}</p>", nil]
|
|
80
|
+
end
|
|
117
81
|
end
|
|
82
|
+
end
|
|
118
83
|
|
|
119
|
-
|
|
120
|
-
|
|
84
|
+
def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
|
|
85
|
+
adf_node_render(node) do |n|
|
|
86
|
+
node_attrs = n['attrs']
|
|
87
|
+
case n['type']
|
|
88
|
+
when 'blockquote', 'bulletList', 'codeBlock',
|
|
89
|
+
'mediaSingle', 'mediaGroup',
|
|
90
|
+
'orderedList', 'table', 'taskList' then ['', nil]
|
|
91
|
+
when 'date'
|
|
92
|
+
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
93
|
+
when 'decisionItem' then ['- ', "\n"]
|
|
94
|
+
when 'decisionList' then ["Decisions:\n", nil]
|
|
95
|
+
when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
|
|
96
|
+
when 'expand' then ["#{node_attrs['title']}\n", nil]
|
|
97
|
+
when 'hardBreak' then ["\n", nil]
|
|
98
|
+
when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
|
|
99
|
+
when 'inlineCard' then [node_attrs['url'], nil]
|
|
100
|
+
when 'listItem' then ['- ', nil]
|
|
101
|
+
when 'media'
|
|
102
|
+
text = node_attrs['alt'] || node_attrs['id']
|
|
103
|
+
["Media: #{text}", nil]
|
|
104
|
+
when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
|
|
105
|
+
when 'rule' then ["---\n", nil]
|
|
106
|
+
when 'tableCell', 'tableHeader' then ['', "\t"]
|
|
107
|
+
when 'text' then [n['text'], nil]
|
|
108
|
+
when 'taskItem'
|
|
109
|
+
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
110
|
+
["#{state} ", "\n"]
|
|
111
|
+
else
|
|
112
|
+
["[Unparseable: #{n['type']}]\n", nil]
|
|
113
|
+
end
|
|
121
114
|
end
|
|
122
|
-
|
|
123
|
-
result << closing_tag if closing_tag
|
|
124
|
-
result
|
|
125
115
|
end
|
|
126
116
|
|
|
127
117
|
def adf_marks_to_html list
|
|
@@ -157,4 +147,14 @@ class AtlassianDocumentFormat
|
|
|
157
147
|
text = "@#{user.display_name}" if user
|
|
158
148
|
"<span class='account_id'>#{text}</span>"
|
|
159
149
|
end
|
|
160
|
-
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def adf_node_render node, &render_node
|
|
154
|
+
prefix, suffix = render_node.call(node)
|
|
155
|
+
result = +(prefix || '')
|
|
156
|
+
node['content']&.each { |child| result << adf_node_render(child, &render_node) }
|
|
157
|
+
result << suffix if suffix
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/value_equality'
|
|
4
|
+
|
|
5
|
+
class BarChartRange
|
|
6
|
+
include ValueEquality
|
|
7
|
+
|
|
8
|
+
attr_accessor :start, :stop, :color, :title, :highlight
|
|
9
|
+
|
|
10
|
+
def initialize start:, stop:, color:, title:, highlight: false
|
|
11
|
+
@start = start
|
|
12
|
+
@stop = stop
|
|
13
|
+
@color = color
|
|
14
|
+
@title = title
|
|
15
|
+
@highlight = highlight
|
|
16
|
+
end
|
|
17
|
+
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,18 +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
|
+
@features = features
|
|
11
12
|
|
|
12
13
|
columns = raw['columnConfig']['columns']
|
|
13
14
|
ensure_uniqueness_of_column_names! columns
|
|
14
15
|
|
|
15
|
-
# For a Kanban board, the first column
|
|
16
|
-
# visible on the board.
|
|
17
|
-
|
|
18
|
-
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'
|
|
19
19
|
|
|
20
20
|
@backlog_statuses = []
|
|
21
21
|
@visible_columns = columns.filter_map do |column|
|
|
@@ -25,7 +25,7 @@ class Board
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def backlog_statuses
|
|
28
|
-
if @backlog_statuses.empty? && kanban
|
|
28
|
+
if @backlog_statuses.empty? && board_type == 'kanban'
|
|
29
29
|
status_ids = status_ids_from_column raw['columnConfig']['columns'].first
|
|
30
30
|
@backlog_statuses = status_ids.filter_map do |id|
|
|
31
31
|
@possible_statuses.find_by_id id
|
|
@@ -67,8 +67,28 @@ class Board
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def board_type = raw['type']
|
|
70
|
-
|
|
71
|
-
def scrum?
|
|
70
|
+
|
|
71
|
+
def scrum?
|
|
72
|
+
return true if board_type == 'scrum'
|
|
73
|
+
return false unless board_type == 'simple'
|
|
74
|
+
|
|
75
|
+
has_sprints_feature?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def kanban?
|
|
79
|
+
return true if board_type == 'kanban'
|
|
80
|
+
return false unless board_type == 'simple'
|
|
81
|
+
|
|
82
|
+
!scrum?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def team_managed_kanban?
|
|
86
|
+
board_type == 'simple' && !has_sprints_feature?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def has_sprints_feature?
|
|
90
|
+
@features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
91
|
+
end
|
|
72
92
|
|
|
73
93
|
def id
|
|
74
94
|
@raw['id'].to_i
|
|
@@ -116,4 +136,8 @@ class Board
|
|
|
116
136
|
def estimation_configuration
|
|
117
137
|
EstimationConfiguration.new raw: raw['estimation']
|
|
118
138
|
end
|
|
139
|
+
|
|
140
|
+
def inspect
|
|
141
|
+
"Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
|
|
142
|
+
end
|
|
119
143
|
end
|
|
@@ -24,7 +24,9 @@ class BoardConfig
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
@board.cycletime = CycleTimeConfig.new(
|
|
27
|
-
|
|
27
|
+
possible_statuses: project_config.possible_statuses,
|
|
28
|
+
label: label, block: block, file_system: project_config.file_system,
|
|
29
|
+
settings: project_config.settings
|
|
28
30
|
)
|
|
29
31
|
end
|
|
30
32
|
|
|
@@ -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
|
|
@@ -10,7 +10,7 @@ class BoardMovementCalculator
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def moves_backwards? issue
|
|
13
|
-
started, stopped = issue.
|
|
13
|
+
started, stopped = issue.started_stopped_times
|
|
14
14
|
return false unless started
|
|
15
15
|
|
|
16
16
|
previous_column = nil
|
|
@@ -70,7 +70,7 @@ class BoardMovementCalculator
|
|
|
70
70
|
@issues.filter_map do |issue|
|
|
71
71
|
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
72
|
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
-
issue_start, issue_done = issue.
|
|
73
|
+
issue_start, issue_done = issue.started_stopped_times
|
|
74
74
|
|
|
75
75
|
# Skip if we can't tell when it started.
|
|
76
76
|
next if issue_start.nil?
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CfdDataBuilder
|
|
4
|
+
def initialize board:, issues:, date_range:, columns: nil
|
|
5
|
+
@board = board
|
|
6
|
+
@issues = issues
|
|
7
|
+
@date_range = date_range
|
|
8
|
+
@columns = columns || board.visible_columns
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
column_map = build_column_map
|
|
13
|
+
issue_states = @issues.map { |issue| process_issue(issue, column_map) }
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
columns: @columns.map(&:name),
|
|
17
|
+
daily_counts: build_daily_counts(issue_states),
|
|
18
|
+
correction_windows: issue_states.flat_map { |s| s[:correction_windows] }
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build_column_map
|
|
25
|
+
map = {}
|
|
26
|
+
@columns.each_with_index do |column, index|
|
|
27
|
+
column.status_ids.each { |id| map[id] = index }
|
|
28
|
+
end
|
|
29
|
+
map
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns { hwm_timeline: [[date, hwm_value], ...], correction_windows: [...] }
|
|
33
|
+
def process_issue issue, column_map
|
|
34
|
+
start_time = issue.started_stopped_times.first
|
|
35
|
+
return { hwm_timeline: [], correction_windows: [] } if start_time.nil?
|
|
36
|
+
|
|
37
|
+
high_water_mark = nil
|
|
38
|
+
correction_open_since = nil
|
|
39
|
+
correction_windows = []
|
|
40
|
+
hwm_timeline = [] # sorted chronologically by date
|
|
41
|
+
|
|
42
|
+
issue.status_changes.each do |change|
|
|
43
|
+
next if change.time < start_time
|
|
44
|
+
|
|
45
|
+
col_index = column_map[change.value_id]
|
|
46
|
+
next if col_index.nil?
|
|
47
|
+
|
|
48
|
+
if high_water_mark.nil? || col_index > high_water_mark
|
|
49
|
+
# Forward movement: advance hwm, close any open correction window, record timeline entry
|
|
50
|
+
if correction_open_since
|
|
51
|
+
correction_windows << {
|
|
52
|
+
start_date: correction_open_since,
|
|
53
|
+
end_date: change.time.to_date,
|
|
54
|
+
column_index: high_water_mark
|
|
55
|
+
}
|
|
56
|
+
correction_open_since = nil
|
|
57
|
+
end
|
|
58
|
+
high_water_mark = col_index
|
|
59
|
+
hwm_timeline << [change.time.to_date, high_water_mark]
|
|
60
|
+
elsif col_index == high_water_mark && correction_open_since
|
|
61
|
+
# Same-column recovery: close the correction window without changing hwm or adding timeline entry
|
|
62
|
+
correction_windows << {
|
|
63
|
+
start_date: correction_open_since,
|
|
64
|
+
end_date: change.time.to_date,
|
|
65
|
+
column_index: high_water_mark
|
|
66
|
+
}
|
|
67
|
+
correction_open_since = nil
|
|
68
|
+
elsif col_index < high_water_mark
|
|
69
|
+
# Backwards movement: open correction window if not already open
|
|
70
|
+
correction_open_since ||= change.time.to_date
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if correction_open_since
|
|
75
|
+
correction_windows << {
|
|
76
|
+
start_date: correction_open_since,
|
|
77
|
+
end_date: @date_range.end,
|
|
78
|
+
column_index: high_water_mark
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ hwm_timeline: hwm_timeline, correction_windows: correction_windows }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def hwm_at hwm_timeline, date
|
|
86
|
+
result = nil
|
|
87
|
+
hwm_timeline.each do |timeline_date, hwm|
|
|
88
|
+
break if timeline_date > date
|
|
89
|
+
|
|
90
|
+
result = hwm
|
|
91
|
+
end
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_daily_counts issue_states
|
|
96
|
+
column_count = @columns.size
|
|
97
|
+
@date_range.each_with_object({}) do |date, result|
|
|
98
|
+
counts = Array.new(column_count, 0)
|
|
99
|
+
issue_states.each do |state|
|
|
100
|
+
hwm = hwm_at(state[:hwm_timeline], date)
|
|
101
|
+
next if hwm.nil?
|
|
102
|
+
|
|
103
|
+
(0..hwm).each { |i| counts[i] += 1 }
|
|
104
|
+
end
|
|
105
|
+
result[date] = counts
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|