jirametrics 2.10 → 2.13
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/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +56 -13
- data/lib/jirametrics/atlassian_document_format.rb +156 -0
- 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 +38 -16
- data/lib/jirametrics/chart_base.rb +7 -5
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/daily_view.rb +274 -0
- data/lib/jirametrics/downloader.rb +61 -21
- 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 +2 -2
- data/lib/jirametrics/file_config.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- 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/index.css +82 -2
- data/lib/jirametrics/html/index.erb +25 -1
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +69 -28
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +8 -1
- data/lib/jirametrics/project_config.rb +24 -7
- 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 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +5 -0
- metadata +8 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class BoardMovementCalculator
|
|
4
|
+
attr_reader :board, :issues, :today
|
|
5
|
+
|
|
6
|
+
def initialize board:, issues:, today:
|
|
7
|
+
@board = board
|
|
8
|
+
@issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
|
|
9
|
+
@today = today
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def moves_backwards? issue
|
|
13
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
14
|
+
return false unless started
|
|
15
|
+
|
|
16
|
+
previous_column = nil
|
|
17
|
+
issue.status_changes.each do |change|
|
|
18
|
+
column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
|
|
19
|
+
next if change.time < started
|
|
20
|
+
next if column.nil? # It disappeared from the board for a bit
|
|
21
|
+
return true if previous_column && column && column < previous_column
|
|
22
|
+
|
|
23
|
+
previous_column = column
|
|
24
|
+
end
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stacked_age_data_for percentages:
|
|
29
|
+
data_list = percentages.sort.collect do |percentage|
|
|
30
|
+
[percentage, age_data_for(percentage: percentage)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
stack_data data_list
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stack_data data_list
|
|
37
|
+
remainder = nil
|
|
38
|
+
data_list.collect do |percentage, data|
|
|
39
|
+
unless remainder.nil?
|
|
40
|
+
data = (0...data.length).collect do |i|
|
|
41
|
+
data[i] - remainder[i]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
end
|
|
45
|
+
remainder = data
|
|
46
|
+
|
|
47
|
+
[percentage, data]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def age_data_for percentage:
|
|
52
|
+
data = []
|
|
53
|
+
board.visible_columns.each_with_index do |_column, column_index|
|
|
54
|
+
ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
|
|
55
|
+
|
|
56
|
+
if ages.empty?
|
|
57
|
+
data << 0
|
|
58
|
+
else
|
|
59
|
+
index = ((ages.size - 1) * percentage / 100).to_i
|
|
60
|
+
data << ages[index]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
data
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ages_of_issues_when_leaving_column column_index:, today:
|
|
67
|
+
this_column = board.visible_columns[column_index]
|
|
68
|
+
next_column = board.visible_columns[column_index + 1]
|
|
69
|
+
|
|
70
|
+
@issues.filter_map do |issue|
|
|
71
|
+
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
|
72
|
+
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
|
73
|
+
issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
|
|
74
|
+
|
|
75
|
+
# Skip if we can't tell when it started.
|
|
76
|
+
next if issue_start.nil?
|
|
77
|
+
|
|
78
|
+
# Skip if it never entered this column
|
|
79
|
+
next if this_column_start.nil?
|
|
80
|
+
|
|
81
|
+
# Skip if it left this column before the item is considered started.
|
|
82
|
+
next 0 if next_column_start && next_column_start <= issue_start
|
|
83
|
+
|
|
84
|
+
# Skip if it was already done by the time it got to this column or it became done when it got to this column
|
|
85
|
+
next if issue_done && issue_done <= this_column_start
|
|
86
|
+
|
|
87
|
+
end_date = case # rubocop:disable Style/EmptyCaseCondition
|
|
88
|
+
when next_column_start.nil?
|
|
89
|
+
# If this is the last column then base age against today
|
|
90
|
+
today
|
|
91
|
+
when issue_done && issue_done < next_column_start
|
|
92
|
+
# it completed while in this column
|
|
93
|
+
issue_done.to_date
|
|
94
|
+
else
|
|
95
|
+
# It passed through this whole column
|
|
96
|
+
next_column_start.to_date
|
|
97
|
+
end
|
|
98
|
+
(end_date - issue_start.to_date).to_i + 1
|
|
99
|
+
end.sort
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
|
103
|
+
# aging and forecasting purposes
|
|
104
|
+
def find_current_column_and_entry_time_in_column issue
|
|
105
|
+
column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
106
|
+
return [] if column.nil? # This issue isn't visible on the board
|
|
107
|
+
|
|
108
|
+
status_ids = column.status_ids
|
|
109
|
+
|
|
110
|
+
entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
|
|
111
|
+
|
|
112
|
+
[column.name, entry_at]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def label_days days
|
|
116
|
+
"#{days} day#{'s' unless days == 1}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def forecasted_days_remaining_and_message issue:, today:
|
|
120
|
+
return [nil, 'Already done'] if issue.done?
|
|
121
|
+
|
|
122
|
+
likely_age_data = age_data_for percentage: 85
|
|
123
|
+
|
|
124
|
+
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
|
125
|
+
return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
|
|
126
|
+
|
|
127
|
+
# This condition has been reported in production so we have a check for it. Having said that, we have no
|
|
128
|
+
# idea what conditions might make this possible and so there is no test for it.
|
|
129
|
+
if entry_time.nil?
|
|
130
|
+
message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
|
|
131
|
+
'predict when it will be done'
|
|
132
|
+
return [nil, message]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
age_in_column = (today - entry_time.to_date).to_i + 1
|
|
136
|
+
|
|
137
|
+
message = nil
|
|
138
|
+
column_index = board.visible_columns.index { |c| c.name == column_name }
|
|
139
|
+
|
|
140
|
+
last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
|
|
141
|
+
return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
|
|
142
|
+
|
|
143
|
+
remaining_in_current_column = likely_age_data[column_index] - age_in_column
|
|
144
|
+
if remaining_in_current_column.negative?
|
|
145
|
+
message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
|
|
146
|
+
"in the #{column_name.inspect} column. Most items on this board have left this column in " \
|
|
147
|
+
"#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
|
|
148
|
+
remaining_in_current_column = 0
|
|
149
|
+
return [nil, message]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
|
|
153
|
+
[forecasted_days, message]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -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,43 @@ 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 description? = (field == 'description')
|
|
34
|
+
def due_date? = (field == 'duedate')
|
|
35
|
+
def flagged? = (field == 'Flagged')
|
|
36
|
+
def issue_type? = field == 'issuetype'
|
|
37
|
+
def labels? = (field == 'labels')
|
|
38
|
+
def link? = (field == 'Link')
|
|
26
39
|
def priority? = (field == 'priority')
|
|
27
|
-
|
|
28
40
|
def resolution? = (field == 'resolution')
|
|
29
|
-
|
|
30
|
-
def artificial? = @artificial
|
|
31
|
-
|
|
32
41
|
def sprint? = (field == 'Sprint')
|
|
33
|
-
|
|
34
|
-
def story_points? = (field == 'Story Points')
|
|
35
|
-
|
|
36
|
-
def link? = (field == 'Link')
|
|
37
|
-
|
|
38
|
-
def labels? = (field == 'labels')
|
|
42
|
+
def status? = (field == 'status')
|
|
39
43
|
|
|
40
44
|
# An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
|
|
41
45
|
def to_time = @time
|
|
42
46
|
|
|
43
47
|
def to_s
|
|
44
48
|
message = +''
|
|
45
|
-
message << "ChangeItem(field: #{field.inspect}
|
|
49
|
+
message << "ChangeItem(field: #{field.inspect}"
|
|
50
|
+
message << ", value: #{value.inspect}"
|
|
51
|
+
message << ':' << value_id.inspect if status?
|
|
52
|
+
if old_value
|
|
53
|
+
message << ", old_value: #{old_value.inspect}"
|
|
54
|
+
message << ':' << old_value_id.inspect if status?
|
|
55
|
+
end
|
|
56
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
|
46
57
|
message << ', artificial' if artificial?
|
|
47
58
|
message << ')'
|
|
48
59
|
message
|
|
@@ -84,6 +95,17 @@ class ChangeItem
|
|
|
84
95
|
end
|
|
85
96
|
end
|
|
86
97
|
|
|
98
|
+
def field_as_human_readable
|
|
99
|
+
case @field
|
|
100
|
+
when 'duedate' then 'Due date'
|
|
101
|
+
when 'timeestimate' then 'Time estimate'
|
|
102
|
+
when 'timeoriginalestimate' then 'Time original estimate'
|
|
103
|
+
when 'issuetype' then 'Issue type'
|
|
104
|
+
when 'IssueParentAssociation' then 'Issue parent association'
|
|
105
|
+
else @field.capitalize
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
87
109
|
private
|
|
88
110
|
|
|
89
111
|
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
|
|
@@ -227,8 +226,8 @@ class ChartBase
|
|
|
227
226
|
icon: ' 👀'
|
|
228
227
|
)
|
|
229
228
|
end
|
|
230
|
-
text = is_category ? status.category
|
|
231
|
-
"<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}"
|
|
232
231
|
end
|
|
233
232
|
|
|
234
233
|
def icon_span title:, icon:
|
|
@@ -260,7 +259,10 @@ class ChartBase
|
|
|
260
259
|
|
|
261
260
|
def color_block color, title: nil
|
|
262
261
|
result = +''
|
|
263
|
-
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 << "'"
|
|
264
266
|
result << " title=#{title.inspect}" if title
|
|
265
267
|
result << '></div>'
|
|
266
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
|
|
@@ -0,0 +1,274 @@
|
|
|
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 atlassian_document_format
|
|
37
|
+
@atlassian_document_format ||= AtlassianDocumentFormat.new(users: users, timezone_offset: timezone_offset)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select_aging_issues
|
|
41
|
+
aging_issues = issues.select do |issue|
|
|
42
|
+
started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
|
|
43
|
+
started_at && !stopped_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
today = date_range.end
|
|
47
|
+
aging_issues.collect do |issue|
|
|
48
|
+
[issue, issue.priority_name, issue.board.cycletime.age(issue, today: today)]
|
|
49
|
+
end.sort(&issue_sorter).collect(&:first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def issue_sorter
|
|
53
|
+
priority_names = settings['priority_order']
|
|
54
|
+
lambda do |a, b|
|
|
55
|
+
a_issue, a_priority, a_age = *a
|
|
56
|
+
b_issue, b_priority, b_age = *b
|
|
57
|
+
|
|
58
|
+
a_priority_index = priority_names.index(a_priority)
|
|
59
|
+
b_priority_index = priority_names.index(b_priority)
|
|
60
|
+
|
|
61
|
+
if a_priority_index.nil? && b_priority_index.nil?
|
|
62
|
+
result = a_priority <=> b_priority
|
|
63
|
+
elsif a_priority_index.nil?
|
|
64
|
+
result = 1
|
|
65
|
+
elsif b_priority_index.nil?
|
|
66
|
+
result = -1
|
|
67
|
+
else
|
|
68
|
+
result = b_priority_index <=> a_priority_index
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
result = b_age <=> a_age if result.zero?
|
|
72
|
+
result = a_issue <=> b_issue if result.zero?
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def make_blocked_stalled_lines issue
|
|
78
|
+
today = date_range.end
|
|
79
|
+
started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
|
|
80
|
+
return [] unless started_date
|
|
81
|
+
|
|
82
|
+
blocked_stalled = issue.blocked_stalled_by_date(
|
|
83
|
+
date_range: today..today, chart_end_time: time_range.end, settings: settings
|
|
84
|
+
)[today]
|
|
85
|
+
return [] unless blocked_stalled
|
|
86
|
+
|
|
87
|
+
lines = []
|
|
88
|
+
if blocked_stalled.blocked?
|
|
89
|
+
marker = color_block '--blocked-color'
|
|
90
|
+
lines << ["#{marker} Blocked by flag"] if blocked_stalled.flag
|
|
91
|
+
lines << ["#{marker} Blocked by status: #{blocked_stalled.status}"] if blocked_stalled.blocked_by_status?
|
|
92
|
+
blocked_stalled.blocking_issue_keys&.each do |key|
|
|
93
|
+
lines << ["#{marker} Blocked by issue: #{key}"]
|
|
94
|
+
blocking_issue = issues.find { |i| i.key == key }
|
|
95
|
+
lines << blocking_issue if blocking_issue
|
|
96
|
+
end
|
|
97
|
+
elsif blocked_stalled.stalled_by_status?
|
|
98
|
+
lines << ["#{color_block '--stalled-color'} Stalled by status: #{blocked_stalled.status}"]
|
|
99
|
+
else
|
|
100
|
+
lines << ["#{color_block '--stalled-color'} Stalled by inactivity: #{blocked_stalled.stalled_days} days"]
|
|
101
|
+
end
|
|
102
|
+
lines
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def make_issue_label issue
|
|
106
|
+
"<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> " \
|
|
107
|
+
"<b><a href='#{issue.url}'>#{issue.key}</a></b> <i>#{issue.summary}</i>"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def make_title_line issue
|
|
111
|
+
title_line = +''
|
|
112
|
+
title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
|
|
113
|
+
title_line << make_issue_label(issue)
|
|
114
|
+
title_line
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def make_parent_lines issue
|
|
118
|
+
lines = []
|
|
119
|
+
parent_key = issue.parent_key
|
|
120
|
+
if parent_key
|
|
121
|
+
parent = issues.find_by_key key: parent_key, include_hidden: true
|
|
122
|
+
text = parent ? make_issue_label(parent) : parent_key
|
|
123
|
+
lines << ["Parent: #{text}"]
|
|
124
|
+
end
|
|
125
|
+
lines
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def make_stats_lines issue
|
|
129
|
+
line = []
|
|
130
|
+
|
|
131
|
+
line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
|
|
132
|
+
|
|
133
|
+
age = issue.board.cycletime.age(issue, today: date_range.end)
|
|
134
|
+
line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
|
|
135
|
+
|
|
136
|
+
line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
|
|
137
|
+
|
|
138
|
+
column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
|
139
|
+
line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
|
|
140
|
+
|
|
141
|
+
if issue.assigned_to
|
|
142
|
+
line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
|
|
146
|
+
|
|
147
|
+
block = lambda do |collection, label|
|
|
148
|
+
unless collection.empty?
|
|
149
|
+
text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
|
|
150
|
+
line << "#{label} #{text}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
block.call issue.labels, 'Labels:'
|
|
154
|
+
block.call issue.component_names, 'Components:'
|
|
155
|
+
|
|
156
|
+
[line]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def make_child_lines issue
|
|
160
|
+
lines = []
|
|
161
|
+
subtasks = issue.subtasks.reject { |i| i.done? }
|
|
162
|
+
|
|
163
|
+
unless subtasks.empty?
|
|
164
|
+
icon_urls = subtasks.collect(&:type_icon_url).uniq.collect { |url| "<img src='#{url}' class='icon' />" }
|
|
165
|
+
lines << (icon_urls << 'Incomplete child issues')
|
|
166
|
+
lines += subtasks
|
|
167
|
+
end
|
|
168
|
+
lines
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def make_history_lines issue
|
|
172
|
+
history = issue.changes.reverse
|
|
173
|
+
lines = []
|
|
174
|
+
|
|
175
|
+
id = next_id
|
|
176
|
+
lines << [
|
|
177
|
+
"<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
|
|
178
|
+
"<span id='open#{id}'>▶ Issue History</span>" \
|
|
179
|
+
"<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
|
|
180
|
+
]
|
|
181
|
+
table = +''
|
|
182
|
+
table << "<table id='table#{id}' style='display: none'>"
|
|
183
|
+
history.each do |c|
|
|
184
|
+
time = c.time.strftime '%b %d, %I:%M%P'
|
|
185
|
+
|
|
186
|
+
table << '<tr>'
|
|
187
|
+
table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
|
|
188
|
+
table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
|
|
189
|
+
text = history_text change: c, board: issue.board
|
|
190
|
+
table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
|
|
191
|
+
table << '</tr>'
|
|
192
|
+
end
|
|
193
|
+
table << '</table>'
|
|
194
|
+
lines << [table]
|
|
195
|
+
lines
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def history_text change:, board:
|
|
199
|
+
if change.comment? || change.description?
|
|
200
|
+
atlassian_document_format.to_html(change.value)
|
|
201
|
+
elsif change.status?
|
|
202
|
+
convertor = ->(id) { format_status(board.possible_statuses.find_by_id(id), board: board) }
|
|
203
|
+
to = convertor.call(change.value_id)
|
|
204
|
+
if change.old_value
|
|
205
|
+
from = convertor.call(change.old_value_id)
|
|
206
|
+
"Changed from #{from} to #{to}"
|
|
207
|
+
else
|
|
208
|
+
"Set to #{to}"
|
|
209
|
+
end
|
|
210
|
+
elsif %w[priority assignee duedate issuetype].include?(change.field)
|
|
211
|
+
"Changed from \"#{change.old_value}\" to \"#{change.value}\""
|
|
212
|
+
elsif change.flagged?
|
|
213
|
+
change.value == '' ? 'Off' : 'On'
|
|
214
|
+
else
|
|
215
|
+
change.value
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def make_sprints_lines issue
|
|
220
|
+
return [] unless issue.board.scrum?
|
|
221
|
+
|
|
222
|
+
sprint_names = issue.sprints.collect do |sprint|
|
|
223
|
+
if sprint.closed?
|
|
224
|
+
"<s>#{sprint.name}</s>"
|
|
225
|
+
else
|
|
226
|
+
sprint.name
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
return [['Sprints: NONE']] if sprint_names.empty?
|
|
231
|
+
|
|
232
|
+
[[+'Sprints: ' << sprint_names
|
|
233
|
+
.collect { |name| "<span class='label'>#{name}</span>" }
|
|
234
|
+
.join(' ')]]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def make_description_lines issue
|
|
238
|
+
description = issue.raw['fields']['description']
|
|
239
|
+
result = []
|
|
240
|
+
result << [atlassian_document_format.to_html(description)] if description
|
|
241
|
+
result
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def assemble_issue_lines issue, child:
|
|
245
|
+
lines = []
|
|
246
|
+
lines << [make_title_line(issue)]
|
|
247
|
+
lines += make_parent_lines(issue) unless child
|
|
248
|
+
lines += make_stats_lines(issue)
|
|
249
|
+
lines += make_description_lines(issue)
|
|
250
|
+
lines += make_sprints_lines(issue)
|
|
251
|
+
lines += make_blocked_stalled_lines(issue)
|
|
252
|
+
lines += make_child_lines(issue)
|
|
253
|
+
lines += make_history_lines(issue)
|
|
254
|
+
lines
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def render_issue issue, child:
|
|
258
|
+
css_class = child ? 'child_issue' : 'daily_issue'
|
|
259
|
+
result = +''
|
|
260
|
+
result << "<div class='#{css_class}'>"
|
|
261
|
+
assemble_issue_lines(issue, child: child).each do |row|
|
|
262
|
+
if row.is_a? Issue
|
|
263
|
+
result << render_issue(row, child: true)
|
|
264
|
+
else
|
|
265
|
+
result << '<div class="heading">'
|
|
266
|
+
row.each do |chunk|
|
|
267
|
+
result << "<div>#{chunk}</div>"
|
|
268
|
+
end
|
|
269
|
+
result << '</div>'
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
result << '</div>'
|
|
273
|
+
end
|
|
274
|
+
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
|
|
@@ -96,30 +97,59 @@ class Downloader
|
|
|
96
97
|
log " JQL: #{jql}"
|
|
97
98
|
escaped_jql = CGI.escape jql
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
|
-
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
|
-
|
|
106
|
-
json['issues'].each do |issue_json|
|
|
107
|
-
issue_json['exporter'] = {
|
|
108
|
-
'in_initial_query' => initial_query
|
|
109
|
-
}
|
|
110
|
-
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
111
|
-
file = "#{issue_json['key']}-#{board.id}.json"
|
|
112
|
-
|
|
113
|
-
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
114
|
-
end
|
|
100
|
+
if @jira_gateway.cloud?
|
|
101
|
+
max_results = 5_000 # The maximum allowed by Jira
|
|
102
|
+
next_page_token = nil
|
|
103
|
+
issue_count = 0
|
|
115
104
|
|
|
116
|
-
|
|
117
|
-
|
|
105
|
+
loop do
|
|
106
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/3/search/jql' \
|
|
107
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&" \
|
|
108
|
+
"nextPageToken=#{next_page_token}&expand=changelog&fields=*all"
|
|
109
|
+
next_page_token = json['nextPageToken']
|
|
118
110
|
|
|
119
|
-
|
|
120
|
-
|
|
111
|
+
json['issues'].each do |issue_json|
|
|
112
|
+
issue_json['exporter'] = {
|
|
113
|
+
'in_initial_query' => initial_query
|
|
114
|
+
}
|
|
115
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
116
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
121
117
|
|
|
122
|
-
|
|
118
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
119
|
+
issue_count += 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
message = " Downloaded #{issue_count} issues"
|
|
123
|
+
log message, both: true
|
|
124
|
+
|
|
125
|
+
break unless next_page_token
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
max_results = 100
|
|
129
|
+
start_at = 0
|
|
130
|
+
total = 1
|
|
131
|
+
while start_at < total
|
|
132
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
133
|
+
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
134
|
+
|
|
135
|
+
json['issues'].each do |issue_json|
|
|
136
|
+
issue_json['exporter'] = {
|
|
137
|
+
'in_initial_query' => initial_query
|
|
138
|
+
}
|
|
139
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
140
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
141
|
+
|
|
142
|
+
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
total = json['total'].to_i
|
|
146
|
+
max_results = json['maxResults']
|
|
147
|
+
|
|
148
|
+
message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
|
|
149
|
+
log message, both: true
|
|
150
|
+
|
|
151
|
+
start_at += json['issues'].size
|
|
152
|
+
end
|
|
123
153
|
end
|
|
124
154
|
end
|
|
125
155
|
|
|
@@ -147,6 +177,16 @@ class Downloader
|
|
|
147
177
|
)
|
|
148
178
|
end
|
|
149
179
|
|
|
180
|
+
def download_users
|
|
181
|
+
log ' Downloading all users', both: true
|
|
182
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
183
|
+
|
|
184
|
+
@file_system.save_json(
|
|
185
|
+
json: json,
|
|
186
|
+
filename: File.join(@target_path, "#{file_prefix}_users.json")
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
150
190
|
def update_status_history_file
|
|
151
191
|
status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
152
192
|
return unless file_system.file_exist? status_filename
|