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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/self_or_issue_dispatcher'
|
|
4
|
+
require 'date'
|
|
5
|
+
|
|
6
|
+
class CycleTimeConfig
|
|
7
|
+
include SelfOrIssueDispatcher
|
|
8
|
+
|
|
9
|
+
attr_reader :label, :settings, :file_system
|
|
10
|
+
|
|
11
|
+
def initialize possible_statuses:, label:, block:, settings:, file_system: nil, today: Date.today
|
|
12
|
+
@possible_statuses = possible_statuses
|
|
13
|
+
@label = label
|
|
14
|
+
@today = today
|
|
15
|
+
@settings = settings
|
|
16
|
+
|
|
17
|
+
# If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
|
|
18
|
+
# may make it easier to find problems in the test code ;-)
|
|
19
|
+
@file_system = file_system
|
|
20
|
+
instance_eval(&block) unless block.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start_at block = nil
|
|
24
|
+
@start_at = block unless block.nil?
|
|
25
|
+
@start_at
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stop_at block = nil
|
|
29
|
+
@stop_at = block unless block.nil?
|
|
30
|
+
@stop_at
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def in_progress? issue
|
|
34
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
35
|
+
started_time && stopped_time.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def done? issue
|
|
39
|
+
started_stopped_times(issue).last
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def started_time issue
|
|
43
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
44
|
+
started_stopped_times(issue).first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def stopped_time issue
|
|
48
|
+
@file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
|
|
49
|
+
started_stopped_times(issue).last
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fabricate_change_item time
|
|
53
|
+
@file_system.deprecated(
|
|
54
|
+
date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
|
|
55
|
+
)
|
|
56
|
+
raw = {
|
|
57
|
+
'field' => 'Fabricated change',
|
|
58
|
+
'to' => '0',
|
|
59
|
+
'toString' => '',
|
|
60
|
+
'from' => '0',
|
|
61
|
+
'fromString' => ''
|
|
62
|
+
}
|
|
63
|
+
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def started_stopped_changes issue
|
|
67
|
+
cache_key = "#{issue.key}:#{issue.board.id}"
|
|
68
|
+
last_result = (@cache ||= {})[cache_key]
|
|
69
|
+
return *last_result if last_result && settings['cache_cycletime_calculations']
|
|
70
|
+
|
|
71
|
+
started = @start_at.call(issue)
|
|
72
|
+
stopped = @stop_at.call(issue)
|
|
73
|
+
|
|
74
|
+
# Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
|
|
75
|
+
# If they are false then explicitly make them nil.
|
|
76
|
+
started ||= nil
|
|
77
|
+
stopped ||= nil
|
|
78
|
+
|
|
79
|
+
# These are only here for backwards compatibility. Hopefully nobody will ever need them.
|
|
80
|
+
started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
|
|
81
|
+
stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
|
|
82
|
+
|
|
83
|
+
# In the case where started and stopped are exactly the same time, we pretend that
|
|
84
|
+
# it just stopped and never started. This allows us to have logic like 'in or right of'
|
|
85
|
+
# for the start and not have it conflict.
|
|
86
|
+
started = nil if started&.time == stopped&.time
|
|
87
|
+
|
|
88
|
+
result = [started, stopped]
|
|
89
|
+
if last_result && result != last_result
|
|
90
|
+
@file_system.error(
|
|
91
|
+
"Calculation mismatch; this could break caching. #{issue.inspect} new=#{result.inspect}, " \
|
|
92
|
+
"previous=#{last_result.inspect}"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
@cache[cache_key] = result
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def started_stopped_times issue
|
|
100
|
+
started, stopped = started_stopped_changes(issue)
|
|
101
|
+
[started&.time, stopped&.time]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def flush_cache
|
|
105
|
+
@cache = nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def started_stopped_dates issue
|
|
109
|
+
started_time, stopped_time = started_stopped_times(issue)
|
|
110
|
+
[started_time&.to_date, stopped_time&.to_date]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def cycletime issue
|
|
114
|
+
start, stop = started_stopped_times(issue)
|
|
115
|
+
return nil if start.nil? || stop.nil?
|
|
116
|
+
|
|
117
|
+
(stop.to_date - start.to_date).to_i + 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def age issue, today: nil
|
|
121
|
+
start = started_stopped_times(issue).first
|
|
122
|
+
stop = today || @today || Date.today
|
|
123
|
+
return nil if start.nil? || stop.nil?
|
|
124
|
+
|
|
125
|
+
(stop.to_date - start.to_date).to_i + 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def possible_statuses
|
|
129
|
+
if parent_config.is_a? BoardConfig
|
|
130
|
+
project_config = parent_config.project_config
|
|
131
|
+
else
|
|
132
|
+
# TODO: This will go away when cycletimes are no longer supported inside html_reports
|
|
133
|
+
project_config = parent_config.file_config.project_config
|
|
134
|
+
end
|
|
135
|
+
project_config.possible_statuses
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
3
|
+
require 'jirametrics/time_based_histogram'
|
|
4
4
|
|
|
5
|
-
class CycletimeHistogram <
|
|
6
|
-
include GroupableIssueChart
|
|
5
|
+
class CycletimeHistogram < TimeBasedHistogram
|
|
7
6
|
attr_accessor :possible_statuses
|
|
8
7
|
|
|
9
8
|
def initialize block
|
|
10
9
|
super()
|
|
11
10
|
|
|
11
|
+
@x_axis_title = 'Cycletime in days'
|
|
12
|
+
@y_axis_title = 'Count'
|
|
13
|
+
|
|
12
14
|
header_text 'Cycletime Histogram'
|
|
13
15
|
description_text <<-HTML
|
|
14
16
|
<p>
|
|
@@ -26,49 +28,26 @@ class CycletimeHistogram < ChartBase
|
|
|
26
28
|
end
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def
|
|
31
|
+
def all_items
|
|
30
32
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
31
33
|
|
|
32
34
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
data_sets = rules_to_issues.keys.collect do |rules|
|
|
37
|
-
data_set_for(
|
|
38
|
-
histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
|
|
39
|
-
label: rules.label,
|
|
40
|
-
color: rules.color
|
|
41
|
-
)
|
|
42
|
-
end
|
|
35
|
+
stopped_issues.select { |issue| issue.started_stopped_times.first }
|
|
36
|
+
end
|
|
43
37
|
|
|
44
|
-
|
|
38
|
+
def value_for_item issue
|
|
39
|
+
issue.board.cycletime.cycletime(issue)
|
|
45
40
|
end
|
|
46
41
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
issues.each do |issue|
|
|
50
|
-
days = issue.board.cycletime.cycletime(issue)
|
|
51
|
-
count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
|
|
52
|
-
end
|
|
53
|
-
count_hash
|
|
42
|
+
def title_for_item count:, value:
|
|
43
|
+
"#{count} items completed in #{label_days value}"
|
|
54
44
|
end
|
|
55
45
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
type: 'bar',
|
|
60
|
-
label: label,
|
|
61
|
-
data: keys.sort.filter_map do |key|
|
|
62
|
-
next if histogram_data[key].zero?
|
|
46
|
+
def sort_items items
|
|
47
|
+
items.sort_by(&:key_as_i)
|
|
48
|
+
end
|
|
63
49
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
y: histogram_data[key],
|
|
67
|
-
title: "#{histogram_data[key]} items completed in #{label_days key}"
|
|
68
|
-
}
|
|
69
|
-
end,
|
|
70
|
-
backgroundColor: color,
|
|
71
|
-
borderRadius: 0
|
|
72
|
-
}
|
|
50
|
+
def label_for_item issue, hint:
|
|
51
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
73
52
|
end
|
|
74
53
|
end
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'jirametrics/
|
|
4
|
-
|
|
5
|
-
class CycletimeScatterplot < ChartBase
|
|
6
|
-
include GroupableIssueChart
|
|
3
|
+
require 'jirametrics/time_based_scatterplot'
|
|
7
4
|
|
|
5
|
+
class CycletimeScatterplot < TimeBasedScatterplot
|
|
8
6
|
attr_accessor :possible_statuses
|
|
9
7
|
|
|
10
8
|
def initialize block
|
|
@@ -24,8 +22,10 @@ class CycletimeScatterplot < ChartBase
|
|
|
24
22
|
predict that most work of this type will complete in <%= overall_percent_line %> days or
|
|
25
23
|
less. The other lines reflect the 85% line for that respective type of work.
|
|
26
24
|
</div>
|
|
27
|
-
#{
|
|
25
|
+
#{describe_non_working_days}
|
|
28
26
|
HTML
|
|
27
|
+
@x_axis_title = 'Date completed'
|
|
28
|
+
@y_axis_title = 'Cycletime in days'
|
|
29
29
|
|
|
30
30
|
init_configuration_block block do
|
|
31
31
|
grouping_rules do |issue, rule|
|
|
@@ -33,98 +33,29 @@ class CycletimeScatterplot < ChartBase
|
|
|
33
33
|
rule.color = color_for type: issue.type
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
|
-
|
|
37
|
-
@percentage_lines = []
|
|
38
|
-
@highest_cycletime = 0
|
|
39
36
|
end
|
|
40
37
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
data_sets = create_datasets completed_issues
|
|
45
|
-
overall_percent_line = calculate_percent_line(completed_issues)
|
|
46
|
-
@percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
|
|
47
|
-
|
|
48
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
49
|
-
|
|
50
|
-
wrap_and_render(binding, __FILE__)
|
|
38
|
+
def minimum_y_value
|
|
39
|
+
1 # Values under 1 day are data quality problems; they're flagged in the quality report instead
|
|
51
40
|
end
|
|
52
41
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
groups = group_issues completed_issues
|
|
57
|
-
|
|
58
|
-
groups.each_key do |rules|
|
|
59
|
-
completed_issues_by_type = groups[rules]
|
|
60
|
-
label = rules.label
|
|
61
|
-
color = rules.color
|
|
62
|
-
percent_line = calculate_percent_line completed_issues_by_type
|
|
63
|
-
data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
|
|
64
|
-
data_sets << {
|
|
65
|
-
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
66
|
-
data: data,
|
|
67
|
-
fill: false,
|
|
68
|
-
showLine: false,
|
|
69
|
-
backgroundColor: color
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
73
|
-
|
|
74
|
-
@percentage_lines << [percent_line, color]
|
|
75
|
-
end
|
|
76
|
-
data_sets
|
|
42
|
+
def all_items
|
|
43
|
+
completed_issues_in_range include_unstarted: false
|
|
77
44
|
end
|
|
78
45
|
|
|
79
|
-
def
|
|
80
|
-
|
|
46
|
+
def x_value item
|
|
47
|
+
item.started_stopped_times.last
|
|
81
48
|
end
|
|
82
49
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# The trend calculation works with numbers only so convert Time to an int and back
|
|
89
|
-
calculator = TrendLineCalculator.new(points)
|
|
90
|
-
data_points = calculator.chart_datapoints(
|
|
91
|
-
range: time_range.begin.to_i..time_range.end.to_i,
|
|
92
|
-
max_y: @highest_cycletime
|
|
93
|
-
)
|
|
94
|
-
data_points.each do |point_hash|
|
|
95
|
-
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
type: 'line',
|
|
100
|
-
label: "#{label} Trendline",
|
|
101
|
-
data: data_points,
|
|
102
|
-
fill: false,
|
|
103
|
-
borderWidth: 1,
|
|
104
|
-
markerType: 'none',
|
|
105
|
-
borderColor: color,
|
|
106
|
-
borderDash: [6, 3],
|
|
107
|
-
pointStyle: 'dash',
|
|
108
|
-
hidden: !@show_trend_lines
|
|
109
|
-
}
|
|
50
|
+
def y_value item
|
|
51
|
+
item.board.cycletime.cycletime(item)
|
|
110
52
|
end
|
|
111
53
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
@highest_cycletime = cycle_time if @highest_cycletime < cycle_time
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
y: cycle_time,
|
|
120
|
-
x: chart_format(issue.board.cycletime.stopped_time(issue)),
|
|
121
|
-
title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
|
|
122
|
-
}
|
|
54
|
+
def title_value item, rules: nil
|
|
55
|
+
hint = @issue_hints&.fetch(item, nil)
|
|
56
|
+
"#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
|
|
123
57
|
end
|
|
124
58
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
index = times.size * 85 / 100
|
|
128
|
-
times.sort[index]
|
|
129
|
-
end
|
|
59
|
+
# Kept for backwards compatibility with existing callers and specs
|
|
60
|
+
alias data_for_issue data_for_item
|
|
130
61
|
end
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class DailyView < ChartBase
|
|
4
|
+
attr_accessor :possible_statuses
|
|
5
|
+
|
|
6
|
+
def initialize _block
|
|
7
|
+
super()
|
|
8
|
+
|
|
9
|
+
header_text 'Daily View'
|
|
10
|
+
description_text <<-HTML
|
|
11
|
+
<div class="p">
|
|
12
|
+
This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
|
|
13
|
+
coordination meeting
|
|
14
|
+
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
15
|
+
items are at the top, and the least at the bottom.
|
|
16
|
+
</div>
|
|
17
|
+
<div class="p">
|
|
18
|
+
By default, we sort by priority first and then by age within each of those priorities.
|
|
19
|
+
Hover over the issue to make it stand out more.
|
|
20
|
+
</div>
|
|
21
|
+
HTML
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
aging_issues = select_aging_issues
|
|
26
|
+
|
|
27
|
+
return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>" if aging_issues.empty?
|
|
28
|
+
|
|
29
|
+
result = +''
|
|
30
|
+
result << render_top_text(binding)
|
|
31
|
+
aging_issues.each do |issue|
|
|
32
|
+
result << render_issue(issue, child: false)
|
|
33
|
+
end
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def select_aging_issues
|
|
38
|
+
aging_issues = issues.select do |issue|
|
|
39
|
+
started_at, stopped_at = issue.started_stopped_times
|
|
40
|
+
started_at && !stopped_at
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
today = date_range.end
|
|
44
|
+
aging_issues.collect do |issue|
|
|
45
|
+
[issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
|
|
46
|
+
end.sort(&issue_sorter).collect(&:first)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def issue_sorter
|
|
50
|
+
priority_names = settings['priority_order']
|
|
51
|
+
lambda do |a, b|
|
|
52
|
+
a_issue, a_priority, a_age = *a
|
|
53
|
+
b_issue, b_priority, b_age = *b
|
|
54
|
+
|
|
55
|
+
a_priority_index = priority_names.index(a_priority)
|
|
56
|
+
b_priority_index = priority_names.index(b_priority)
|
|
57
|
+
|
|
58
|
+
if a_priority_index.nil? && b_priority_index.nil?
|
|
59
|
+
result = a_priority <=> b_priority
|
|
60
|
+
elsif a_priority_index.nil?
|
|
61
|
+
result = 1
|
|
62
|
+
elsif b_priority_index.nil?
|
|
63
|
+
result = -1
|
|
64
|
+
else
|
|
65
|
+
result = b_priority_index <=> a_priority_index
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
result = b_age <=> a_age if result.zero?
|
|
69
|
+
result = a_issue <=> b_issue if result.zero?
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def make_blocked_stalled_lines issue
|
|
75
|
+
today = date_range.end
|
|
76
|
+
started_date = issue.started_stopped_times.first&.to_date
|
|
77
|
+
return [] unless started_date
|
|
78
|
+
|
|
79
|
+
blocked_stalled = issue.blocked_stalled_by_date(
|
|
80
|
+
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
81
|
+
)[today]
|
|
82
|
+
return [] if blocked_stalled.active?
|
|
83
|
+
|
|
84
|
+
lines = []
|
|
85
|
+
if blocked_stalled.blocked?
|
|
86
|
+
marker = color_block '--blocked-color'
|
|
87
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
88
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
89
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
90
|
+
blocking_issue = issues.find_by_key key: key, include_hidden: true
|
|
91
|
+
if blocking_issue
|
|
92
|
+
lines << "<section><div class=\"foldable startFolded\">#{marker} Blocked by issue: " \
|
|
93
|
+
"#{make_issue_label issue: blocking_issue, done: blocking_issue.done?}</div>"
|
|
94
|
+
lines << blocking_issue
|
|
95
|
+
lines << '</section>'
|
|
96
|
+
else
|
|
97
|
+
lines << ["#{marker} Blocked by issue: #{key} (no description found)"]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
elsif blocked_stalled.stalled_by_status?
|
|
101
|
+
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
102
|
+
else
|
|
103
|
+
lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
|
|
104
|
+
end
|
|
105
|
+
lines
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def make_issue_label issue:, done:
|
|
109
|
+
label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
|
|
110
|
+
label << '<s>' if done
|
|
111
|
+
label << "<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
112
|
+
label << '</s>' if done
|
|
113
|
+
label
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def make_title_line issue:, done:
|
|
117
|
+
title_line = +''
|
|
118
|
+
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
119
|
+
title_line << make_issue_label(issue: issue, done: done)
|
|
120
|
+
title_line
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def make_parent_lines issue
|
|
124
|
+
lines = []
|
|
125
|
+
parent_key = issue.parent_key
|
|
126
|
+
if parent_key
|
|
127
|
+
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
128
|
+
text = parent ? make_issue_label(issue: parent, done: parent.done?) : parent_key
|
|
129
|
+
lines << ["Parent: #{text}"]
|
|
130
|
+
end
|
|
131
|
+
lines
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def make_stats_lines issue:, done:
|
|
135
|
+
line = []
|
|
136
|
+
|
|
137
|
+
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
138
|
+
|
|
139
|
+
if done
|
|
140
|
+
cycletime = issue.board.cycletime.cycletime(issue)
|
|
141
|
+
|
|
142
|
+
line << "Cycletime: <b>#{label_days cycletime}</b>"
|
|
143
|
+
else
|
|
144
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
145
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
146
|
+
end
|
|
147
|
+
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
148
|
+
|
|
149
|
+
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
150
|
+
line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
|
|
151
|
+
|
|
152
|
+
if issue.assigned_to
|
|
153
|
+
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if issue.due_date
|
|
157
|
+
today = date_range.end
|
|
158
|
+
days = (issue.due_date - today).to_i
|
|
159
|
+
relative =
|
|
160
|
+
if days.zero? then 'today'
|
|
161
|
+
elsif days.positive? then "in #{label_days days}"
|
|
162
|
+
else "#{label_days(-days)} ago"
|
|
163
|
+
end
|
|
164
|
+
content = "#{issue.due_date} (#{relative})"
|
|
165
|
+
content = "<span style='background: var(--warning-banner)'>#{content}</span>" if days.negative?
|
|
166
|
+
line << "Due: <b>#{content}</b>"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
block = lambda do |collection, label|
|
|
170
|
+
unless collection.empty?
|
|
171
|
+
text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
|
|
172
|
+
line << "#{label} #{text}"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
block.call issue.labels, 'Labels:'
|
|
176
|
+
block.call issue.component_names, 'Components:'
|
|
177
|
+
|
|
178
|
+
[line]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def make_child_lines issue
|
|
182
|
+
lines = []
|
|
183
|
+
subtasks = issue.subtasks
|
|
184
|
+
|
|
185
|
+
return lines if subtasks.empty?
|
|
186
|
+
|
|
187
|
+
lines << "<section><div class=\"foldable startFolded\">Child issues (#{subtasks.count})</div>"
|
|
188
|
+
lines += subtasks
|
|
189
|
+
lines << '</section>'
|
|
190
|
+
|
|
191
|
+
lines
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def make_history_lines issue
|
|
195
|
+
history = issue.changes.reverse
|
|
196
|
+
lines = []
|
|
197
|
+
|
|
198
|
+
lines << '<section><div class="foldable startFolded">Issue history</div>'
|
|
199
|
+
table = +''
|
|
200
|
+
table << '<table>'
|
|
201
|
+
history.each do |c|
|
|
202
|
+
time = c.time.strftime '%b %d, %Y @ %I:%M%P'
|
|
203
|
+
|
|
204
|
+
table << '<tr>'
|
|
205
|
+
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
206
|
+
table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
|
|
207
|
+
text = history_text change: c, board: issue.board
|
|
208
|
+
table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
|
|
209
|
+
table << '</tr>'
|
|
210
|
+
end
|
|
211
|
+
table << '</table>'
|
|
212
|
+
lines << [table]
|
|
213
|
+
lines << '</section>'
|
|
214
|
+
lines
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def history_text change:, board:
|
|
218
|
+
convertor = ->(value, _id) { value.inspect }
|
|
219
|
+
convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
|
|
220
|
+
|
|
221
|
+
if change.comment? || change.description?
|
|
222
|
+
atlassian_document_format.to_html(change.value)
|
|
223
|
+
elsif %w[status priority assignee duedate issuetype].include?(change.field)
|
|
224
|
+
to = convertor.call(change.value, change.value_id)
|
|
225
|
+
if change.old_value
|
|
226
|
+
from = convertor.call(change.old_value, change.old_value_id)
|
|
227
|
+
"Changed from #{from} to #{to}"
|
|
228
|
+
else
|
|
229
|
+
"Set to #{to}"
|
|
230
|
+
end
|
|
231
|
+
elsif change.flagged?
|
|
232
|
+
change.value == '' ? 'Off' : 'On'
|
|
233
|
+
else
|
|
234
|
+
change.value
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def make_sprints_lines issue
|
|
239
|
+
return [] unless issue.board.scrum?
|
|
240
|
+
|
|
241
|
+
sprint_names = issue.sprints.collect do |sprint|
|
|
242
|
+
if sprint.closed?
|
|
243
|
+
"<s>#{sprint.name}</s>"
|
|
244
|
+
else
|
|
245
|
+
sprint.name
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
return [['Sprints: NONE']] if sprint_names.empty?
|
|
250
|
+
|
|
251
|
+
[[+'Sprints: ' << sprint_names
|
|
252
|
+
.collect { |name| "<span class='label'>#{name}</span>" }
|
|
253
|
+
.join(' ')]]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def make_description_lines issue
|
|
257
|
+
description = issue.raw['fields']['description']
|
|
258
|
+
return [] unless description
|
|
259
|
+
|
|
260
|
+
text = "<div class='foldable startFolded'>Description</div>" \
|
|
261
|
+
"<div>#{atlassian_document_format.to_html(description)}</div>"
|
|
262
|
+
[[text]]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def assemble_issue_lines issue, child:
|
|
266
|
+
done = issue.done?
|
|
267
|
+
|
|
268
|
+
lines = []
|
|
269
|
+
lines << [make_title_line(issue: issue, done: done)]
|
|
270
|
+
lines << make_not_visible_line(issue)
|
|
271
|
+
lines += make_parent_lines(issue) unless child
|
|
272
|
+
lines += make_stats_lines(issue: issue, done: done)
|
|
273
|
+
unless done
|
|
274
|
+
lines += make_description_lines(issue)
|
|
275
|
+
lines += make_sprints_lines(issue)
|
|
276
|
+
lines += make_blocked_stalled_lines(issue)
|
|
277
|
+
lines += make_child_lines(issue)
|
|
278
|
+
lines += make_history_lines(issue)
|
|
279
|
+
end
|
|
280
|
+
lines.compact
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def render_issue issue, child:
|
|
284
|
+
css_class = child ? 'child_issue' : 'daily_issue'
|
|
285
|
+
result = +''
|
|
286
|
+
result << "<div class='#{css_class}'>"
|
|
287
|
+
assemble_issue_lines(issue, child: child).each do |row|
|
|
288
|
+
if row.is_a? Issue
|
|
289
|
+
result << render_issue(row, child: true)
|
|
290
|
+
elsif row.is_a?(String)
|
|
291
|
+
result << row
|
|
292
|
+
else
|
|
293
|
+
result << '<div class="heading">'
|
|
294
|
+
row.each do |chunk|
|
|
295
|
+
result << "<div>#{chunk}</div>"
|
|
296
|
+
end
|
|
297
|
+
result << '</div>'
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
result << '</div>'
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def make_not_visible_line issue
|
|
304
|
+
not_visible_text issue
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -4,7 +4,7 @@ require 'jirametrics/daily_wip_chart'
|
|
|
4
4
|
|
|
5
5
|
class DailyWipByAgeChart < DailyWipChart
|
|
6
6
|
def initialize block
|
|
7
|
-
super
|
|
7
|
+
super
|
|
8
8
|
|
|
9
9
|
add_trend_line line_color: '--aging-work-in-progress-by-age-trend-line-color', group_labels: [
|
|
10
10
|
'Less than a day',
|
|
@@ -49,11 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
|
|
53
|
-
started = cycletime.started_time(issue)&.to_date
|
|
54
|
-
stopped = cycletime.stopped_time(issue)&.to_date
|
|
55
|
-
|
|
56
|
-
rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
|
|
52
|
+
started, stopped = issue.started_stopped_dates
|
|
57
53
|
|
|
58
54
|
if stopped && started.nil? # We can't tell when it started
|
|
59
55
|
@has_completed_but_not_started = true
|
|
@@ -74,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
74
70
|
rules.label = 'Start date unknown'
|
|
75
71
|
rules.color = '--body-background'
|
|
76
72
|
rules.group_priority = 11
|
|
77
|
-
created_days = rules.current_date - created
|
|
73
|
+
created_days = rules.current_date - created
|
|
78
74
|
rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
|
|
79
75
|
end
|
|
80
76
|
end
|
|
@@ -86,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
86
82
|
end
|
|
87
83
|
|
|
88
84
|
def group_by_age started:, rules:
|
|
89
|
-
age = rules.current_date - started + 1
|
|
85
|
+
age = (rules.current_date - started).to_i + 1
|
|
86
|
+
rules.issue_hint = "(age: #{label_days age})"
|
|
90
87
|
|
|
91
88
|
case age
|
|
92
89
|
when 1
|