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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  3. data/lib/jirametrics/aging_work_table.rb +56 -13
  4. data/lib/jirametrics/atlassian_document_format.rb +156 -0
  5. data/lib/jirametrics/board.rb +38 -10
  6. data/lib/jirametrics/board_config.rb +1 -0
  7. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  8. data/lib/jirametrics/change_item.rb +38 -16
  9. data/lib/jirametrics/chart_base.rb +7 -5
  10. data/lib/jirametrics/css_variable.rb +1 -1
  11. data/lib/jirametrics/cycletime_config.rb +1 -1
  12. data/lib/jirametrics/daily_view.rb +274 -0
  13. data/lib/jirametrics/downloader.rb +61 -21
  14. data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
  15. data/lib/jirametrics/estimation_configuration.rb +25 -0
  16. data/lib/jirametrics/examples/standard_project.rb +2 -0
  17. data/lib/jirametrics/exporter.rb +2 -2
  18. data/lib/jirametrics/file_config.rb +1 -1
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  21. data/lib/jirametrics/html/aging_work_table.erb +7 -3
  22. data/lib/jirametrics/html/index.css +82 -2
  23. data/lib/jirametrics/html/index.erb +25 -1
  24. data/lib/jirametrics/html_report_config.rb +2 -0
  25. data/lib/jirametrics/issue.rb +69 -28
  26. data/lib/jirametrics/issue_collection.rb +33 -0
  27. data/lib/jirametrics/jira_gateway.rb +8 -1
  28. data/lib/jirametrics/project_config.rb +24 -7
  29. data/lib/jirametrics/settings.json +2 -1
  30. data/lib/jirametrics/sprint.rb +1 -0
  31. data/lib/jirametrics/sprint_burndown.rb +35 -33
  32. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  33. data/lib/jirametrics/status.rb +3 -0
  34. data/lib/jirametrics/status_collection.rb +7 -0
  35. data/lib/jirametrics/user.rb +12 -0
  36. data/lib/jirametrics.rb +5 -0
  37. 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, :author, :time
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:, time:, author:, artificial: false
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 status? = (field == 'status')
22
+ def author
23
+ @author_raw&.[]('displayName') || @author_raw&.[]('name') || 'Unknown author'
24
+ end
23
25
 
24
- def flagged? = (field == 'Flagged')
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}, value: #{value.inspect}, time: #{time_to_s(@time).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.name : status.name
231
- "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
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='background: var(#{color});'"
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
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -59,7 +59,7 @@ class CycleTimeConfig
59
59
  'from' => '0',
60
60
  'fromString' => ''
61
61
  }
62
- ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
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> &nbsp;<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
- max_results = 100
100
- start_at = 0
101
- total = 1
102
- while start_at < total
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
- total = json['total'].to_i
117
- max_results = json['maxResults']
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
- message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
120
- log message, both: true
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
- start_at += json['issues'].size
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