jirametrics 2.9 → 2.12.1
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 +1 -1
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +56 -13
- data/lib/jirametrics/board.rb +38 -10
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +37 -16
- data/lib/jirametrics/chart_base.rb +22 -5
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/cycletime_histogram.rb +65 -2
- data/lib/jirametrics/daily_view.rb +277 -0
- data/lib/jirametrics/data_quality_report.rb +1 -1
- data/lib/jirametrics/downloader.rb +11 -14
- data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/standard_project.rb +2 -0
- data/lib/jirametrics/exporter.rb +10 -8
- data/lib/jirametrics/file_config.rb +10 -5
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +7 -3
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/index.css +82 -2
- data/lib/jirametrics/html/index.erb +25 -1
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +68 -27
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +20 -4
- data/lib/jirametrics/project_config.rb +25 -8
- data/lib/jirametrics/settings.json +2 -1
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +35 -33
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -6
- data/lib/jirametrics/status_collection.rb +6 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +4 -0
- metadata +7 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class ChangeItem
|
|
4
|
-
attr_reader :field, :value_id, :old_value_id, :raw, :
|
|
4
|
+
attr_reader :field, :value_id, :old_value_id, :raw, :time, :author_raw
|
|
5
5
|
attr_accessor :value, :old_value
|
|
6
6
|
|
|
7
|
-
def initialize raw:,
|
|
7
|
+
def initialize raw:, author_raw:, time:, artificial: false
|
|
8
8
|
@raw = raw
|
|
9
|
+
@author_raw = author_raw
|
|
9
10
|
@time = time
|
|
10
11
|
raise 'ChangeItem.new() time cannot be nil' if time.nil?
|
|
11
12
|
raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
|
|
@@ -16,33 +17,42 @@ class ChangeItem
|
|
|
16
17
|
@old_value = @raw['fromString']
|
|
17
18
|
@old_value_id = @raw['from']&.to_i
|
|
18
19
|
@artificial = artificial
|
|
19
|
-
@author = author
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def author
|
|
23
|
+
@author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
|
|
24
|
+
end
|
|
23
25
|
|
|
24
|
-
def
|
|
26
|
+
def author_icon_url
|
|
27
|
+
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
|
28
|
+
end
|
|
25
29
|
|
|
30
|
+
def artificial? = @artificial
|
|
31
|
+
def assignee? = (field == 'assignee')
|
|
32
|
+
def comment? = (field == 'comment')
|
|
33
|
+
def due_date? = (field == 'duedate')
|
|
34
|
+
def flagged? = (field == 'Flagged')
|
|
35
|
+
def issue_type? = field == 'issuetype'
|
|
36
|
+
def labels? = (field == 'labels')
|
|
37
|
+
def link? = (field == 'Link')
|
|
26
38
|
def priority? = (field == 'priority')
|
|
27
|
-
|
|
28
39
|
def resolution? = (field == 'resolution')
|
|
29
|
-
|
|
30
|
-
def artificial? = @artificial
|
|
31
|
-
|
|
32
40
|
def sprint? = (field == 'Sprint')
|
|
33
|
-
|
|
34
|
-
def story_points? = (field == 'Story Points')
|
|
35
|
-
|
|
36
|
-
def link? = (field == 'Link')
|
|
37
|
-
|
|
38
|
-
def labels? = (field == 'labels')
|
|
41
|
+
def status? = (field == 'status')
|
|
39
42
|
|
|
40
43
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
44
|
def to_time = @time
|
|
42
45
|
|
|
43
46
|
def to_s
|
|
44
47
|
message = +''
|
|
45
|
-
message << "ChangeItem(field: #{field.inspect}
|
|
48
|
+
message << "ChangeItem(field: #{field.inspect}"
|
|
49
|
+
message << ", value: #{value.inspect}"
|
|
50
|
+
message << ':' << value_id.inspect if status?
|
|
51
|
+
if old_value
|
|
52
|
+
message << ", old_value: #{old_value.inspect}"
|
|
53
|
+
message << ':' << old_value_id.inspect if status?
|
|
54
|
+
end
|
|
55
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
|
46
56
|
message << ', artificial' if artificial?
|
|
47
57
|
message << ')'
|
|
48
58
|
message
|
|
@@ -84,6 +94,17 @@ class ChangeItem
|
|
|
84
94
|
end
|
|
85
95
|
end
|
|
86
96
|
|
|
97
|
+
def field_as_human_readable
|
|
98
|
+
case @field
|
|
99
|
+
when 'duedate' then 'Due date'
|
|
100
|
+
when 'timeestimate' then 'Time estimate'
|
|
101
|
+
when 'timeoriginalestimate' then 'Time original estimate'
|
|
102
|
+
when 'issuetype' then 'Issue type'
|
|
103
|
+
when 'IssueParentAssociation' then 'Issue parent association'
|
|
104
|
+
else @field.capitalize
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
87
108
|
private
|
|
88
109
|
|
|
89
110
|
def time_to_s time
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class ChartBase
|
|
4
4
|
attr_accessor :timezone_offset, :board_id, :all_boards, :date_range,
|
|
5
|
-
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system
|
|
5
|
+
:time_range, :data_quality, :holiday_dates, :settings, :issues, :file_system, :users
|
|
6
6
|
attr_writer :aggregated_project
|
|
7
7
|
attr_reader :canvas_width, :canvas_height
|
|
8
8
|
|
|
@@ -38,7 +38,6 @@ class ChartBase
|
|
|
38
38
|
# Insert a incrementing chart_id so that all the chart names on the page are unique
|
|
39
39
|
caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
|
|
40
40
|
|
|
41
|
-
# @html_directory = "#{pathname.dirname}/html"
|
|
42
41
|
erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
|
|
43
42
|
erb.result(caller_binding)
|
|
44
43
|
end
|
|
@@ -130,6 +129,21 @@ class ChartBase
|
|
|
130
129
|
result
|
|
131
130
|
end
|
|
132
131
|
|
|
132
|
+
def working_days_annotation
|
|
133
|
+
holidays.each_with_index.collect do |range, index|
|
|
134
|
+
<<~TEXT
|
|
135
|
+
holiday#{index}: {
|
|
136
|
+
drawTime: 'beforeDraw',
|
|
137
|
+
type: 'box',
|
|
138
|
+
xMin: '#{range.begin}T00:00:00',
|
|
139
|
+
xMax: '#{range.end}T23:59:59',
|
|
140
|
+
backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
|
|
141
|
+
borderColor: #{CssVariable.new('--non-working-days-color').to_json}
|
|
142
|
+
},
|
|
143
|
+
TEXT
|
|
144
|
+
end.join
|
|
145
|
+
end
|
|
146
|
+
|
|
133
147
|
# Return only the board columns for the current board.
|
|
134
148
|
def current_board
|
|
135
149
|
if @board_id.nil?
|
|
@@ -212,8 +226,8 @@ class ChartBase
|
|
|
212
226
|
icon: ' 👀'
|
|
213
227
|
)
|
|
214
228
|
end
|
|
215
|
-
text = is_category ? status.category
|
|
216
|
-
"<span title='Category: #{status.category
|
|
229
|
+
text = is_category ? status.category : status
|
|
230
|
+
"<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
|
|
217
231
|
end
|
|
218
232
|
|
|
219
233
|
def icon_span title:, icon:
|
|
@@ -245,7 +259,10 @@ class ChartBase
|
|
|
245
259
|
|
|
246
260
|
def color_block color, title: nil
|
|
247
261
|
result = +''
|
|
248
|
-
result << "<div class='color_block' style='
|
|
262
|
+
result << "<div class='color_block' style='"
|
|
263
|
+
result << "background: #{CssVariable[color]};" if color
|
|
264
|
+
result << 'visibility: hidden;' unless color
|
|
265
|
+
result << "'"
|
|
249
266
|
result << " title=#{title.inspect}" if title
|
|
250
267
|
result << '></div>'
|
|
251
268
|
result
|
|
@@ -59,7 +59,7 @@ class CycleTimeConfig
|
|
|
59
59
|
'from' => '0',
|
|
60
60
|
'fromString' => ''
|
|
61
61
|
}
|
|
62
|
-
ChangeItem.new raw: raw, time: time,
|
|
62
|
+
ChangeItem.new raw: raw, time: time, artificial: true, author_raw: nil
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def started_stopped_changes issue
|
|
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
|
|
|
5
5
|
class CycletimeHistogram < ChartBase
|
|
6
6
|
include GroupableIssueChart
|
|
7
7
|
attr_accessor :possible_statuses
|
|
8
|
+
attr_reader :show_stats
|
|
8
9
|
|
|
9
10
|
def initialize block
|
|
10
11
|
super()
|
|
11
12
|
|
|
13
|
+
percentiles [50, 85, 98]
|
|
14
|
+
@show_stats = true
|
|
15
|
+
|
|
12
16
|
header_text 'Cycletime Histogram'
|
|
13
17
|
description_text <<-HTML
|
|
14
18
|
<p>
|
|
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
|
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
def percentiles percs = nil
|
|
34
|
+
@percentiles = percs unless percs.nil?
|
|
35
|
+
@percentiles
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def disable_stats
|
|
39
|
+
@show_stats = false
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def run
|
|
30
43
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
|
31
44
|
|
|
@@ -33,10 +46,18 @@ class CycletimeHistogram < ChartBase
|
|
|
33
46
|
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
|
34
47
|
rules_to_issues = group_issues histogram_issues
|
|
35
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
|
|
36
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
|
+
|
|
37
58
|
data_set_for(
|
|
38
|
-
histogram_data:
|
|
39
|
-
label:
|
|
59
|
+
histogram_data: the_histogram,
|
|
60
|
+
label: the_issue_type,
|
|
40
61
|
color: rules.color
|
|
41
62
|
)
|
|
42
63
|
end
|
|
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
|
|
|
55
76
|
count_hash
|
|
56
77
|
end
|
|
57
78
|
|
|
79
|
+
def stats_for histogram_data:, percentiles:
|
|
80
|
+
return {} if histogram_data.empty?
|
|
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
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
58
121
|
def data_set_for histogram_data:, label:, color:
|
|
59
122
|
keys = histogram_data.keys.sort
|
|
60
123
|
{
|
|
@@ -0,0 +1,277 @@
|
|
|
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 you'll want to discuss during your daily coordination meeting
|
|
13
|
+
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
14
|
+
items are at the top, and the least at the bottom.
|
|
15
|
+
</div>
|
|
16
|
+
<div class="p">
|
|
17
|
+
By default, we sort by priority first and then by age within each of those priorities.
|
|
18
|
+
Hover over the issue to make it stand out more.
|
|
19
|
+
</div>
|
|
20
|
+
HTML
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
aging_issues = select_aging_issues
|
|
25
|
+
|
|
26
|
+
return "<h1>#{@header_text}</h1>There are no items currently in progress" if aging_issues.empty?
|
|
27
|
+
|
|
28
|
+
result = +''
|
|
29
|
+
result << render_top_text(binding)
|
|
30
|
+
aging_issues.each do |issue|
|
|
31
|
+
result << render_issue(issue, child: false)
|
|
32
|
+
end
|
|
33
|
+
result
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def select_aging_issues
|
|
37
|
+
aging_issues = issues.select do |issue|
|
|
38
|
+
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
39
|
+
started_at && !stopped_at
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
today = date_range.end
|
|
43
|
+
aging_issues.collect do |issue|
|
|
44
|
+
[issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
|
|
45
|
+
end.sort(&issue_sorter).collect(&:first)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def issue_sorter
|
|
49
|
+
priority_names = settings['priority_order']
|
|
50
|
+
lambda do |a, b|
|
|
51
|
+
a_issue, a_priority, a_age = *a
|
|
52
|
+
b_issue, b_priority, b_age = *b
|
|
53
|
+
|
|
54
|
+
a_priority_index = priority_names.index(a_priority)
|
|
55
|
+
b_priority_index = priority_names.index(b_priority)
|
|
56
|
+
|
|
57
|
+
if a_priority_index.nil? && b_priority_index.nil?
|
|
58
|
+
result = a_priority <=> b_priority
|
|
59
|
+
elsif a_priority_index.nil?
|
|
60
|
+
result = 1
|
|
61
|
+
elsif b_priority_index.nil?
|
|
62
|
+
result = -1
|
|
63
|
+
else
|
|
64
|
+
result = b_priority_index <=> a_priority_index
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
result = b_age <=> a_age if result.zero?
|
|
68
|
+
result = a_issue <=> b_issue if result.zero?
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def make_blocked_stalled_lines issue
|
|
74
|
+
today = date_range.end
|
|
75
|
+
started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
|
|
76
|
+
return [] unless started_date
|
|
77
|
+
|
|
78
|
+
blocked_stalled = issue.blocked_stalled_by_date(
|
|
79
|
+
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
80
|
+
)[today]
|
|
81
|
+
return [] unless blocked_stalled
|
|
82
|
+
|
|
83
|
+
lines = []
|
|
84
|
+
if blocked_stalled.blocked?
|
|
85
|
+
marker = color_block '--blocked-color'
|
|
86
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
87
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
88
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
89
|
+
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
90
|
+
blocking_issue = issues.find { |i| i.key == key }
|
|
91
|
+
lines << blocking_issue if blocking_issue
|
|
92
|
+
end
|
|
93
|
+
elsif blocked_stalled.stalled_by_status?
|
|
94
|
+
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
95
|
+
else
|
|
96
|
+
lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
|
|
97
|
+
end
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def make_issue_label issue
|
|
102
|
+
"<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> " \
|
|
103
|
+
"<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def make_title_line issue
|
|
107
|
+
title_line = +''
|
|
108
|
+
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
109
|
+
title_line << make_issue_label(issue)
|
|
110
|
+
title_line
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def make_parent_lines issue
|
|
114
|
+
lines = []
|
|
115
|
+
parent_key = issue.parent_key
|
|
116
|
+
if parent_key
|
|
117
|
+
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
118
|
+
text = parent ? make_issue_label(parent) : parent_key
|
|
119
|
+
lines << ["Parent: #{text}"]
|
|
120
|
+
end
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def make_stats_lines issue
|
|
125
|
+
line = []
|
|
126
|
+
|
|
127
|
+
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
128
|
+
|
|
129
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
130
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
131
|
+
|
|
132
|
+
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
133
|
+
|
|
134
|
+
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
135
|
+
line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
|
|
136
|
+
|
|
137
|
+
if issue.assigned_to
|
|
138
|
+
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
|
|
142
|
+
|
|
143
|
+
block = lambda do |collection, label|
|
|
144
|
+
unless collection.empty?
|
|
145
|
+
text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
|
|
146
|
+
line << "#{label} #{text}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
block.call issue.labels, 'Labels:'
|
|
150
|
+
block.call issue.component_names, 'Components:'
|
|
151
|
+
|
|
152
|
+
[line]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def make_child_lines issue
|
|
156
|
+
lines = []
|
|
157
|
+
subtasks = issue.subtasks.reject { |i| i.done? }
|
|
158
|
+
|
|
159
|
+
unless subtasks.empty?
|
|
160
|
+
icon_urls = subtasks.collect(&:type_icon_url).uniq.collect { |url| "<img src='#{url}' class='icon' />" }
|
|
161
|
+
lines << (icon_urls << 'Incomplete child issues')
|
|
162
|
+
lines += subtasks
|
|
163
|
+
end
|
|
164
|
+
lines
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def jira_rich_text_to_html text
|
|
168
|
+
text
|
|
169
|
+
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
170
|
+
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
171
|
+
.gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
|
|
172
|
+
.gsub("\n", '<br />')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def expand_account_id account_id
|
|
176
|
+
user = @users.find { |u| u.account_id == account_id }
|
|
177
|
+
text = account_id
|
|
178
|
+
text = "@#{user.display_name}" if user
|
|
179
|
+
"<span class='account_id'>#{text}</span>"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def make_history_lines issue
|
|
183
|
+
history = issue.changes.reverse
|
|
184
|
+
lines = []
|
|
185
|
+
|
|
186
|
+
id = next_id
|
|
187
|
+
lines << [
|
|
188
|
+
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
|
|
189
|
+
"<span id='open#{id}'>▶ Issue History</span>" \
|
|
190
|
+
"<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
|
|
191
|
+
]
|
|
192
|
+
table = +''
|
|
193
|
+
table << "<table id='table#{id}' style='display: none'>"
|
|
194
|
+
history.each do |c|
|
|
195
|
+
time = c.time.strftime '%b %d, %I:%M%P'
|
|
196
|
+
|
|
197
|
+
table << '<tr>'
|
|
198
|
+
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
199
|
+
table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
|
|
200
|
+
text = history_text change: c, board: issue.board
|
|
201
|
+
table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
|
|
202
|
+
table << '</tr>'
|
|
203
|
+
end
|
|
204
|
+
table << '</table>'
|
|
205
|
+
lines << [table]
|
|
206
|
+
lines
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def history_text change:, board:
|
|
210
|
+
if change.comment?
|
|
211
|
+
jira_rich_text_to_html(change.value)
|
|
212
|
+
elsif change.status?
|
|
213
|
+
convertor = ->(id) { format_status(board.possible_statuses.find_by_id(id), board: board) }
|
|
214
|
+
to = convertor.call(change.value_id)
|
|
215
|
+
if change.old_value
|
|
216
|
+
from = convertor.call(change.old_value_id)
|
|
217
|
+
"Changed from #{from} to #{to}"
|
|
218
|
+
else
|
|
219
|
+
"Set to #{to}"
|
|
220
|
+
end
|
|
221
|
+
elsif %w[priority assignee duedate issuetype].include?(change.field)
|
|
222
|
+
"Changed from \"#{change.old_value}\" to \"#{change.value}\""
|
|
223
|
+
elsif change.flagged?
|
|
224
|
+
change.value == '' ? 'Off' : 'On'
|
|
225
|
+
else
|
|
226
|
+
change.value
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def make_sprints_lines issue
|
|
231
|
+
return [] unless issue.board.scrum?
|
|
232
|
+
|
|
233
|
+
sprint_names = issue.sprints.collect do |sprint|
|
|
234
|
+
if sprint.closed?
|
|
235
|
+
"<s>#{sprint.name}</s>"
|
|
236
|
+
else
|
|
237
|
+
sprint.name
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
return [['Sprints: NONE']] if sprint_names.empty?
|
|
242
|
+
|
|
243
|
+
[[+'Sprints: ' << sprint_names
|
|
244
|
+
.collect { |name| "<span class='label'>#{name}</span>" }
|
|
245
|
+
.join(' ')]]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def assemble_issue_lines issue, child:
|
|
249
|
+
lines = []
|
|
250
|
+
lines << [make_title_line(issue)]
|
|
251
|
+
lines += make_parent_lines(issue) unless child
|
|
252
|
+
lines += make_stats_lines(issue)
|
|
253
|
+
lines += make_sprints_lines(issue)
|
|
254
|
+
lines += make_blocked_stalled_lines(issue)
|
|
255
|
+
lines += make_child_lines(issue)
|
|
256
|
+
lines += make_history_lines(issue)
|
|
257
|
+
lines
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_issue issue, child:
|
|
261
|
+
css_class = child ? 'child_issue' : 'daily_issue'
|
|
262
|
+
result = +''
|
|
263
|
+
result << "<div class='#{css_class}'>"
|
|
264
|
+
assemble_issue_lines(issue, child: child).each do |row|
|
|
265
|
+
if row.is_a? Issue
|
|
266
|
+
result << render_issue(row, child: true)
|
|
267
|
+
else
|
|
268
|
+
result << '<div class="heading">'
|
|
269
|
+
row.each do |chunk|
|
|
270
|
+
result << "<div>#{chunk}</div>"
|
|
271
|
+
end
|
|
272
|
+
result << '</div>'
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
result << '</div>'
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -272,7 +272,7 @@ class DataQualityReport < ChartBase
|
|
|
272
272
|
|
|
273
273
|
entry.report(
|
|
274
274
|
problem_key: :items_blocked_on_closed_tickets,
|
|
275
|
-
detail: "#{entry.issue.key} thinks it's blocked
|
|
275
|
+
detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
|
|
276
276
|
"except #{link.other_issue.key} is closed."
|
|
277
277
|
)
|
|
278
278
|
end
|
|
@@ -45,6 +45,7 @@ class Downloader
|
|
|
45
45
|
board = download_board_configuration board_id: id
|
|
46
46
|
download_issues board: board
|
|
47
47
|
end
|
|
48
|
+
download_users
|
|
48
49
|
|
|
49
50
|
save_metadata
|
|
50
51
|
end
|
|
@@ -103,8 +104,6 @@ class Downloader
|
|
|
103
104
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
105
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
106
|
|
|
106
|
-
exit_if_call_failed json
|
|
107
|
-
|
|
108
107
|
json['issues'].each do |issue_json|
|
|
109
108
|
issue_json['exporter'] = {
|
|
110
109
|
'in_initial_query' => initial_query
|
|
@@ -139,15 +138,6 @@ class Downloader
|
|
|
139
138
|
end
|
|
140
139
|
end
|
|
141
140
|
|
|
142
|
-
def exit_if_call_failed json
|
|
143
|
-
# Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
|
|
144
|
-
return unless json['error'] || json['errorMessages'] || json['errorMessage']
|
|
145
|
-
|
|
146
|
-
log "Download failed. See #{@file_system.logfile_name} for details.", both: true
|
|
147
|
-
log " #{JSON.pretty_generate(json)}"
|
|
148
|
-
exit 1
|
|
149
|
-
end
|
|
150
|
-
|
|
151
141
|
def download_statuses
|
|
152
142
|
log ' Downloading all statuses', both: true
|
|
153
143
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
|
@@ -158,6 +148,16 @@ class Downloader
|
|
|
158
148
|
)
|
|
159
149
|
end
|
|
160
150
|
|
|
151
|
+
def download_users
|
|
152
|
+
log ' Downloading all users', both: true
|
|
153
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
154
|
+
|
|
155
|
+
@file_system.save_json(
|
|
156
|
+
json: json,
|
|
157
|
+
filename: File.join(@target_path, "#{file_prefix}_users.json")
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
161
|
def update_status_history_file
|
|
162
162
|
status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
163
163
|
return unless file_system.file_exist? status_filename
|
|
@@ -188,8 +188,6 @@ class Downloader
|
|
|
188
188
|
log " Downloading board configuration for board #{board_id}", both: true
|
|
189
189
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
190
190
|
|
|
191
|
-
exit_if_call_failed json
|
|
192
|
-
|
|
193
191
|
@file_system.save_json(
|
|
194
192
|
json: json,
|
|
195
193
|
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
@@ -213,7 +211,6 @@ class Downloader
|
|
|
213
211
|
while is_last == false
|
|
214
212
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
215
213
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
216
|
-
exit_if_call_failed json
|
|
217
214
|
|
|
218
215
|
@file_system.save_json(
|
|
219
216
|
json: json,
|