jirametrics 2.11 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 670df42835c7d41e30dcde01ead744a132f81313165297d434334e272de795c7
4
- data.tar.gz: 12822842210b4aeddb7f59e6681a9f1bd25060bae0ff98e8490d14e895dea657
3
+ metadata.gz: 7565c5b6320b9c4375a3feb27fcaf80f96d9c058041b71584f287e6a3d7eb399
4
+ data.tar.gz: 4554c7edbc7c42e01ad4838094ab922819c2e71818a46907f25740e35c327aa6
5
5
  SHA512:
6
- metadata.gz: a4f36b8921c94a457710dc68251a9a2ddb38be2f0f7027991cf381fdf1cff28d2e549e508314733a3c753f583fd3c695b7e4979e1bb7be43c45de90ec001cbab
7
- data.tar.gz: 28a2553b31cd4de5fce0c943da38643ad13dbfad3a8bddc77f4764e179394968966bb676e70daa75a4faeb42a67027fb7efb63895b56c01a3db563a029df66aa
6
+ metadata.gz: 021c1f989117b1fbb8708183d9ad8e79b049060989c1f20d31121a486d25f1ae2d1c036e013279479cd3ac6f5b101a48d8c9f4943570107c9cc4b40485ea6a4f
7
+ data.tar.gz: 7f7e334a2161e23ef5ae8a754aa3f12524c1bb130893ca3df3e8574f7a44286ef33c8a03b2ac2790ccb8e8b792ce0fb6c17b1d69d2da068ea48dbce1b4c514a1
@@ -108,20 +108,11 @@ class AgingWorkTable < ChartBase
108
108
  end
109
109
 
110
110
  def sprints_text issue
111
- sprint_ids = []
112
-
113
- issue.changes.each do |change|
114
- next unless change.sprint?
115
-
116
- sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
117
- end
118
- sprint_ids.flatten!
119
-
120
- issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
111
+ issue.sprints.collect do |sprint|
121
112
  icon_text = nil
122
113
  if sprint.active?
123
114
  icon_text = icon_span title: 'Active sprint', icon: '➡️'
124
- else
115
+ elsif sprint.closed?
125
116
  icon_text = icon_span title: 'Sprint closed', icon: '✅'
126
117
  end
127
118
  "#{sprint.name} #{icon_text}"
@@ -181,4 +172,8 @@ class AgingWorkTable < ChartBase
181
172
 
182
173
  result.reverse
183
174
  end
175
+
176
+ def priority_text issue
177
+ "<img src='#{issue.priority_url}' title='Priority: #{issue.priority_name}' />"
178
+ end
184
179
  end
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Board
4
- attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
4
+ attr_reader :visible_columns, :raw, :possible_statuses, :sprints
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
7
  def initialize raw:, possible_statuses:
8
8
  @raw = raw
9
- @board_type = raw['type']
10
9
  @possible_statuses = possible_statuses
11
10
  @sprints = []
12
11
 
@@ -67,13 +66,9 @@ class Board
67
66
  status_ids
68
67
  end
69
68
 
70
- def kanban?
71
- @board_type == 'kanban'
72
- end
73
-
74
- def scrum?
75
- @board_type == 'scrum'
76
- end
69
+ def board_type = raw['type']
70
+ def kanban? = (board_type == 'kanban')
71
+ def scrum? = (board_type == 'scrum')
77
72
 
78
73
  def id
79
74
  @raw['id'].to_i
@@ -117,4 +112,8 @@ class Board
117
112
  all_names << name
118
113
  end
119
114
  end
115
+
116
+ def estimation_configuration
117
+ EstimationConfiguration.new raw: raw['estimation']
118
+ end
120
119
  end
@@ -13,6 +13,7 @@ class BoardConfig
13
13
  @board = @project_config.all_boards[id]
14
14
 
15
15
  instance_eval(&@block)
16
+ raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
16
17
  end
17
18
 
18
19
  def cycletime label = nil, &block
@@ -124,6 +124,14 @@ class BoardMovementCalculator
124
124
  column_name, entry_time = find_current_column_and_entry_time_in_column issue
125
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
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
+
127
135
  age_in_column = (today - entry_time.to_date).to_i + 1
128
136
 
129
137
  message = nil
@@ -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,26 +17,28 @@ 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 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
@@ -91,6 +94,17 @@ class ChangeItem
91
94
  end
92
95
  end
93
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
+
94
108
  private
95
109
 
96
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
@@ -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:
@@ -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,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> &nbsp;<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
@@ -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
@@ -147,6 +148,16 @@ class Downloader
147
148
  )
148
149
  end
149
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
+
150
161
  def update_status_history_file
151
162
  status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
152
163
  return unless file_system.file_exist? status_filename
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
22
22
  </div>
23
23
  HTML
24
24
 
25
- @y_axis_label = 'Story Point Estimates'
26
25
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
26
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
27
  @y_axis_sort_order = nil
29
28
 
30
29
  instance_eval(&configuration_block)
31
30
  end
32
31
 
33
32
  def run
33
+ if @y_axis_label.nil?
34
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
+ @y_axis_label = "Estimated #{text}"
36
+ end
34
37
  data_sets = scan_issues
35
38
 
36
39
  return '' if data_sets.empty?
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
41
44
  def scan_issues
42
45
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
46
 
47
+ estimation_units = current_board.estimation_configuration.units
44
48
  @has_aging_data = !aging_hash.empty?
45
49
 
46
50
  [
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
53
57
  # We sort so that the smaller circles are in front of the bigger circles.
54
58
  data = hash.sort(&hash_sorter).collect do |key, values|
55
59
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
60
+
61
+ title = [
62
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
63
+ "Cycletime: #{label_days(cycle_time)}, " \
64
+ "#{values.size} issues"
65
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
66
+
59
67
  {
60
68
  'x' => cycle_time,
61
69
  'y' => estimate,
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
77
85
  end
78
86
  end
79
87
 
88
+ def estimate_label estimate:, estimation_units:
89
+ if @y_axis_type == 'linear'
90
+ if estimation_units == :story_points
91
+ estimate_label = "#{estimate}pts"
92
+ elsif estimation_units == :seconds
93
+ estimate_label = label_days estimate
94
+ end
95
+ end
96
+ estimate_label = estimate.to_s if estimate_label.nil?
97
+ estimate_label
98
+ end
99
+
80
100
  def split_into_completed_and_aging issues:
81
101
  aging_hash = {}
82
102
  completed_hash = {}
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
126
146
  end
127
147
  end
128
148
 
129
- def story_points_at issue:, start_time:
130
- story_points = nil
149
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
150
+ estimate = nil
151
+
131
152
  issue.changes.each do |change|
132
- return story_points if change.time >= start_time
153
+ return estimate if change.time >= start_time
133
154
 
134
- story_points = change.value if change.story_points?
155
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
156
+ estimate = change.value
157
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
158
+ end
135
159
  end
136
- story_points
160
+ estimate
137
161
  end
138
162
 
139
163
  def y_axis label:, sort_order: nil, &block
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -58,6 +58,8 @@ class Exporter
58
58
  type: :header
59
59
  end
60
60
 
61
+ daily_view
62
+
61
63
  cycletime_scatterplot do
62
64
  show_trend_lines
63
65
  end
@@ -13,7 +13,7 @@ class FileConfig
13
13
  end
14
14
 
15
15
  def run
16
- @issues = project_config.issues.dup
16
+ @issues = project_config.issues
17
17
  instance_eval(&@block)
18
18
 
19
19
  if @columns
@@ -4,6 +4,7 @@
4
4
  <th title="Age in days">Age</th>
5
5
  <th title="Expedited">E</th>
6
6
  <th title="Blocked / Stalled">B/S</th>
7
+ <th title="Priority">P</th>
7
8
  <th>Issue</th>
8
9
  <th>Status</th>
9
10
  <th>Forecast</th>
@@ -29,6 +30,7 @@
29
30
  <td style="text-align: right;"><%= issue_age || 'Not started' %></td>
30
31
  <td><%= expedited_text(issue) %></td>
31
32
  <td><%= blocked_text(issue) %></td>
33
+ <td><%= priority_text(issue) %></td>
32
34
  <td>
33
35
  <% parent_hierarchy(issue).each_with_index do |parent, index| %>
34
36
  <% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>