jirametrics 2.20.1 → 2.25
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 +189 -133
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/anonymizer.rb +74 -1
- data/lib/jirametrics/atlassian_document_format.rb +93 -93
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -3
- data/lib/jirametrics/board.rb +24 -8
- data/lib/jirametrics/board_config.rb +2 -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 +103 -0
- data/lib/jirametrics/change_item.rb +13 -5
- data/lib/jirametrics/chart_base.rb +124 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
- data/lib/jirametrics/cycletime_histogram.rb +15 -103
- data/lib/jirametrics/cycletime_scatterplot.rb +15 -85
- data/lib/jirametrics/daily_view.rb +35 -11
- 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 +37 -11
- data/lib/jirametrics/dependency_chart.rb +1 -1
- 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/aggregated_project.rb +1 -1
- data/lib/jirametrics/examples/standard_project.rb +28 -18
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +7 -3
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
- data/lib/jirametrics/github_gateway.rb +106 -0
- data/lib/jirametrics/groupable_issue_chart.rb +9 -1
- data/lib/jirametrics/grouping_rules.rb +21 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
- data/lib/jirametrics/html/aging_work_table.erb +5 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -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 +134 -0
- data/lib/jirametrics/html/index.erb +6 -1
- data/lib/jirametrics/html/index.js +76 -2
- data/lib/jirametrics/html/sprint_burndown.erb +12 -12
- 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} +8 -9
- data/lib/jirametrics/html_generator.rb +31 -0
- data/lib/jirametrics/html_report_config.rb +26 -39
- data/lib/jirametrics/issue.rb +186 -88
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +6 -3
- data/lib/jirametrics/project_config.rb +78 -8
- 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 +81 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +3 -1
- data/lib/jirametrics/sprint.rb +12 -0
- data/lib/jirametrics/sprint_burndown.rb +9 -3
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/stitcher.rb +76 -0
- data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
- data/lib/jirametrics/throughput_chart.rb +56 -22
- data/lib/jirametrics/time_based_histogram.rb +139 -0
- data/lib/jirametrics/time_based_scatterplot.rb +100 -0
- data/lib/jirametrics.rb +8 -1
- metadata +22 -5
|
@@ -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
|
|
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
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,20 @@ 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
|
+
@features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
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
|
|
72
84
|
|
|
73
85
|
def id
|
|
74
86
|
@raw['id'].to_i
|
|
@@ -116,4 +128,8 @@ class Board
|
|
|
116
128
|
def estimation_configuration
|
|
117
129
|
EstimationConfiguration.new raw: raw['estimation']
|
|
118
130
|
end
|
|
131
|
+
|
|
132
|
+
def inspect
|
|
133
|
+
"Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
|
|
134
|
+
end
|
|
119
135
|
end
|
|
@@ -24,7 +24,8 @@ 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,
|
|
28
29
|
settings: project_config.settings
|
|
29
30
|
)
|
|
30
31
|
end
|
|
@@ -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,103 @@
|
|
|
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
|
+
high_water_mark = nil
|
|
35
|
+
correction_open_since = nil
|
|
36
|
+
correction_windows = []
|
|
37
|
+
hwm_timeline = [] # sorted chronologically by date
|
|
38
|
+
|
|
39
|
+
issue.status_changes.each do |change|
|
|
40
|
+
col_index = column_map[change.value_id]
|
|
41
|
+
next if col_index.nil?
|
|
42
|
+
|
|
43
|
+
if high_water_mark.nil? || col_index > high_water_mark
|
|
44
|
+
# Forward movement: advance hwm, close any open correction window, record timeline entry
|
|
45
|
+
if correction_open_since
|
|
46
|
+
correction_windows << {
|
|
47
|
+
start_date: correction_open_since,
|
|
48
|
+
end_date: change.time.to_date,
|
|
49
|
+
column_index: high_water_mark
|
|
50
|
+
}
|
|
51
|
+
correction_open_since = nil
|
|
52
|
+
end
|
|
53
|
+
high_water_mark = col_index
|
|
54
|
+
hwm_timeline << [change.time.to_date, high_water_mark]
|
|
55
|
+
elsif col_index == high_water_mark && correction_open_since
|
|
56
|
+
# Same-column recovery: close the correction window without changing hwm or adding timeline entry
|
|
57
|
+
correction_windows << {
|
|
58
|
+
start_date: correction_open_since,
|
|
59
|
+
end_date: change.time.to_date,
|
|
60
|
+
column_index: high_water_mark
|
|
61
|
+
}
|
|
62
|
+
correction_open_since = nil
|
|
63
|
+
elsif col_index < high_water_mark
|
|
64
|
+
# Backwards movement: open correction window if not already open
|
|
65
|
+
correction_open_since ||= change.time.to_date
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if correction_open_since
|
|
70
|
+
correction_windows << {
|
|
71
|
+
start_date: correction_open_since,
|
|
72
|
+
end_date: @date_range.end,
|
|
73
|
+
column_index: high_water_mark
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ hwm_timeline: hwm_timeline, correction_windows: correction_windows }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def hwm_at hwm_timeline, date
|
|
81
|
+
result = nil
|
|
82
|
+
hwm_timeline.each do |timeline_date, hwm|
|
|
83
|
+
break if timeline_date > date
|
|
84
|
+
|
|
85
|
+
result = hwm
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_daily_counts issue_states
|
|
91
|
+
column_count = @columns.size
|
|
92
|
+
@date_range.each_with_object({}) do |date, result|
|
|
93
|
+
counts = Array.new(column_count, 0)
|
|
94
|
+
issue_states.each do |state|
|
|
95
|
+
hwm = hwm_at(state[:hwm_timeline], date)
|
|
96
|
+
next if hwm.nil?
|
|
97
|
+
|
|
98
|
+
(0..hwm).each { |i| counts[i] += 1 }
|
|
99
|
+
end
|
|
100
|
+
result[date] = counts
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :author_raw
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :author_raw, :field_id
|
|
5
5
|
attr_accessor :value, :old_value, :time
|
|
6
6
|
|
|
7
7
|
def initialize raw:, author_raw:, time:, artificial: false
|
|
@@ -13,9 +13,15 @@ class ChangeItem
|
|
|
13
13
|
|
|
14
14
|
@field = @raw['field']
|
|
15
15
|
@value = @raw['toString']
|
|
16
|
-
@value_id = @raw['to'].to_i
|
|
17
16
|
@old_value = @raw['fromString']
|
|
18
|
-
|
|
17
|
+
if sprint?
|
|
18
|
+
@value_id = @raw['to'].split(', ').collect(&:to_i)
|
|
19
|
+
@old_value_id = (@raw['from'] || '').split(', ').collect(&:to_i)
|
|
20
|
+
else
|
|
21
|
+
@value_id = @raw['to']&.to_i
|
|
22
|
+
@old_value_id = @raw['from']&.to_i
|
|
23
|
+
end
|
|
24
|
+
@field_id = @raw['fieldId']
|
|
19
25
|
@artificial = artificial
|
|
20
26
|
end
|
|
21
27
|
|
|
@@ -40,6 +46,7 @@ class ChangeItem
|
|
|
40
46
|
def resolution? = (field == 'resolution')
|
|
41
47
|
def sprint? = (field == 'Sprint')
|
|
42
48
|
def status? = (field == 'status')
|
|
49
|
+
def fix_version? = (field == 'Fix Version')
|
|
43
50
|
|
|
44
51
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
45
52
|
def to_time = @time
|
|
@@ -48,12 +55,13 @@ class ChangeItem
|
|
|
48
55
|
message = +''
|
|
49
56
|
message << "ChangeItem(field: #{field.inspect}"
|
|
50
57
|
message << ", value: #{value.inspect}"
|
|
51
|
-
message << ':' << value_id.inspect if
|
|
58
|
+
message << ':' << value_id.inspect if value_id
|
|
52
59
|
if old_value
|
|
53
60
|
message << ", old_value: #{old_value.inspect}"
|
|
54
|
-
message << ':' << old_value_id.inspect if
|
|
61
|
+
message << ':' << old_value_id.inspect if old_value_id
|
|
55
62
|
end
|
|
56
63
|
message << ", time: #{time_to_s(@time).inspect}"
|
|
64
|
+
message << ", field_id: #{@field_id.inspect}" if @field_id
|
|
57
65
|
message << ', artificial' if artificial?
|
|
58
66
|
message << ')'
|
|
59
67
|
message
|
|
@@ -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
|
|
|
@@ -22,6 +22,14 @@ class ChartBase
|
|
|
22
22
|
@canvas_responsive = true
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def call_before_run &proc
|
|
26
|
+
(@call_before_run_procs ||= []) << proc
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def before_run
|
|
30
|
+
@call_before_run_procs&.each { |proc| proc.call }
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
def aggregated_project?
|
|
26
34
|
@aggregated_project
|
|
27
35
|
end
|
|
@@ -72,10 +80,26 @@ class ChartBase
|
|
|
72
80
|
"#{days} day#{'s' unless days == 1}"
|
|
73
81
|
end
|
|
74
82
|
|
|
83
|
+
def label_hours hours
|
|
84
|
+
return 'unknown' if hours.nil?
|
|
85
|
+
|
|
86
|
+
"#{hours} hour#{'s' unless hours == 1}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def label_minutes minutes
|
|
90
|
+
return 'unknown' if minutes.nil?
|
|
91
|
+
|
|
92
|
+
"#{minutes} minute#{'s' unless minutes == 1}"
|
|
93
|
+
end
|
|
94
|
+
|
|
75
95
|
def label_issues count
|
|
76
96
|
"#{count} issue#{'s' unless count == 1}"
|
|
77
97
|
end
|
|
78
98
|
|
|
99
|
+
def to_human_readable number
|
|
100
|
+
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
101
|
+
end
|
|
102
|
+
|
|
79
103
|
def daily_chart_dataset date_issues_list:, color:, label:, positive: true
|
|
80
104
|
{
|
|
81
105
|
type: 'bar',
|
|
@@ -147,6 +171,56 @@ class ChartBase
|
|
|
147
171
|
end.join
|
|
148
172
|
end
|
|
149
173
|
|
|
174
|
+
LABEL_POSITIONS = %w[5% 25% 45% 65%].freeze
|
|
175
|
+
|
|
176
|
+
def date_annotation
|
|
177
|
+
annotations = settings['date_annotations'] || []
|
|
178
|
+
in_range = annotations
|
|
179
|
+
.map { |a| [a, normalize_annotation_datetime(a['date'])] }
|
|
180
|
+
.select { |(_, dt)| date_range.cover?(Date.parse(dt)) }
|
|
181
|
+
.sort_by { |(_, dt)| dt }
|
|
182
|
+
|
|
183
|
+
positions = stagger_label_positions(in_range.map { |(_, dt)| dt })
|
|
184
|
+
|
|
185
|
+
in_range.each_with_index.collect do |(a, normalized), index|
|
|
186
|
+
<<~TEXT
|
|
187
|
+
dateAnnotation#{index}: {
|
|
188
|
+
type: 'line',
|
|
189
|
+
xMin: #{normalized.to_json},
|
|
190
|
+
xMax: #{normalized.to_json},
|
|
191
|
+
borderColor: 'rgba(0,0,0,0.7)',
|
|
192
|
+
borderWidth: 1,
|
|
193
|
+
label: {
|
|
194
|
+
display: true,
|
|
195
|
+
content: #{a['label'].to_json},
|
|
196
|
+
position: #{positions[index].to_json}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
TEXT
|
|
200
|
+
end.join
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def stagger_label_positions datetimes
|
|
204
|
+
return [] if datetimes.empty?
|
|
205
|
+
|
|
206
|
+
threshold_days = (date_range.end - date_range.begin).to_f / 5.0
|
|
207
|
+
slot = 0
|
|
208
|
+
[LABEL_POSITIONS[0]] + datetimes.each_cons(2).map do |a, b|
|
|
209
|
+
days_apart = (Date.parse(b) - Date.parse(a)).to_f.abs
|
|
210
|
+
slot = days_apart < threshold_days ? slot + 1 : 0
|
|
211
|
+
LABEL_POSITIONS[slot % LABEL_POSITIONS.size]
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_annotation_datetime value
|
|
216
|
+
offset = timezone_offset || '+00:00'
|
|
217
|
+
if value.include?('T')
|
|
218
|
+
value.match?(/([+-]\d{2}:\d{2}|Z)$/) ? value : "#{value}#{offset}"
|
|
219
|
+
else
|
|
220
|
+
"#{value}T00:00:00#{offset}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
150
224
|
# Return only the board columns for the current board.
|
|
151
225
|
def current_board
|
|
152
226
|
if @board_id.nil?
|
|
@@ -237,6 +311,13 @@ class ChartBase
|
|
|
237
311
|
"<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
|
|
238
312
|
end
|
|
239
313
|
|
|
314
|
+
def not_visible_text issue
|
|
315
|
+
reasons = issue.reasons_not_visible_on_board
|
|
316
|
+
return nil if reasons.empty?
|
|
317
|
+
|
|
318
|
+
"<span style='background: var(--warning-banner)'>Not visible on board: #{reasons.join(', ')}</span>"
|
|
319
|
+
end
|
|
320
|
+
|
|
240
321
|
def status_category_color status
|
|
241
322
|
case status.category.key
|
|
242
323
|
when 'new' then CssVariable['--status-category-todo-color']
|
|
@@ -279,4 +360,46 @@ class ChartBase
|
|
|
279
360
|
</div>
|
|
280
361
|
TEXT
|
|
281
362
|
end
|
|
363
|
+
|
|
364
|
+
# Set a cycletime for just this one chart, overriding the one for the report.
|
|
365
|
+
def cycletime &block
|
|
366
|
+
call_before_run do
|
|
367
|
+
@cycletime = CycleTimeConfig.new(
|
|
368
|
+
possible_statuses: possible_statuses, label: nil, block: block, file_system: file_system,
|
|
369
|
+
settings: settings
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Returns the cycletime in use right now, which may be specific to the chart or across the report.
|
|
375
|
+
def cycletime_for_issue issue
|
|
376
|
+
@cycletime || issue.board.cycletime
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def seam_start type = 'chart'
|
|
380
|
+
"\n<!-- seam-start | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def seam_end type = 'chart'
|
|
384
|
+
"\n<!-- seam-end | chart#{@@chart_counter} | #{self.class} | #{header_text} | #{type} -->"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def render_axis_title axis_direction
|
|
388
|
+
text = case axis_direction
|
|
389
|
+
when :x
|
|
390
|
+
x_axis_title
|
|
391
|
+
when :y
|
|
392
|
+
y_axis_title
|
|
393
|
+
else
|
|
394
|
+
raise "Unexpected axis_direction: #{axis_direction}"
|
|
395
|
+
end
|
|
396
|
+
return '' unless text
|
|
397
|
+
|
|
398
|
+
<<~CONTENT
|
|
399
|
+
title: {
|
|
400
|
+
display: true,
|
|
401
|
+
text: "#{text}"
|
|
402
|
+
},
|
|
403
|
+
CONTENT
|
|
404
|
+
end
|
|
282
405
|
end
|