jirametrics 2.31 → 3.0
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 +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +19 -10
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +2 -2
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +73 -32
- data/lib/jirametrics.rb +6 -2
- metadata +13 -20
|
@@ -12,7 +12,9 @@ class AtlassianDocumentFormat
|
|
|
12
12
|
if input.is_a? String
|
|
13
13
|
input
|
|
14
14
|
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
|
+
# rubocop:disable Style/PerlBackrefs -- in a gsub block $1 reads cleaner than Regexp.last_match[:...]
|
|
15
16
|
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
17
|
+
# rubocop:enable Style/PerlBackrefs
|
|
16
18
|
.gsub(/\[([^|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
17
19
|
.gsub("\n", '<br />')
|
|
18
20
|
elsif input&.[]('content')
|
|
@@ -35,7 +37,11 @@ class AtlassianDocumentFormat
|
|
|
35
37
|
|
|
36
38
|
# ADF is Atlassian Document Format
|
|
37
39
|
# https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
|
|
38
|
-
|
|
40
|
+
#
|
|
41
|
+
# Flat node-type dispatch: ~25 trivial branches mapping an ADF node type to its [prefix, suffix]
|
|
42
|
+
# pair (adf_node_render does the recursion). The complexity here is breadth, not depth — a lookup
|
|
43
|
+
# table would only trade a scannable case for a hash plus indirection, so we keep the case.
|
|
44
|
+
def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
|
|
39
45
|
adf_node_render(node) do |n|
|
|
40
46
|
node_attrs = n['attrs']
|
|
41
47
|
case n['type']
|
|
@@ -74,20 +80,21 @@ class AtlassianDocumentFormat
|
|
|
74
80
|
when 'taskItem'
|
|
75
81
|
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
76
82
|
["<li>#{state} ", '</li>']
|
|
77
|
-
when 'taskList'
|
|
83
|
+
when 'taskList' then ["<ul class='taskList'>", '</ul>']
|
|
78
84
|
else
|
|
79
85
|
["<p>Unparseable section: #{n['type']}</p>", nil]
|
|
80
86
|
end
|
|
81
87
|
end
|
|
82
88
|
end
|
|
83
89
|
|
|
90
|
+
# Flat node-type dispatch, same reasoning as adf_node_to_html: breadth, not depth. Kept as a case.
|
|
84
91
|
def adf_node_to_text node # rubocop:disable Metrics/CyclomaticComplexity
|
|
85
92
|
adf_node_render(node) do |n|
|
|
86
93
|
node_attrs = n['attrs']
|
|
87
94
|
case n['type']
|
|
88
95
|
when 'blockquote', 'bulletList', 'codeBlock',
|
|
89
96
|
'mediaSingle', 'mediaGroup',
|
|
90
|
-
'orderedList', 'table', 'taskList'
|
|
97
|
+
'orderedList', 'table', 'taskList' then ['', nil]
|
|
91
98
|
when 'date'
|
|
92
99
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
93
100
|
when 'decisionItem' then ['- ', "\n"]
|
|
@@ -151,7 +158,7 @@ class AtlassianDocumentFormat
|
|
|
151
158
|
private
|
|
152
159
|
|
|
153
160
|
def adf_node_render node, &render_node
|
|
154
|
-
prefix, suffix =
|
|
161
|
+
prefix, suffix = yield(node)
|
|
155
162
|
result = +(prefix || '')
|
|
156
163
|
node['content']&.each { |child| result << adf_node_render(child, &render_node) }
|
|
157
164
|
result << suffix if suffix
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rolls a stream of BlockedStalledChange entries up into a single winning entry per date across
|
|
4
|
+
# the requested date range. It's handed the already-computed stream so it never reaches back
|
|
5
|
+
# through the issue's collaborators.
|
|
6
|
+
class BlockedStalledByDateBuilder
|
|
7
|
+
def initialize blocked_stalled_changes:, date_range:
|
|
8
|
+
@blocked_stalled_changes = blocked_stalled_changes
|
|
9
|
+
@date_range = date_range
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build
|
|
13
|
+
results = winners_and_last_by_date
|
|
14
|
+
fill_gaps results
|
|
15
|
+
results = results.transform_values(&:first)
|
|
16
|
+
extrapolate_across_range results
|
|
17
|
+
|
|
18
|
+
# We've been accumulating data for every date to keep the code simple. Now drop anything
|
|
19
|
+
# that isn't in the requested date_range.
|
|
20
|
+
results.select! { |date, _value| @date_range.include? date }
|
|
21
|
+
results
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# For each date we track both the winning change (the one that best represents the day) and the
|
|
25
|
+
# last change seen (used to carry state forward into days that had no changes of their own).
|
|
26
|
+
def winners_and_last_by_date
|
|
27
|
+
results = {}
|
|
28
|
+
@blocked_stalled_changes.each do |change|
|
|
29
|
+
date = change.time.to_date
|
|
30
|
+
winning_change, = results[date]
|
|
31
|
+
winning_change = change if winning_change.nil? || wins_the_day?(change, winning_change)
|
|
32
|
+
results[date] = [winning_change, change]
|
|
33
|
+
end
|
|
34
|
+
results
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def wins_the_day? change, winning_change
|
|
38
|
+
change.blocked? ||
|
|
39
|
+
(change.active? && (winning_change.active? || winning_change.stalled?)) ||
|
|
40
|
+
(change.stalled? && winning_change.stalled?)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def fill_gaps results
|
|
44
|
+
last_populated_date = nil
|
|
45
|
+
(results.keys.min..results.keys.max).each do |date|
|
|
46
|
+
if results.key? date
|
|
47
|
+
last_populated_date = date
|
|
48
|
+
else
|
|
49
|
+
_winner, last = results[last_populated_date]
|
|
50
|
+
results[date] = [last, last]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def extrapolate_across_range results
|
|
56
|
+
# The requested date range may span outside the actual changes we find in the changelog.
|
|
57
|
+
date_of_first_change = @blocked_stalled_changes[0].time.to_date
|
|
58
|
+
date_of_last_change = @blocked_stalled_changes[-1].time.to_date
|
|
59
|
+
@date_range.each do |date|
|
|
60
|
+
results[date] = @blocked_stalled_changes[0] if date < date_of_first_change
|
|
61
|
+
results[date] = @blocked_stalled_changes[-1] if date > date_of_last_change
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -4,6 +4,7 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class BlockedStalledChange
|
|
6
6
|
include ValueEquality
|
|
7
|
+
|
|
7
8
|
attr_reader :time, :blocking_issue_keys, :flag, :flag_reason, :status, :stalled_days, :status_is_blocking
|
|
8
9
|
|
|
9
10
|
def initialize time:, flagged: nil, flag_reason: nil, status: nil, status_is_blocking: true,
|
|
@@ -24,7 +25,10 @@ class BlockedStalledChange
|
|
|
24
25
|
def blocked_by_status? = !!(@status && @status_is_blocking)
|
|
25
26
|
def stalled_by_status? = !!(@status && !@status_is_blocking)
|
|
26
27
|
|
|
27
|
-
def reasons
|
|
28
|
+
def reasons # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
29
|
+
# A flat list of why an item is blocked or stalled: each branch is a single guarded append.
|
|
30
|
+
# Pulling these apart into helpers would scatter the story across single-use methods without
|
|
31
|
+
# reading any clearer, so we keep it whole.
|
|
28
32
|
result = []
|
|
29
33
|
if blocked?
|
|
30
34
|
result << (@flag_reason ? "Blocked by flag: #{@flag_reason}" : 'Blocked by flag') if @flag
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Builds the time-ordered stream of BlockedStalledChange entries for a single issue.
|
|
4
|
+
# Everything it needs is passed in explicitly (changes, settings, and a few resolved values)
|
|
5
|
+
# so it never reaches back through the issue's collaborators.
|
|
6
|
+
class BlockedStalledChangeStreamBuilder
|
|
7
|
+
# Mutable state threaded through the change loop as we walk the issue's history. Each branch
|
|
8
|
+
# only overwrites the fields it owns, so a flag/status/link value persists until the next change
|
|
9
|
+
# of that kind replaces it.
|
|
10
|
+
BlockingState = Struct.new(
|
|
11
|
+
:flag, :flag_reason, :status, :is_blocked, :blocking_issue_keys, keyword_init: true
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def initialize changes:, settings:, created:, key:, subtask_activity_times:, atlassian_document_format:
|
|
15
|
+
@changes = changes
|
|
16
|
+
@settings = settings
|
|
17
|
+
@created = created
|
|
18
|
+
@key = key
|
|
19
|
+
@subtask_activity_times = subtask_activity_times
|
|
20
|
+
@atlassian_document_format = atlassian_document_format
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build end_time:
|
|
24
|
+
result = []
|
|
25
|
+
state = BlockingState.new(flag: nil, flag_reason: nil, status: nil, is_blocked: false, blocking_issue_keys: [])
|
|
26
|
+
previous_was_active = false # Must start as false so that the creation will insert an :active
|
|
27
|
+
previous_change_time = @created
|
|
28
|
+
|
|
29
|
+
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
30
|
+
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
31
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
32
|
+
|
|
33
|
+
(@changes + [mock_change]).each do |change|
|
|
34
|
+
previous_was_active = false if check_for_stalled(
|
|
35
|
+
change_time: change.time,
|
|
36
|
+
previous_change_time: previous_change_time,
|
|
37
|
+
stalled_threshold: @settings['stalled_threshold_days'],
|
|
38
|
+
blocking_stalled_changes: result
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
update_blocking_state state, change
|
|
42
|
+
|
|
43
|
+
new_change = new_blocked_stalled_change state, change.time
|
|
44
|
+
|
|
45
|
+
# We don't want to dump two actives in a row as that would just be noise. Unless this is
|
|
46
|
+
# the mock change, which we always want to dump
|
|
47
|
+
result << new_change if record_change? new_change, previous_was_active, change, mock_change
|
|
48
|
+
|
|
49
|
+
previous_was_active = new_change.active?
|
|
50
|
+
previous_change_time = change.time
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
finalize_stalled_tail result
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def update_blocking_state state, change
|
|
58
|
+
if change.flagged? && flagged_means_blocked?
|
|
59
|
+
state.flag, state.flag_reason = flag_logic change
|
|
60
|
+
elsif change.status?
|
|
61
|
+
state.status, state.is_blocked = status_change change
|
|
62
|
+
elsif change.link?
|
|
63
|
+
apply_link_change change, state.blocking_issue_keys
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def flagged_means_blocked?
|
|
68
|
+
!!@settings['flagged_means_blocked']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def new_blocked_stalled_change state, time
|
|
72
|
+
BlockedStalledChange.new(
|
|
73
|
+
flagged: state.flag,
|
|
74
|
+
flag_reason: state.flag_reason,
|
|
75
|
+
status: state.status,
|
|
76
|
+
status_is_blocking: state.status.nil? || state.is_blocked,
|
|
77
|
+
blocking_issue_keys: (state.blocking_issue_keys.empty? ? nil : state.blocking_issue_keys.dup),
|
|
78
|
+
time: time
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_change? candidate, previous_was_active, change, mock_change
|
|
83
|
+
!candidate.active? || !previous_was_active || change == mock_change
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def status_change change
|
|
87
|
+
if @settings['blocked_statuses'].find_by_id(change.value_id)
|
|
88
|
+
[change.value, true]
|
|
89
|
+
elsif @settings['stalled_statuses'].find_by_id(change.value_id)
|
|
90
|
+
[change.value, false]
|
|
91
|
+
else
|
|
92
|
+
[nil, false]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def apply_link_change change, blocking_issue_keys
|
|
97
|
+
link_value = change.value || change.old_value
|
|
98
|
+
# Example: "This issue is satisfied by ANON-30465"
|
|
99
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ link_value
|
|
100
|
+
puts "Issue(#{@key}) Can't parse link text: #{link_value}"
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return unless @settings['blocked_link_text'].include? link_text
|
|
105
|
+
|
|
106
|
+
if change.value
|
|
107
|
+
blocking_issue_keys << issue_key
|
|
108
|
+
else
|
|
109
|
+
blocking_issue_keys.delete issue_key
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flag_logic change
|
|
114
|
+
flag = change.value
|
|
115
|
+
flag = nil if change.value == ''
|
|
116
|
+
[flag, flag ? flag_reason_from_comment(change) : nil]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def flag_reason_from_comment change
|
|
120
|
+
comment_change = comment_near change.time
|
|
121
|
+
flag_reason = comment_change && @atlassian_document_format.to_text(comment_change.value)
|
|
122
|
+
# Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
|
|
123
|
+
flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
|
|
124
|
+
flag_reason = nil if flag_reason && flag_reason.empty?
|
|
125
|
+
flag_reason
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def comment_near flag_time
|
|
129
|
+
# When the user is adding a comment to explain why a flag was set, the flag is set immediately
|
|
130
|
+
# and the comment is inserted after the user hits enter, which means that there is some time
|
|
131
|
+
# gap. If a comment happened shortly after the flag was set, we assume they're linked. This
|
|
132
|
+
# won't always be true and so there will be false positives, but it's a reasonable assumption.
|
|
133
|
+
max_seconds_between_flag_and_comment = 30
|
|
134
|
+
@changes.find do |c|
|
|
135
|
+
c.comment? && c.time >= flag_time && (c.time - flag_time) <= max_seconds_between_flag_and_comment
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def finalize_stalled_tail result
|
|
140
|
+
return unless result.size >= 2
|
|
141
|
+
|
|
142
|
+
# The existence of the mock entry will mess with the stalled count as it will wake everything
|
|
143
|
+
# back up. This hack will clean up appropriately.
|
|
144
|
+
hack = result.pop
|
|
145
|
+
result << BlockedStalledChange.new(
|
|
146
|
+
flagged: hack.flag,
|
|
147
|
+
flag_reason: hack.flag_reason,
|
|
148
|
+
status: hack.status,
|
|
149
|
+
status_is_blocking: hack.status_is_blocking,
|
|
150
|
+
blocking_issue_keys: hack.blocking_issue_keys,
|
|
151
|
+
time: hack.time,
|
|
152
|
+
stalled_days: result[-1].stalled_days
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
157
|
+
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
158
|
+
|
|
159
|
+
# The most common case will be nothing to split so quick escape.
|
|
160
|
+
return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
|
|
161
|
+
|
|
162
|
+
# If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
|
|
163
|
+
return false if blocking_stalled_changes[-1]&.blocked?
|
|
164
|
+
|
|
165
|
+
ranges = split_range_by_subtask_activity previous_change_time..change_time
|
|
166
|
+
insert_stalled_changes ranges, stalled_threshold_seconds, blocking_stalled_changes
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def split_range_by_subtask_activity initial_range
|
|
170
|
+
list = [initial_range]
|
|
171
|
+
@subtask_activity_times.each do |time|
|
|
172
|
+
matching_range = list.find { |range| time.between?(range.begin, range.end) }
|
|
173
|
+
next unless matching_range
|
|
174
|
+
|
|
175
|
+
list.delete matching_range
|
|
176
|
+
list << (matching_range.begin..time)
|
|
177
|
+
list << (time..matching_range.end)
|
|
178
|
+
end
|
|
179
|
+
list
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def insert_stalled_changes ranges, stalled_threshold_seconds, blocking_stalled_changes
|
|
183
|
+
inserted_stalled = false
|
|
184
|
+
ranges.sort_by(&:begin).each do |range|
|
|
185
|
+
seconds = (range.end - range.begin).to_i
|
|
186
|
+
next if seconds < stalled_threshold_seconds
|
|
187
|
+
|
|
188
|
+
an_hour_later = range.begin + (60 * 60)
|
|
189
|
+
blocking_stalled_changes << BlockedStalledChange.new(stalled_days: seconds / (24 * 60 * 60), time: an_hour_later)
|
|
190
|
+
inserted_stalled = true
|
|
191
|
+
end
|
|
192
|
+
inserted_stalled
|
|
193
|
+
end
|
|
194
|
+
end
|
data/lib/jirametrics/board.rb
CHANGED
|
@@ -35,9 +35,9 @@ class Board
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def server_url_prefix
|
|
38
|
-
raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self']
|
|
38
|
+
raise "Cannot parse self: #{@raw['self'].inspect}" unless %r{^(?<prefix>https?://.+)/rest/} =~ @raw['self']
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
prefix
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def url
|
|
@@ -59,20 +59,34 @@ class Board
|
|
|
59
59
|
status_ids += column.status_ids if found_it
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
unless found_it
|
|
63
|
-
|
|
64
|
-
raise "No visible column with name: #{column_name.inspect} Possible options are: #{column_names}"
|
|
65
|
-
end
|
|
62
|
+
raise column_not_found_message(column_name) unless found_it
|
|
63
|
+
|
|
66
64
|
status_ids
|
|
67
65
|
end
|
|
68
66
|
|
|
67
|
+
# Column-name matching is case-sensitive, so a config asking for "Done" against a board whose column
|
|
68
|
+
# is "DONE" fails -- and that's the mistake people actually make. When only the case differs, name the
|
|
69
|
+
# likely intended column outright instead of making the reader spot it in the list themselves.
|
|
70
|
+
def column_not_found_message column_name
|
|
71
|
+
names = @visible_columns.collect(&:name)
|
|
72
|
+
suggestion = names.find { |name| name.casecmp?(column_name) }
|
|
73
|
+
unless suggestion
|
|
74
|
+
return "No visible column named #{column_name.inspect}. " \
|
|
75
|
+
"The visible columns are: #{names.collect(&:inspect).join(', ')}."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
others = (names - [suggestion]).collect(&:inspect).join(', ')
|
|
79
|
+
"You specified #{column_name.inspect} but probably meant #{suggestion.inspect}. Column names are " \
|
|
80
|
+
"case-sensitive. If that's not it, the other visible columns are: #{others}."
|
|
81
|
+
end
|
|
82
|
+
|
|
69
83
|
def board_type = raw['type']
|
|
70
84
|
|
|
71
85
|
def scrum?
|
|
72
86
|
return true if board_type == 'scrum'
|
|
73
87
|
return false unless board_type == 'simple'
|
|
74
88
|
|
|
75
|
-
|
|
89
|
+
sprints_feature?
|
|
76
90
|
end
|
|
77
91
|
|
|
78
92
|
def kanban?
|
|
@@ -83,10 +97,10 @@ class Board
|
|
|
83
97
|
end
|
|
84
98
|
|
|
85
99
|
def team_managed_kanban?
|
|
86
|
-
board_type == 'simple' && !
|
|
100
|
+
board_type == 'simple' && !sprints_feature?
|
|
87
101
|
end
|
|
88
102
|
|
|
89
|
-
def
|
|
103
|
+
def sprints_feature?
|
|
90
104
|
@features.any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
|
|
91
105
|
end
|
|
92
106
|
|
|
@@ -29,11 +29,4 @@ class BoardConfig
|
|
|
29
29
|
settings: project_config.settings
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
|
-
|
|
33
|
-
def expedited_priority_names *priority_names
|
|
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
|
|
38
|
-
end
|
|
39
32
|
end
|
|
@@ -10,19 +10,16 @@ class BoardMovementCalculator
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def moves_backwards? issue
|
|
13
|
-
started,
|
|
13
|
+
started, = issue.started_stopped_times
|
|
14
14
|
return false unless started
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return true if previous_column && column && column < previous_column
|
|
16
|
+
# filter_map drops any status that isn't on a visible column (it disappeared from the board for a bit),
|
|
17
|
+
# so a dip either side of that gap is still seen as consecutive columns.
|
|
18
|
+
columns = issue.status_changes
|
|
19
|
+
.select { |change| change.time >= started }
|
|
20
|
+
.filter_map { |change| board.visible_columns.index { |c| c.status_ids.include?(change.value_id) } }
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
end
|
|
25
|
-
false
|
|
22
|
+
columns.each_cons(2).any? { |previous, current| current < previous }
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
def stacked_age_data_for percentages:
|
|
@@ -63,7 +60,13 @@ class BoardMovementCalculator
|
|
|
63
60
|
data
|
|
64
61
|
end
|
|
65
62
|
|
|
63
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
66
64
|
def ages_of_issues_when_leaving_column column_index:, today:
|
|
65
|
+
# Why are complexity warnings disabled? This is an inherently sequential run of guard clauses
|
|
66
|
+
# feeding a single age calculation. Pulling pieces out (the skip rules, the end-date case) would
|
|
67
|
+
# scatter the linear "compute the column times -> apply the skip rules -> pick the end date -> age"
|
|
68
|
+
# story across single-use methods and read worse, so we keep it whole.
|
|
69
|
+
|
|
67
70
|
this_column = board.visible_columns[column_index]
|
|
68
71
|
next_column = board.visible_columns[column_index + 1]
|
|
69
72
|
|
|
@@ -84,20 +87,20 @@ class BoardMovementCalculator
|
|
|
84
87
|
# 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
88
|
next if issue_done && issue_done <= this_column_start
|
|
86
89
|
|
|
87
|
-
end_date =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
90
|
+
end_date = if next_column_start.nil?
|
|
91
|
+
# If this is the last column then base age against today
|
|
92
|
+
today
|
|
93
|
+
elsif issue_done && issue_done < next_column_start
|
|
94
|
+
# it completed while in this column
|
|
95
|
+
issue_done.to_date
|
|
96
|
+
else
|
|
97
|
+
# It passed through this whole column
|
|
98
|
+
next_column_start.to_date
|
|
99
|
+
end
|
|
98
100
|
(end_date - issue_start.to_date).to_i + 1
|
|
99
101
|
end.sort
|
|
100
102
|
end
|
|
103
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
101
104
|
|
|
102
105
|
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
|
103
106
|
# aging and forecasting purposes
|
|
@@ -29,65 +29,67 @@ class CfdDataBuilder
|
|
|
29
29
|
map
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
#
|
|
32
|
+
# Tracks an issue's furthest progress (the high water mark) and, whenever it slips behind that mark,
|
|
33
|
+
# the correction window that stays open until it recovers. Mutated in place as we walk the changes.
|
|
34
|
+
CorrectionState = Struct.new(
|
|
35
|
+
:high_water_mark, :correction_open_since, :correction_windows, :high_water_mark_timeline,
|
|
36
|
+
keyword_init: true
|
|
37
|
+
)
|
|
38
|
+
private_constant :CorrectionState
|
|
39
|
+
|
|
40
|
+
# Returns { high_water_mark_timeline: [[date, high_water_mark], ...], correction_windows: [...] }
|
|
33
41
|
def process_issue issue, column_map
|
|
34
42
|
start_time = issue.started_stopped_times.first
|
|
35
|
-
return {
|
|
36
|
-
|
|
37
|
-
high_water_mark = nil
|
|
38
|
-
correction_open_since = nil
|
|
39
|
-
correction_windows = []
|
|
40
|
-
hwm_timeline = [] # sorted chronologically by date
|
|
43
|
+
return { high_water_mark_timeline: [], correction_windows: [] } if start_time.nil?
|
|
41
44
|
|
|
45
|
+
state = CorrectionState.new(
|
|
46
|
+
high_water_mark: nil, correction_open_since: nil, correction_windows: [], high_water_mark_timeline: []
|
|
47
|
+
)
|
|
42
48
|
issue.status_changes.each do |change|
|
|
43
49
|
next if change.time < start_time
|
|
44
50
|
|
|
45
51
|
col_index = column_map[change.value_id]
|
|
46
52
|
next if col_index.nil?
|
|
47
53
|
|
|
48
|
-
|
|
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
|
|
54
|
+
apply_change(state, col_index, change.time.to_date)
|
|
72
55
|
end
|
|
56
|
+
# A window still open at the end never recovered, so it runs to the end of the range.
|
|
57
|
+
close_correction(state, @date_range.end) if state.correction_open_since
|
|
58
|
+
|
|
59
|
+
{ high_water_mark_timeline: state.high_water_mark_timeline, correction_windows: state.correction_windows }
|
|
60
|
+
end
|
|
73
61
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
62
|
+
def apply_change state, col_index, date
|
|
63
|
+
if state.high_water_mark.nil? || col_index > state.high_water_mark
|
|
64
|
+
advance_high_water_mark(state, col_index, date)
|
|
65
|
+
elsif col_index == state.high_water_mark && state.correction_open_since
|
|
66
|
+
close_correction(state, date) # recovered back to the high water mark
|
|
67
|
+
elsif col_index < state.high_water_mark
|
|
68
|
+
state.correction_open_since ||= date # slipped behind; open a window if one isn't already open
|
|
80
69
|
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def advance_high_water_mark state, col_index, date
|
|
73
|
+
close_correction(state, date) if state.correction_open_since
|
|
74
|
+
state.high_water_mark = col_index
|
|
75
|
+
state.high_water_mark_timeline << [date, col_index]
|
|
76
|
+
end
|
|
81
77
|
|
|
82
|
-
|
|
78
|
+
def close_correction state, end_date
|
|
79
|
+
state.correction_windows << {
|
|
80
|
+
start_date: state.correction_open_since,
|
|
81
|
+
end_date: end_date,
|
|
82
|
+
column_index: state.high_water_mark
|
|
83
|
+
}
|
|
84
|
+
state.correction_open_since = nil
|
|
83
85
|
end
|
|
84
86
|
|
|
85
|
-
def
|
|
87
|
+
def high_water_mark_at high_water_mark_timeline, date
|
|
86
88
|
result = nil
|
|
87
|
-
|
|
89
|
+
high_water_mark_timeline.each do |timeline_date, high_water_mark|
|
|
88
90
|
break if timeline_date > date
|
|
89
91
|
|
|
90
|
-
result =
|
|
92
|
+
result = high_water_mark
|
|
91
93
|
end
|
|
92
94
|
result
|
|
93
95
|
end
|
|
@@ -97,10 +99,10 @@ class CfdDataBuilder
|
|
|
97
99
|
@date_range.each_with_object({}) do |date, result|
|
|
98
100
|
counts = Array.new(column_count, 0)
|
|
99
101
|
issue_states.each do |state|
|
|
100
|
-
|
|
101
|
-
next if
|
|
102
|
+
high_water_mark = high_water_mark_at(state[:high_water_mark_timeline], date)
|
|
103
|
+
next if high_water_mark.nil?
|
|
102
104
|
|
|
103
|
-
(0..
|
|
105
|
+
(0..high_water_mark).each { |i| counts[i] += 1 }
|
|
104
106
|
end
|
|
105
107
|
result[date] = counts
|
|
106
108
|
end
|
|
@@ -14,17 +14,29 @@ class ChangeItem
|
|
|
14
14
|
@field = @raw['field']
|
|
15
15
|
@value = @raw['toString']
|
|
16
16
|
@old_value = @raw['fromString']
|
|
17
|
-
|
|
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
|
|
17
|
+
@value_id, @old_value_id = parse_value_ids
|
|
24
18
|
@field_id = @raw['fieldId']
|
|
25
19
|
@artificial = artificial
|
|
26
20
|
end
|
|
27
21
|
|
|
22
|
+
# Sprints carry a comma-separated list of ids in 'to'/'from', so those become arrays. Every other
|
|
23
|
+
# field is treated as a single id: to_i grabs the leading number, or 0 for text/bracketed values like
|
|
24
|
+
# Watchers or Markets (whose value_id is never used as a real id anyway).
|
|
25
|
+
def parse_value_ids
|
|
26
|
+
to = @raw['to']
|
|
27
|
+
from = @raw['from']
|
|
28
|
+
return [to&.to_i, from&.to_i] unless sprint?
|
|
29
|
+
|
|
30
|
+
[parse_multiple_integer_values(to), parse_multiple_integer_values(from)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Parses a comma-separated string of integers into a list. Sprint is the only field we currently parse
|
|
34
|
+
# this way, but the operation is generic - other fields may turn out to carry multiple ids too. to_s
|
|
35
|
+
# turns a nil (no value) into an empty list.
|
|
36
|
+
def parse_multiple_integer_values raw_value
|
|
37
|
+
raw_value.to_s.split(', ').collect(&:to_i)
|
|
38
|
+
end
|
|
39
|
+
|
|
28
40
|
def author
|
|
29
41
|
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
|
30
42
|
end
|