jirametrics 2.4 → 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 +16 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +63 -19
- data/lib/jirametrics/anonymizer.rb +81 -6
- data/lib/jirametrics/atlassian_document_format.rb +160 -0
- data/lib/jirametrics/bar_chart_range.rb +17 -0
- data/lib/jirametrics/blocked_stalled_change.rb +6 -4
- data/lib/jirametrics/board.rb +74 -22
- data/lib/jirametrics/board_config.rb +11 -3
- data/lib/jirametrics/board_feature.rb +14 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/cfd_data_builder.rb +108 -0
- data/lib/jirametrics/change_item.rb +54 -18
- data/lib/jirametrics/chart_base.rb +203 -30
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/cycle_time_config.rb +137 -0
- data/lib/jirametrics/cycletime_histogram.rb +17 -38
- data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
- data/lib/jirametrics/daily_wip_chart.rb +36 -16
- data/lib/jirametrics/data_quality_report.rb +251 -42
- data/lib/jirametrics/dependency_chart.rb +42 -12
- data/lib/jirametrics/download_config.rb +27 -0
- data/lib/jirametrics/downloader.rb +185 -110
- 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 +75 -14
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +9 -23
- data/lib/jirametrics/examples/standard_project.rb +57 -58
- data/lib/jirametrics/expedited_chart.rb +11 -10
- data/lib/jirametrics/exporter.rb +51 -14
- data/lib/jirametrics/file_config.rb +21 -6
- data/lib/jirametrics/file_system.rb +96 -4
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +12 -4
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +13 -4
- 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 +41 -15
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +7 -24
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +336 -62
- data/lib/jirametrics/html/index.erb +16 -21
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +18 -25
- data/lib/jirametrics/html/throughput_chart.erb +43 -21
- data/lib/jirametrics/html/time_based_histogram.erb +123 -0
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
- 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 +83 -76
- data/lib/jirametrics/issue.rb +499 -91
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +96 -16
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +374 -130
- 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/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -2
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +47 -39
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +83 -38
- 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/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +101 -66
- metadata +72 -16
- data/lib/jirametrics/cycletime_config.rb +0 -69
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AtlassianDocumentFormat
|
|
4
|
+
attr_reader :users
|
|
5
|
+
|
|
6
|
+
def initialize users:, timezone_offset:
|
|
7
|
+
@users = users
|
|
8
|
+
@timezone_offset = timezone_offset
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_html input
|
|
12
|
+
if input.is_a? String
|
|
13
|
+
input
|
|
14
|
+
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
|
+
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
16
|
+
.gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
17
|
+
.gsub("\n", '<br />')
|
|
18
|
+
elsif input&.[]('content')
|
|
19
|
+
input['content'].collect { |element| adf_node_to_html element }.join("\n")
|
|
20
|
+
else
|
|
21
|
+
# We have an actual ADF document with no content.
|
|
22
|
+
''
|
|
23
|
+
end
|
|
24
|
+
end
|
|
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
|
+
|
|
36
|
+
# ADF is Atlassian Document Format
|
|
37
|
+
# https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
|
|
38
|
+
def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
|
|
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
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
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
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def adf_marks_to_html list
|
|
118
|
+
return [] if list.nil?
|
|
119
|
+
|
|
120
|
+
mappings = [
|
|
121
|
+
['strong', '<b>', '</b>'],
|
|
122
|
+
['code', '<code>', '</code>'],
|
|
123
|
+
['em', '<em>', '</em>'],
|
|
124
|
+
['strike', '<s>', '</s>'],
|
|
125
|
+
['underline', '<u>', '</u>']
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
list.filter_map do |mark|
|
|
129
|
+
type = mark['type']
|
|
130
|
+
if type == 'textColor'
|
|
131
|
+
color = mark['attrs']['color']
|
|
132
|
+
["<span style='color: #{color}'>", '</span>']
|
|
133
|
+
elsif type == 'link'
|
|
134
|
+
href = mark['attrs']['href']
|
|
135
|
+
title = mark['attrs']['title']
|
|
136
|
+
["<a href='#{href}' title='#{title}'>", '</a>']
|
|
137
|
+
else
|
|
138
|
+
line = mappings.find { |key, _open, _close| key == type }
|
|
139
|
+
[line[1], line[2]] if line
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def expand_account_id account_id
|
|
145
|
+
user = @users.find { |u| u.account_id == account_id }
|
|
146
|
+
text = account_id
|
|
147
|
+
text = "@#{user.display_name}" if user
|
|
148
|
+
"<span class='account_id'>#{text}</span>"
|
|
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
|
+
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?
|
|
@@ -47,7 +49,7 @@ class BlockedStalledChange
|
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def inspect
|
|
50
|
-
text =
|
|
52
|
+
text = "BlockedStalledChange(time: '#{@time}', "
|
|
51
53
|
if active?
|
|
52
54
|
text << 'Active'
|
|
53
55
|
else
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Board
|
|
4
|
-
attr_reader :visible_columns, :raw, :possible_statuses, :sprints
|
|
5
|
-
attr_accessor :cycletime, :project_config
|
|
4
|
+
attr_reader :visible_columns, :raw, :possible_statuses, :sprints
|
|
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
|
-
@board_type = raw['type']
|
|
10
9
|
@possible_statuses = possible_statuses
|
|
11
10
|
@sprints = []
|
|
12
|
-
@
|
|
11
|
+
@features = features
|
|
13
12
|
|
|
14
13
|
columns = raw['columnConfig']['columns']
|
|
14
|
+
ensure_uniqueness_of_column_names! columns
|
|
15
15
|
|
|
16
|
-
# For a Kanban board, the first column
|
|
17
|
-
# visible on the board.
|
|
18
|
-
|
|
19
|
-
if kanban?
|
|
20
|
-
@backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
|
|
21
|
-
# There is a status defined as being 'backlog' that is no longer being returned in statuses.
|
|
22
|
-
# We used to display a warning for this but honestly, there is nothing that anyone can do about it
|
|
23
|
-
# so now we just quietly ignore it.
|
|
24
|
-
end
|
|
25
|
-
columns = columns[1..]
|
|
26
|
-
else
|
|
27
|
-
# We currently don't know how to get the backlog status for a Scrum board
|
|
28
|
-
@backlog_statuses = []
|
|
29
|
-
end
|
|
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'
|
|
30
19
|
|
|
20
|
+
@backlog_statuses = []
|
|
31
21
|
@visible_columns = columns.filter_map do |column|
|
|
32
22
|
# It's possible for a column to be defined without any statuses and in this case, it won't be visible.
|
|
33
23
|
BoardColumn.new column unless status_ids_from_column(column).empty?
|
|
34
24
|
end
|
|
35
25
|
end
|
|
36
26
|
|
|
27
|
+
def backlog_statuses
|
|
28
|
+
if @backlog_statuses.empty? && board_type == 'kanban'
|
|
29
|
+
status_ids = status_ids_from_column raw['columnConfig']['columns'].first
|
|
30
|
+
@backlog_statuses = status_ids.filter_map do |id|
|
|
31
|
+
@possible_statuses.find_by_id id
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
@backlog_statuses
|
|
35
|
+
end
|
|
36
|
+
|
|
37
37
|
def server_url_prefix
|
|
38
38
|
raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
|
|
39
39
|
|
|
@@ -66,12 +66,28 @@ class Board
|
|
|
66
66
|
status_ids
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
def board_type = raw['type']
|
|
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
|
+
|
|
69
78
|
def kanban?
|
|
70
|
-
|
|
79
|
+
return true if board_type == 'kanban'
|
|
80
|
+
return false unless board_type == 'simple'
|
|
81
|
+
|
|
82
|
+
!scrum?
|
|
71
83
|
end
|
|
72
84
|
|
|
73
|
-
def
|
|
74
|
-
|
|
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? }
|
|
75
91
|
end
|
|
76
92
|
|
|
77
93
|
def id
|
|
@@ -88,4 +104,40 @@ class Board
|
|
|
88
104
|
def name
|
|
89
105
|
@raw['name']
|
|
90
106
|
end
|
|
107
|
+
|
|
108
|
+
def accumulated_status_ids_per_column
|
|
109
|
+
accumulated_status_ids = []
|
|
110
|
+
visible_columns.reverse.filter_map do |column|
|
|
111
|
+
next if column == @fake_column
|
|
112
|
+
|
|
113
|
+
accumulated_status_ids += column.status_ids
|
|
114
|
+
[column.name, accumulated_status_ids.dup]
|
|
115
|
+
end.reverse
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ensure_uniqueness_of_column_names! json
|
|
119
|
+
all_names = []
|
|
120
|
+
json.each do |column_json|
|
|
121
|
+
name = column_json['name']
|
|
122
|
+
if all_names.include? name
|
|
123
|
+
(2..).each do |i|
|
|
124
|
+
new_name = "#{name}-#{i}"
|
|
125
|
+
next if all_names.include?(new_name)
|
|
126
|
+
|
|
127
|
+
name = new_name
|
|
128
|
+
column_json['name'] = new_name
|
|
129
|
+
break
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
all_names << name
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def estimation_configuration
|
|
137
|
+
EstimationConfiguration.new raw: raw['estimation']
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def inspect
|
|
141
|
+
"Board(id: #{id}, name: #{name.inspect}, board_type: #{board_type.inspect})"
|
|
142
|
+
end
|
|
91
143
|
end
|
|
@@ -11,9 +11,10 @@ class BoardConfig
|
|
|
11
11
|
|
|
12
12
|
def run
|
|
13
13
|
@board = @project_config.all_boards[id]
|
|
14
|
-
|
|
14
|
+
raise "Can't find board #{id.inspect} in #{@project_config.all_boards.keys.inspect}" unless @board
|
|
15
15
|
|
|
16
16
|
instance_eval(&@block)
|
|
17
|
+
raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def cycletime label = nil, &block
|
|
@@ -22,10 +23,17 @@ class BoardConfig
|
|
|
22
23
|
'If so, remove it from there.'
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
@board.cycletime = CycleTimeConfig.new(
|
|
26
|
+
@board.cycletime = CycleTimeConfig.new(
|
|
27
|
+
possible_statuses: project_config.possible_statuses,
|
|
28
|
+
label: label, block: block, file_system: project_config.file_system,
|
|
29
|
+
settings: project_config.settings
|
|
30
|
+
)
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def expedited_priority_names *priority_names
|
|
29
|
-
|
|
34
|
+
project_config.exporter.file_system.deprecated(
|
|
35
|
+
date: '2024-09-15', message: 'Expedited priority names are now specified in settings'
|
|
36
|
+
)
|
|
37
|
+
@project_config.settings['expedited_priority_names'] = priority_names
|
|
30
38
|
end
|
|
31
39
|
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
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class BoardMovementCalculator
|
|
4
|
+
attr_reader :board, :issues, :today
|
|
5
|
+
|
|
6
|
+
def initialize board:, issues:, today:
|
|
7
|
+
@board = board
|
|
8
|
+
@issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
|
|
9
|
+
@today = today
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def moves_backwards? issue
|
|
13
|
+
started, stopped = issue.started_stopped_times
|
|
14
|
+
return false unless started
|
|
15
|
+
|
|
16
|
+
previous_column = nil
|
|
17
|
+
issue.status_changes.each do |change|
|
|
18
|
+
column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
|
|
19
|
+
next if change.time < started
|
|
20
|
+
next if column.nil? # It disappeared from the board for a bit
|
|
21
|
+
return true if previous_column && column && column < previous_column
|
|
22
|
+
|
|
23
|
+
previous_column = column
|
|
24
|
+
end
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stacked_age_data_for percentages:
|
|
29
|
+
data_list = percentages.sort.collect do |percentage|
|
|
30
|
+
[percentage, age_data_for(percentage: percentage)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stack_data data_list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stack_data data_list
|
|
37
|
+
remainder = nil
|
|
38
|
+
data_list.collect do |percentage, data|
|
|
39
|
+
unless remainder.nil?
|
|
40
|
+
data = (0...data.length).collect do |i|
|
|
41
|
+
data[i] - remainder[i]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
end
|
|
45
|
+
remainder = data
|
|
46
|
+
|
|
47
|
+
[percentage, data]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def age_data_for percentage:
|
|
52
|
+
data = []
|
|
53
|
+
board.visible_columns.each_with_index do |_column, column_index|
|
|
54
|
+
ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
|
|
55
|
+
|
|
56
|
+
if ages.empty?
|
|
57
|
+
data << 0
|
|
58
|
+
else
|
|
59
|
+
index = ((ages.size - 1) * percentage / 100).to_i
|
|
60
|
+
data << ages[index]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
data
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ages_of_issues_when_leaving_column column_index:, today:
|
|
67
|
+
this_column = board.visible_columns[column_index]
|
|
68
|
+
next_column = board.visible_columns[column_index + 1]
|
|
69
|
+
|
|
70
|
+
@issues.filter_map do |issue|
|
|
71
|
+
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
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.started_stopped_times
|
|
74
|
+
|
|
75
|
+
# Skip if we can't tell when it started.
|
|
76
|
+
next if issue_start.nil?
|
|
77
|
+
|
|
78
|
+
# Skip if it never entered this column
|
|
79
|
+
next if this_column_start.nil?
|
|
80
|
+
|
|
81
|
+
# Skip if it left this column before the item is considered started.
|
|
82
|
+
next 0 if next_column_start && next_column_start <= issue_start
|
|
83
|
+
|
|
84
|
+
# Skip if it was already done by the time it got to this column or it became done when it got to this column
|
|
85
|
+
next if issue_done && issue_done <= this_column_start
|
|
86
|
+
|
|
87
|
+
end_date = case # rubocop:disable Style/EmptyCaseCondition
|
|
88
|
+
when next_column_start.nil?
|
|
89
|
+
# If this is the last column then base age against today
|
|
90
|
+
today
|
|
91
|
+
when issue_done && issue_done < next_column_start
|
|
92
|
+
# it completed while in this column
|
|
93
|
+
issue_done.to_date
|
|
94
|
+
else
|
|
95
|
+
# It passed through this whole column
|
|
96
|
+
next_column_start.to_date
|
|
97
|
+
end
|
|
98
|
+
(end_date - issue_start.to_date).to_i + 1
|
|
99
|
+
end.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
|
103
|
+
# aging and forecasting purposes
|
|
104
|
+
def find_current_column_and_entry_time_in_column issue
|
|
105
|
+
column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
106
|
+
return [] if column.nil? # This issue isn't visible on the board
|
|
107
|
+
|
|
108
|
+
status_ids = column.status_ids
|
|
109
|
+
|
|
110
|
+
entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
|
|
111
|
+
|
|
112
|
+
[column.name, entry_at]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def label_days days
|
|
116
|
+
"#{days} day#{'s' unless days == 1}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def forecasted_days_remaining_and_message issue:, today:
|
|
120
|
+
return [nil, 'Already done'] if issue.done?
|
|
121
|
+
|
|
122
|
+
likely_age_data = age_data_for percentage: 85
|
|
123
|
+
|
|
124
|
+
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
|
125
|
+
return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
|
|
126
|
+
|
|
127
|
+
# This condition has been reported in production so we have a check for it. Having said that, we have no
|
|
128
|
+
# idea what conditions might make this possible and so there is no test for it.
|
|
129
|
+
if entry_time.nil?
|
|
130
|
+
message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
|
|
131
|
+
'predict when it will be done'
|
|
132
|
+
return [nil, message]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
age_in_column = (today - entry_time.to_date).to_i + 1
|
|
136
|
+
|
|
137
|
+
message = nil
|
|
138
|
+
column_index = board.visible_columns.index { |c| c.name == column_name }
|
|
139
|
+
|
|
140
|
+
last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
|
|
141
|
+
return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
|
|
142
|
+
|
|
143
|
+
remaining_in_current_column = likely_age_data[column_index] - age_in_column
|
|
144
|
+
if remaining_in_current_column.negative?
|
|
145
|
+
message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
|
|
146
|
+
"in the #{column_name.inspect} column. Most items on this board have left this column in " \
|
|
147
|
+
"#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
|
|
148
|
+
remaining_in_current_column = 0
|
|
149
|
+
return [nil, message]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
|
|
153
|
+
[forecasted_days, message]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -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
|