jirametrics 2.11 → 2.14

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: d5b1b9e8d837f6d74990d377db007ed5e55670de77738ab38a04c6d023d865c3
4
+ data.tar.gz: 99e8ef3e85a3dfa2bd6d845a32d974718c0e9884d2f4750891ba491fd884ab0b
5
5
  SHA512:
6
- metadata.gz: a4f36b8921c94a457710dc68251a9a2ddb38be2f0f7027991cf381fdf1cff28d2e549e508314733a3c753f583fd3c695b7e4979e1bb7be43c45de90ec001cbab
7
- data.tar.gz: 28a2553b31cd4de5fce0c943da38643ad13dbfad3a8bddc77f4764e179394968966bb676e70daa75a4faeb42a67027fb7efb63895b56c01a3db563a029df66aa
6
+ metadata.gz: 804a25c8a19df9ae9862e2f95539183ece53e3841e590c64b8deb7048601bfb2b42ad5b75c075b85058abb7cea0cc875d517f4208bd5edbf98e2129e5567af59
7
+ data.tar.gz: '0059cde9746423c5baf3ca1f47243b6489ccf77b8fd409255f7301f638eb1975b8b2815d266f720cd9f1cd51e2cf0bc9e33cbad3e34110ca1dedc4ad7fc9ff5b'
@@ -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
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AtlassianDocumentFormat
4
+ attr_reader :users
5
+
6
+ def initialize users:, timezone_offset:
7
+ @users = users
8
+ @timezone_offset = timezone_offset
9
+ end
10
+
11
+ def to_html input
12
+ if input.is_a? String
13
+ input
14
+ .gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
15
+ .gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
16
+ .gsub(/\[([^\|]+)\|(https?[^\]]+)\]/, '<a href="\2">\1</a>') # URLs
17
+ .gsub("\n", '<br />')
18
+ elsif input['content']
19
+ input['content'].collect { |element| adf_node_to_html element }.join("\n")
20
+ else
21
+ # We have an actual ADF document with no content.
22
+ ''
23
+ end
24
+ end
25
+
26
+ # ADF is Atlassian Document Format
27
+ # https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
28
+ def adf_node_to_html node # rubocop:disable Metrics/CyclomaticComplexity
29
+ closing_tag = nil
30
+ node_attrs = node['attrs']
31
+
32
+ result = +''
33
+ case node['type']
34
+ when 'blockquote'
35
+ result << '<blockquote>'
36
+ closing_tag = '</blockquote>'
37
+ when 'bulletList'
38
+ result << '<ul>'
39
+ closing_tag = '</ul>'
40
+ when 'codeBlock'
41
+ result << '<code>'
42
+ closing_tag = '</code>'
43
+ when 'date'
44
+ result << Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s
45
+ when 'decisionItem'
46
+ result << '<li>'
47
+ closing_tag = '</li>'
48
+ when 'decisionList'
49
+ result << '<div>Decisions<ul>'
50
+ closing_tag = '</ul></div>'
51
+ when 'emoji'
52
+ result << node_attrs['text']
53
+ when 'expand'
54
+ # TODO: Maybe, someday, make this actually expandable. For now it's always open
55
+ result << "<div>#{node_attrs['title']}</div>"
56
+ when 'hardBreak'
57
+ result << '<br />'
58
+ when 'heading'
59
+ level = node_attrs['level']
60
+ result << "<h#{level}>"
61
+ closing_tag = "</h#{level}>"
62
+ when 'inlineCard'
63
+ url = node_attrs['url']
64
+ result << "[Inline card]: <a href='#{url}'>#{url}</a>"
65
+ when 'listItem'
66
+ result << '<li>'
67
+ closing_tag = '</li>'
68
+ when 'media'
69
+ text = node_attrs['alt'] || node_attrs['id']
70
+ result << "Media: #{text}"
71
+ when 'mediaSingle', 'mediaGroup'
72
+ result << '<div>'
73
+ closing_tag = '</div>'
74
+ when 'mention'
75
+ user = node_attrs['text']
76
+ result << "<b>#{user}</b>"
77
+ when 'orderedList'
78
+ result << '<ol>'
79
+ closing_tag = '</ol>'
80
+ when 'panel'
81
+ type = node_attrs['panelType']
82
+ result << "<div>#{type.upcase}</div>"
83
+ when 'paragraph'
84
+ result << '<p>'
85
+ closing_tag = '</p>'
86
+ when 'rule'
87
+ result << '<hr />'
88
+ when 'status'
89
+ text = node_attrs['text']
90
+ result << text
91
+ when 'table'
92
+ result << '<table>'
93
+ closing_tag = '</table>'
94
+ when 'tableCell'
95
+ result << '<td>'
96
+ closing_tag = '</td>'
97
+ when 'tableHeader'
98
+ result << '<th>'
99
+ closing_tag = '</th>'
100
+ when 'tableRow'
101
+ result << '<tr>'
102
+ closing_tag = '</tr>'
103
+ when 'text'
104
+ marks = adf_marks_to_html node['marks']
105
+ result << marks.collect(&:first).join
106
+ result << node['text']
107
+ result << marks.collect(&:last).join
108
+ when 'taskItem'
109
+ state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
110
+ result << "<li>#{state} "
111
+ closing_tag = '</li>'
112
+ when 'taskList'
113
+ result << "<ul class='taskList'>"
114
+ closing_tag = '</ul>'
115
+ else
116
+ result << "<p>Unparseable section: #{node['type']}</p>"
117
+ end
118
+
119
+ node['content']&.each do |child|
120
+ result << adf_node_to_html(child)
121
+ end
122
+
123
+ result << closing_tag if closing_tag
124
+ result
125
+ end
126
+
127
+ def adf_marks_to_html list
128
+ return [] if list.nil?
129
+
130
+ mappings = [
131
+ ['strong', '<b>', '</b>'],
132
+ ['code', '<code>', '</code>'],
133
+ ['em', '<em>', '</em>'],
134
+ ['strike', '<s>', '</s>'],
135
+ ['underline', '<u>', '</u>']
136
+ ]
137
+
138
+ list.filter_map do |mark|
139
+ type = mark['type']
140
+ if type == 'textColor'
141
+ color = mark['attrs']['color']
142
+ ["<span style='color: #{color}'>", '</span>']
143
+ elsif type == 'link'
144
+ href = mark['attrs']['href']
145
+ title = mark['attrs']['title']
146
+ ["<a href='#{href}' title='#{title}'>", '</a>']
147
+ else
148
+ line = mappings.find { |key, _open, _close| key == type }
149
+ [line[1], line[2]] if line
150
+ end
151
+ end
152
+ end
153
+
154
+ def expand_account_id account_id
155
+ user = @users.find { |u| u.account_id == account_id }
156
+ text = account_id
157
+ text = "@#{user.display_name}" if user
158
+ "<span class='account_id'>#{text}</span>"
159
+ end
160
+ 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
@@ -11,8 +11,10 @@ class BoardConfig
11
11
 
12
12
  def run
13
13
  @board = @project_config.all_boards[id]
14
+ raise "Can't find board #{id.inspect} in #{@project_config.all_boards.keys.inspect}" unless @board
14
15
 
15
16
  instance_eval(&@block)
17
+ raise "Must specify a cycletime for board #{@id}" if @board.cycletime.nil?
16
18
  end
17
19
 
18
20
  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,29 @@ 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
@@ -91,6 +95,17 @@ class ChangeItem
91
95
  end
92
96
  end
93
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
+
94
109
  private
95
110
 
96
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
@@ -67,6 +66,8 @@ class ChartBase
67
66
  end
68
67
 
69
68
  def label_days days
69
+ return 'unknown' if days.nil?
70
+
70
71
  "#{days} day#{'s' unless days == 1}"
71
72
  end
72
73
 
@@ -227,8 +228,8 @@ class ChartBase
227
228
  icon: ' 👀'
228
229
  )
229
230
  end
230
- text = is_category ? status.category.name : status.name
231
- "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
231
+ text = is_category ? status.category : status
232
+ "<span title='Category: #{status.category}'>#{color_block color.name} #{text}</span>#{visibility}"
232
233
  end
233
234
 
234
235
  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,295 @@
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 [] if blocked_stalled.active?
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:, done:
106
+ label = "<img src='#{issue.type_icon_url}' title='#{issue.type}' class='icon' /> "
107
+ label << '<s>' if done
108
+ label << "<b><a href='#{issue.url}'>#{issue.key}</a></b> &nbsp;<i>#{issue.summary}</i>"
109
+ label << '</s>' if done
110
+ label
111
+ end
112
+
113
+ def make_title_line issue:, done:
114
+ title_line = +''
115
+ title_line << color_block('--expedited-color', title: 'Expedited') if issue.expedited?
116
+ title_line << make_issue_label(issue: issue, done: done)
117
+ title_line
118
+ end
119
+
120
+ def make_parent_lines issue
121
+ lines = []
122
+ parent_key = issue.parent_key
123
+ if parent_key
124
+ parent = issues.find_by_key key: parent_key, include_hidden: true
125
+ text = parent ? make_issue_label(issue: parent, done: parent.done?) : parent_key
126
+ lines << ["Parent: #{text}"]
127
+ end
128
+ lines
129
+ end
130
+
131
+ def make_stats_lines issue:, done:
132
+ line = []
133
+
134
+ line << "<img src='#{issue.priority_url}' class='icon' /> <b>#{issue.priority_name}</b>"
135
+
136
+ if done
137
+ cycletime = issue.board.cycletime.cycletime(issue)
138
+
139
+ line << "Cycletime: <b>#{label_days cycletime}</b>"
140
+ else
141
+ age = issue.board.cycletime.age(issue, today: date_range.end)
142
+ line << "Age: <b>#{age ? label_days(age) : '(Not Started)'}</b>"
143
+ end
144
+ line << "Status: <b>#{format_status issue.status, board: issue.board}</b>"
145
+
146
+ column = issue.board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
147
+ line << "Column: <b>#{column&.name || '(not visible on board)'}</b>"
148
+
149
+ if issue.assigned_to
150
+ line << "Assignee: <img src='#{issue.assigned_to_icon_url}' class='icon' /> <b>#{issue.assigned_to}</b>"
151
+ end
152
+
153
+ line << "Due: <b>#{issue.due_date}</b>" if issue.due_date
154
+
155
+ block = lambda do |collection, label|
156
+ unless collection.empty?
157
+ text = collection.collect { |l| "<span class='label'>#{l}</span>" }.join(' ')
158
+ line << "#{label} #{text}"
159
+ end
160
+ end
161
+ block.call issue.labels, 'Labels:'
162
+ block.call issue.component_names, 'Components:'
163
+
164
+ [line]
165
+ end
166
+
167
+ def make_child_lines issue
168
+ lines = []
169
+ subtasks = issue.subtasks
170
+
171
+ return lines if subtasks.empty?
172
+
173
+ id = next_id
174
+ lines <<
175
+ "<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'section#{id}');\">" \
176
+ "<span id='open#{id}' style='display: none'>▶ Child issues</span>" \
177
+ "<span id='close#{id}'>▼ Child issues</span></a>"
178
+ lines << "<section id='section#{id}'>"
179
+
180
+ lines += subtasks
181
+ lines << '</section>'
182
+
183
+ lines
184
+ end
185
+
186
+ def make_history_lines issue
187
+ history = issue.changes.reverse
188
+ lines = []
189
+
190
+ id = next_id
191
+ lines << [
192
+ "<a href=\"javascript:toggle_visibility('open#{id}', 'close#{id}', 'table#{id}');\">" \
193
+ "<span id='open#{id}'>▶ Issue History</span>" \
194
+ "<span id='close#{id}' style='display: none'>▼ Issue History</span></a>"
195
+ ]
196
+ table = +''
197
+ table << "<table id='table#{id}' style='display: none'>"
198
+ history.each do |c|
199
+ time = c.time.strftime '%b %d, %I:%M%P'
200
+
201
+ table << '<tr>'
202
+ table << "<td><span class='time' title='Timestamp: #{c.time}'>#{time}</span></td>"
203
+ table << "<td><img src='#{c.author_icon_url}' class='icon' title='#{c.author}' /></td>"
204
+ text = history_text change: c, board: issue.board
205
+ table << "<td><span class='field'>#{c.field_as_human_readable}</span> #{text}</td>"
206
+ table << '</tr>'
207
+ end
208
+ table << '</table>'
209
+ lines << [table]
210
+ lines
211
+ end
212
+
213
+ def history_text change:, board:
214
+ convertor = ->(value, _id) { value.inspect }
215
+ convertor = ->(_value, id) { format_status(board.possible_statuses.find_by_id(id), board: board) } if change.status?
216
+
217
+ if change.comment? || change.description?
218
+ atlassian_document_format.to_html(change.value)
219
+ elsif %w[status priority assignee duedate issuetype].include?(change.field)
220
+ to = convertor.call(change.value, change.value_id)
221
+ if change.old_value
222
+ from = convertor.call(change.old_value, change.old_value_id)
223
+ "Changed from #{from} to #{to}"
224
+ else
225
+ "Set to #{to}"
226
+ end
227
+ elsif change.flagged?
228
+ change.value == '' ? 'Off' : 'On'
229
+ else
230
+ change.value
231
+ end
232
+ end
233
+
234
+ def make_sprints_lines issue
235
+ return [] unless issue.board.scrum?
236
+
237
+ sprint_names = issue.sprints.collect do |sprint|
238
+ if sprint.closed?
239
+ "<s>#{sprint.name}</s>"
240
+ else
241
+ sprint.name
242
+ end
243
+ end
244
+
245
+ return [['Sprints: NONE']] if sprint_names.empty?
246
+
247
+ [[+'Sprints: ' << sprint_names
248
+ .collect { |name| "<span class='label'>#{name}</span>" }
249
+ .join(' ')]]
250
+ end
251
+
252
+ def make_description_lines issue
253
+ description = issue.raw['fields']['description']
254
+ result = []
255
+ result << [atlassian_document_format.to_html(description)] if description
256
+ result
257
+ end
258
+
259
+ def assemble_issue_lines issue, child:
260
+ done = issue.done?
261
+
262
+ lines = []
263
+ lines << [make_title_line(issue: issue, done: done)]
264
+ lines += make_parent_lines(issue) unless child
265
+ lines += make_stats_lines(issue: issue, done: done)
266
+ unless done
267
+ lines += make_description_lines(issue)
268
+ lines += make_sprints_lines(issue)
269
+ lines += make_blocked_stalled_lines(issue)
270
+ lines += make_child_lines(issue)
271
+ lines += make_history_lines(issue)
272
+ end
273
+ lines
274
+ end
275
+
276
+ def render_issue issue, child:
277
+ css_class = child ? 'child_issue' : 'daily_issue'
278
+ result = +''
279
+ result << "<div class='#{css_class}'>"
280
+ assemble_issue_lines(issue, child: child).each do |row|
281
+ if row.is_a? Issue
282
+ result << render_issue(row, child: true)
283
+ elsif row.is_a?(String)
284
+ result << row
285
+ else
286
+ result << '<div class="heading">'
287
+ row.each do |chunk|
288
+ result << "<div>#{chunk}</div>"
289
+ end
290
+ result << '</div>'
291
+ end
292
+ end
293
+ result << '</div>'
294
+ end
295
+ end