jirametrics 2.10 → 2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/jirametrics-mcp +5 -0
- data/lib/jirametrics/aggregate_config.rb +10 -2
- data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
- data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
- data/lib/jirametrics/aging_work_table.rb +62 -17
- 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 +5 -3
- data/lib/jirametrics/board.rb +63 -11
- data/lib/jirametrics/board_config.rb +5 -1
- 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 +49 -19
- data/lib/jirametrics/chart_base.rb +147 -7
- data/lib/jirametrics/css_variable.rb +2 -2
- data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
- data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
- data/lib/jirametrics/cycletime_histogram.rb +15 -101
- data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
- data/lib/jirametrics/daily_view.rb +306 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
- data/lib/jirametrics/daily_wip_chart.rb +30 -8
- data/lib/jirametrics/data_quality_report.rb +43 -12
- data/lib/jirametrics/dependency_chart.rb +6 -3
- data/lib/jirametrics/download_config.rb +15 -0
- data/lib/jirametrics/downloader.rb +128 -71
- 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 +74 -12
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +2 -2
- data/lib/jirametrics/examples/standard_project.rb +42 -27
- data/lib/jirametrics/expedited_chart.rb +3 -1
- data/lib/jirametrics/exporter.rb +28 -8
- data/lib/jirametrics/file_config.rb +10 -12
- data/lib/jirametrics/file_system.rb +59 -3
- data/lib/jirametrics/fix_version.rb +13 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
- data/lib/jirametrics/github_gateway.rb +115 -0
- data/lib/jirametrics/groupable_issue_chart.rb +11 -1
- data/lib/jirametrics/grouping_rules.rb +26 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
- data/lib/jirametrics/html/aging_work_table.erb +12 -3
- data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
- data/lib/jirametrics/html/expedited_chart.erb +6 -14
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
- data/lib/jirametrics/html/index.css +323 -63
- data/lib/jirametrics/html/index.erb +17 -19
- data/lib/jirametrics/html/index.js +164 -0
- data/lib/jirametrics/html/legacy_colors.css +174 -0
- data/lib/jirametrics/html/sprint_burndown.erb +17 -15
- data/lib/jirametrics/html/throughput_chart.erb +42 -11
- data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
- data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
- data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
- data/lib/jirametrics/html_generator.rb +32 -0
- data/lib/jirametrics/html_report_config.rb +52 -55
- data/lib/jirametrics/issue.rb +347 -103
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/issue_printer.rb +97 -0
- data/lib/jirametrics/jira_gateway.rb +81 -14
- data/lib/jirametrics/mcp_server.rb +531 -0
- data/lib/jirametrics/project_config.rb +151 -18
- data/lib/jirametrics/pull_request.rb +30 -0
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
- data/lib/jirametrics/pull_request_review.rb +13 -0
- data/lib/jirametrics/raw_javascript.rb +17 -0
- data/lib/jirametrics/settings.json +6 -1
- data/lib/jirametrics/sprint.rb +13 -0
- data/lib/jirametrics/sprint_burndown.rb +45 -37
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- 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/wip_by_column_chart.rb +236 -0
- data/lib/jirametrics.rb +83 -64
- metadata +66 -6
|
@@ -1,17 +1,15 @@
|
|
|
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
|
-
attr_reader :show_stats
|
|
9
7
|
|
|
10
8
|
def initialize block
|
|
11
9
|
super()
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
@
|
|
11
|
+
@x_axis_title = 'Cycletime in days'
|
|
12
|
+
@y_axis_title = 'Count'
|
|
15
13
|
|
|
16
14
|
header_text 'Cycletime Histogram'
|
|
17
15
|
description_text <<-HTML
|
|
@@ -30,110 +28,26 @@ class CycletimeHistogram < ChartBase
|
|
|
30
28
|
end
|
|
31
29
|
end
|
|
32
30
|
|
|
33
|
-
def
|
|
34
|
-
@percentiles = percs unless percs.nil?
|
|
35
|
-
@percentiles
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def disable_stats
|
|
39
|
-
@show_stats = false
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def run
|
|
31
|
+
def all_items
|
|
43
32
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
44
33
|
|
|
45
34
|
# For the histogram, we only want to consider items that have both a start and a stop time.
|
|
46
|
-
|
|
47
|
-
rules_to_issues = group_issues histogram_issues
|
|
48
|
-
|
|
49
|
-
the_stats = {}
|
|
50
|
-
|
|
51
|
-
overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
|
|
52
|
-
the_stats[:all] = overall_stats
|
|
53
|
-
data_sets = rules_to_issues.keys.collect do |rules|
|
|
54
|
-
the_issue_type = rules.label
|
|
55
|
-
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
|
56
|
-
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
|
57
|
-
|
|
58
|
-
data_set_for(
|
|
59
|
-
histogram_data: the_histogram,
|
|
60
|
-
label: the_issue_type,
|
|
61
|
-
color: rules.color
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
66
|
-
|
|
67
|
-
wrap_and_render(binding, __FILE__)
|
|
35
|
+
stopped_issues.select { |issue| issue.started_stopped_times.first }
|
|
68
36
|
end
|
|
69
37
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
issues.each do |issue|
|
|
73
|
-
days = issue.board.cycletime.cycletime(issue)
|
|
74
|
-
count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
|
|
75
|
-
end
|
|
76
|
-
count_hash
|
|
38
|
+
def value_for_item issue
|
|
39
|
+
issue.board.cycletime.cycletime(issue)
|
|
77
40
|
end
|
|
78
41
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
total_values = histogram_data.values.sum
|
|
83
|
-
|
|
84
|
-
# Calculate the average
|
|
85
|
-
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
|
86
|
-
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
|
87
|
-
|
|
88
|
-
# Find the mode (or modes!) and the spread of the distribution
|
|
89
|
-
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
|
90
|
-
max_freq = sorted_histogram[-1][1]
|
|
91
|
-
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
|
92
|
-
|
|
93
|
-
minmax = histogram_data.keys.minmax
|
|
94
|
-
|
|
95
|
-
# Calculate percentiles
|
|
96
|
-
sorted_values = histogram_data.keys.sort
|
|
97
|
-
cumulative_counts = {}
|
|
98
|
-
cumulative_sum = 0
|
|
99
|
-
|
|
100
|
-
sorted_values.each do |value|
|
|
101
|
-
cumulative_sum += histogram_data[value]
|
|
102
|
-
cumulative_counts[value] = cumulative_sum
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
percentile_results = {}
|
|
106
|
-
percentiles.each do |percentile|
|
|
107
|
-
rank = (percentile / 100.0) * total_values
|
|
108
|
-
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
|
109
|
-
percentile_results[percentile] = percentile_value
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
average: average,
|
|
114
|
-
mode: mode.collect(&:first).sort,
|
|
115
|
-
min: minmax[0],
|
|
116
|
-
max: minmax[1],
|
|
117
|
-
percentiles: percentile_results
|
|
118
|
-
}
|
|
42
|
+
def title_for_item count:, value:
|
|
43
|
+
"#{count} items completed in #{label_days value}"
|
|
119
44
|
end
|
|
120
45
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
type: 'bar',
|
|
125
|
-
label: label,
|
|
126
|
-
data: keys.sort.filter_map do |key|
|
|
127
|
-
next if histogram_data[key].zero?
|
|
46
|
+
def sort_items items
|
|
47
|
+
items.sort_by(&:key_as_i)
|
|
48
|
+
end
|
|
128
49
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
y: histogram_data[key],
|
|
132
|
-
title: "#{histogram_data[key]} items completed in #{label_days key}"
|
|
133
|
-
}
|
|
134
|
-
end,
|
|
135
|
-
backgroundColor: color,
|
|
136
|
-
borderRadius: 0
|
|
137
|
-
}
|
|
50
|
+
def label_for_item issue, hint:
|
|
51
|
+
"#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
|
|
138
52
|
end
|
|
139
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
|
|
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
|
|
|
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,95 +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
|
-
group_issues(completed_issues).each do |rules, completed_issues_by_type|
|
|
57
|
-
label = rules.label
|
|
58
|
-
color = rules.color
|
|
59
|
-
percent_line = calculate_percent_line completed_issues_by_type
|
|
60
|
-
data = completed_issues_by_type.filter_map { |issue| data_for_issue(issue) }
|
|
61
|
-
data_sets << {
|
|
62
|
-
label: "#{label} (85% at #{label_days(percent_line)})",
|
|
63
|
-
data: data,
|
|
64
|
-
fill: false,
|
|
65
|
-
showLine: false,
|
|
66
|
-
backgroundColor: color
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
data_sets << trend_line_data_set(label: label, data: data, color: color)
|
|
70
|
-
|
|
71
|
-
@percentage_lines << [percent_line, color]
|
|
72
|
-
end
|
|
73
|
-
data_sets
|
|
42
|
+
def all_items
|
|
43
|
+
completed_issues_in_range include_unstarted: false
|
|
74
44
|
end
|
|
75
45
|
|
|
76
|
-
def
|
|
77
|
-
|
|
46
|
+
def x_value item
|
|
47
|
+
item.started_stopped_times.last
|
|
78
48
|
end
|
|
79
49
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
[Time.parse(hash[:x]).to_i, hash[:y]]
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# The trend calculation works with numbers only so convert Time to an int and back
|
|
86
|
-
calculator = TrendLineCalculator.new(points)
|
|
87
|
-
data_points = calculator.chart_datapoints(
|
|
88
|
-
range: time_range.begin.to_i..time_range.end.to_i,
|
|
89
|
-
max_y: @highest_cycletime
|
|
90
|
-
)
|
|
91
|
-
data_points.each do |point_hash|
|
|
92
|
-
point_hash[:x] = chart_format Time.at(point_hash[:x])
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
{
|
|
96
|
-
type: 'line',
|
|
97
|
-
label: "#{label} Trendline",
|
|
98
|
-
data: data_points,
|
|
99
|
-
fill: false,
|
|
100
|
-
borderWidth: 1,
|
|
101
|
-
markerType: 'none',
|
|
102
|
-
borderColor: color,
|
|
103
|
-
borderDash: [6, 3],
|
|
104
|
-
pointStyle: 'dash',
|
|
105
|
-
hidden: !@show_trend_lines
|
|
106
|
-
}
|
|
50
|
+
def y_value item
|
|
51
|
+
item.board.cycletime.cycletime(item)
|
|
107
52
|
end
|
|
108
53
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@highest_cycletime = cycle_time if @highest_cycletime < cycle_time
|
|
114
|
-
|
|
115
|
-
{
|
|
116
|
-
y: cycle_time,
|
|
117
|
-
x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
|
|
118
|
-
title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
|
|
119
|
-
}
|
|
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}"
|
|
120
57
|
end
|
|
121
58
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
index = times.size * 85 / 100
|
|
125
|
-
times.sort[index]
|
|
126
|
-
end
|
|
59
|
+
# Kept for backwards compatibility with existing callers and specs
|
|
60
|
+
alias data_for_issue data_for_item
|
|
127
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
|
|
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def default_grouping_rules issue:, rules:
|
|
52
|
-
started, stopped = issue.
|
|
53
|
-
|
|
54
|
-
rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
|
|
52
|
+
started, stopped = issue.started_stopped_dates
|
|
55
53
|
|
|
56
54
|
if stopped && started.nil? # We can't tell when it started
|
|
57
55
|
@has_completed_but_not_started = true
|
|
@@ -72,7 +70,7 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
72
70
|
rules.label = 'Start date unknown'
|
|
73
71
|
rules.color = '--body-background'
|
|
74
72
|
rules.group_priority = 11
|
|
75
|
-
created_days = rules.current_date - created
|
|
73
|
+
created_days = rules.current_date - created
|
|
76
74
|
rules.issue_hint = "(created: #{label_days created_days.to_i} earlier, stopped on #{stopped})"
|
|
77
75
|
end
|
|
78
76
|
end
|
|
@@ -84,7 +82,8 @@ class DailyWipByAgeChart < DailyWipChart
|
|
|
84
82
|
end
|
|
85
83
|
|
|
86
84
|
def group_by_age started:, rules:
|
|
87
|
-
age = rules.current_date - started + 1
|
|
85
|
+
age = (rules.current_date - started).to_i + 1
|
|
86
|
+
rules.issue_hint = "(age: #{label_days age})"
|
|
88
87
|
|
|
89
88
|
case age
|
|
90
89
|
when 1
|
|
@@ -39,23 +39,32 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def default_grouping_rules issue:, rules:
|
|
42
|
-
started, stopped = issue.
|
|
42
|
+
started, stopped = issue.started_stopped_times
|
|
43
43
|
stopped_date = stopped&.to_date
|
|
44
|
+
started_date = started&.to_date
|
|
44
45
|
|
|
45
46
|
date = rules.current_date
|
|
46
47
|
change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
|
|
47
|
-
|
|
48
48
|
stopped_today = stopped_date == rules.current_date
|
|
49
49
|
|
|
50
|
+
days = nil
|
|
51
|
+
if started_date && stopped_date
|
|
52
|
+
days = (stopped_date - started_date).to_i + 1 # cycletime
|
|
53
|
+
elsif started_date
|
|
54
|
+
days = (time_range.end.to_date - started_date).to_i + 1 # age
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
if stopped_today && started.nil?
|
|
51
58
|
@has_completed_but_not_started = true
|
|
52
59
|
rules.label = 'Completed but not started'
|
|
53
60
|
rules.color = '--wip-chart-completed-but-not-started-color'
|
|
54
61
|
rules.group_priority = -1
|
|
62
|
+
rules.issue_hint = '(Cycle time: Unknown)'
|
|
55
63
|
elsif stopped_today
|
|
56
64
|
rules.label = 'Completed'
|
|
57
65
|
rules.color = '--wip-chart-completed-color'
|
|
58
66
|
rules.group_priority = -2
|
|
67
|
+
rules.issue_hint = "(Cycle time: #{label_days days})"
|
|
59
68
|
elsif started.nil?
|
|
60
69
|
rules.label = 'Start date unknown'
|
|
61
70
|
rules.color = '--body-background'
|
|
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
|
|
|
64
73
|
rules.label = 'Blocked'
|
|
65
74
|
rules.color = '--blocked-color'
|
|
66
75
|
rules.group_priority = 1
|
|
67
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
76
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
68
77
|
elsif change&.stalled?
|
|
69
78
|
rules.label = 'Stalled'
|
|
70
79
|
rules.color = '--stalled-color'
|
|
71
80
|
rules.group_priority = 2
|
|
72
|
-
rules.issue_hint = "(#{change.reasons})"
|
|
81
|
+
rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
|
|
73
82
|
else
|
|
74
83
|
rules.label = 'Active'
|
|
75
84
|
rules.color = '--wip-chart-active-color'
|
|
76
85
|
rules.group_priority = 3
|
|
86
|
+
rules.issue_hint = "(Age: #{label_days days})"
|
|
77
87
|
end
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def default_grouping_rules issue:, rules:
|
|
29
|
-
parent = issue.parent
|
|
29
|
+
parent = issue.parent
|
|
30
30
|
if parent
|
|
31
|
-
rules.label = parent
|
|
31
|
+
rules.label = parent.key
|
|
32
|
+
rules.label_hint = "#{parent.key} : #{parent.summary}"
|
|
32
33
|
else
|
|
33
34
|
rules.label = 'No parent'
|
|
35
|
+
rules.label_hint = 'No parent'
|
|
34
36
|
rules.group_priority = 1000
|
|
35
37
|
rules.color = '--body-background'
|
|
36
38
|
end
|