jirametrics 2.12pre9 → 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: b2e99113dff452fc39af119d35fe1b480ef991dba7ef3ef8c3335bf471fed9d8
4
- data.tar.gz: e05ad4ed94cb690856293244e23f28d1007fdb1b608820bcfee40aa082232722
3
+ metadata.gz: 7565c5b6320b9c4375a3feb27fcaf80f96d9c058041b71584f287e6a3d7eb399
4
+ data.tar.gz: 4554c7edbc7c42e01ad4838094ab922819c2e71818a46907f25740e35c327aa6
5
5
  SHA512:
6
- metadata.gz: 0ece3dea23ac0f9141342ae2be782dfcbf034b39db6fb6ec7cbe28d8afe85865e41906083e9fbf587ed2de4aba9f6efa586a26f6899faa00a890505f5602e125
7
- data.tar.gz: 7fac5a0b4fdbdda9808febf6b6f3043dcdf0965a3613145458cc3fb8c88cb3c0872e370fac12c0b0c50642172caebcb9c1b932252eb78de313c1fbbe14656de8
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
@@ -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,24 +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 link? = (field == 'Link')
35
-
36
- def labels? = (field == 'labels')
41
+ def status? = (field == 'status')
37
42
 
38
43
  # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
39
44
  def to_time = @time
@@ -89,6 +94,17 @@ class ChangeItem
89
94
  end
90
95
  end
91
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
+
92
108
  private
93
109
 
94
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
@@ -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
@@ -52,8 +52,6 @@ class FileSystem
52
52
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
53
53
  # cases where this simple compression will drop the filesize by half.
54
54
  def compress node
55
- return node
56
-
57
55
  if node.is_a? Hash
58
56
  node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
59
57
  node.each_value { |value| compress value }
@@ -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)' %>
@@ -67,6 +67,9 @@
67
67
  --sprint-burndown-sprint-color-4: red;
68
68
  --sprint-burndown-sprint-color-5: brown;
69
69
 
70
+ --daily-view-selected-issue-background: lightgray;
71
+ --daily-view-issue-border: green;
72
+ --daily-view-selected-issue-border: red;
70
73
 
71
74
  }
72
75
 
@@ -142,6 +145,64 @@ ul.quality_report {
142
145
  border-top: 1px solid gray;
143
146
  }
144
147
 
148
+ div.daily_issue:hover {
149
+ background: var(--daily-view-selected-issue-background);
150
+ border-color: var(--daily-view-selected-issue-border);
151
+ }
152
+
153
+ div.daily_issue {
154
+ border: 1px solid var(--daily-view-issue-border);
155
+ padding: 0.5em;
156
+ .heading {
157
+ vertical-align: middle;
158
+ display: flex;
159
+ flex-wrap: wrap;
160
+ column-gap: 0.5em;
161
+ align-items: center;
162
+ }
163
+ table {
164
+ margin-left: 1em;
165
+ td {
166
+ vertical-align: top;
167
+ }
168
+ .time {
169
+ white-space: nowrap;
170
+ font-size: 0.8em;
171
+ }
172
+ }
173
+ .icon {
174
+ width: 1em;
175
+ height: 1em;
176
+ }
177
+ .account_id {
178
+ font-weight: bold;
179
+ }
180
+ .field {
181
+ border: 1px solid black;
182
+ color: white;
183
+ background: black;
184
+ padding-left: 0.2em;
185
+ padding-right: 0.2em;
186
+ border-radius: 0.2em;
187
+ }
188
+ .label {
189
+ border: 1px solid black;
190
+ padding-left: 0.2em;
191
+ padding-right: 0.2em;
192
+ border-radius: 0.2em;
193
+ }
194
+ margin-bottom: 0.5em;
195
+ }
196
+ div.child_issue:hover {
197
+ background: var(--body-background);
198
+ }
199
+ div.child_issue {
200
+ border: 1px dashed green;
201
+ margin: 0.2em;
202
+ margin-left: 1.5em;
203
+ padding: 0.5em;
204
+ }
205
+
145
206
  @media screen and (prefers-color-scheme: dark) {
146
207
  :root {
147
208
  --warning-banner: #9F2B00;
@@ -174,6 +235,8 @@ ul.quality_report {
174
235
  --wip-chart-duration-two-weeks-or-less-color: #cf9400;
175
236
  --wip-chart-duration-four-weeks-or-less-color: #c25e00;
176
237
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
238
+
239
+ --daily-view-selected-issue-background: #474747;
177
240
  }
178
241
 
179
242
  h1 {
@@ -206,4 +269,12 @@ ul.quality_report {
206
269
  div.color_block {
207
270
  border: 1px solid lightgray;
208
271
  }
272
+
273
+ div.daily_issue {
274
+ .field {
275
+ color: var(--default-text-color);
276
+ }
277
+ }
278
+ }
279
+
209
280
  }
@@ -18,6 +18,23 @@
18
18
  document.getElementById(issues_id).style.display = 'none'
19
19
  }
20
20
  }
21
+
22
+ function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
23
+ let open_link = document.getElementById(open_link_id)
24
+ let close_link = document.getElementById(close_link_id)
25
+ let toggleable_element = document.getElementById(toggleable_id)
26
+
27
+ if(open_link.style.display == 'none') {
28
+ open_link.style.display = 'block'
29
+ close_link.style.display = 'none'
30
+ toggleable_element.style.display = 'none'
31
+ }
32
+ else {
33
+ open_link.style.display = 'none'
34
+ close_link.style.display = 'block'
35
+ toggleable_element.style.display = 'block'
36
+ }
37
+ }
21
38
  // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
22
39
  // in the other colour scheme.
23
40
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
@@ -33,6 +33,7 @@ class HtmlReportConfig
33
33
  define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
34
34
  define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
35
35
  define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
36
+ define_chart name: 'daily_view', classname: 'DailyView'
36
37
 
37
38
  define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
38
39
  deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
@@ -159,6 +160,7 @@ class HtmlReportConfig
159
160
  chart.time_range = project_config.time_range
160
161
  chart.timezone_offset = timezone_offset
161
162
  chart.settings = settings
163
+ chart.users = project_config.users
162
164
 
163
165
  chart.all_boards = project_config.all_boards
164
166
  chart.board_id = find_board_id
@@ -44,9 +44,11 @@ class Issue
44
44
  def key = @raw['key']
45
45
 
46
46
  def type = @raw['fields']['issuetype']['name']
47
-
48
47
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
49
48
 
49
+ def priority_name = @raw['fields']['priority']['name']
50
+ def priority_url = @raw['fields']['priority']['iconUrl']
51
+
50
52
  def summary = @raw['fields']['summary']
51
53
 
52
54
  def labels = @raw['fields']['labels'] || []
@@ -205,6 +207,10 @@ class Issue
205
207
  nil
206
208
  end
207
209
 
210
+ def first_time_visible_on_board
211
+ first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
212
+ end
213
+
208
214
  def parse_time text
209
215
  Time.parse(text).getlocal(@timezone_offset)
210
216
  end
@@ -230,6 +236,10 @@ class Issue
230
236
  @raw['fields']&.[]('assignee')&.[]('displayName')
231
237
  end
232
238
 
239
+ def assigned_to_icon_url
240
+ @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
241
+ end
242
+
233
243
  # Many test failures are simply unreadable because the default inspect on this class goes
234
244
  # on for pages. Shorten it up.
235
245
  def inspect
@@ -315,7 +325,7 @@ class Issue
315
325
 
316
326
  # This mock change is to force the writing of one last entry at the end of the time range.
317
327
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
318
- mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
328
+ mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
319
329
 
320
330
  (changes + [mock_change]).each do |change|
321
331
  previous_was_active = false if check_for_stalled(
@@ -462,8 +472,6 @@ class Issue
462
472
  end
463
473
 
464
474
  def expedited?
465
- return false unless @board&.project_config
466
-
467
475
  names = @board.project_config.settings['expedited_priority_names']
468
476
  return false unless names
469
477
 
@@ -580,7 +588,7 @@ class Issue
580
588
  /(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
581
589
  /(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
582
590
  comparison = project_code1 <=> project_code2
583
- comparison = id1 <=> id2 if comparison.zero?
591
+ comparison = id1.to_i <=> id2.to_i if comparison.zero?
584
592
  comparison
585
593
  end
586
594
 
@@ -685,34 +693,40 @@ class Issue
685
693
  @changes.select { |change| change.status? }
686
694
  end
687
695
 
688
- private
696
+ def sprints
697
+ sprint_ids = []
698
+
699
+ changes.each do |change|
700
+ next unless change.sprint?
701
+
702
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
703
+ end
704
+ sprint_ids.flatten!
689
705
 
690
- def assemble_author raw
691
- raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
706
+ board.sprints.select { |s| sprint_ids.include? s.id }
692
707
  end
693
708
 
709
+ private
710
+
694
711
  def load_history_into_changes
695
712
  @raw['changelog']['histories']&.each do |history|
696
713
  created = parse_time(history['created'])
697
714
 
698
- # It should be impossible to not have an author but we've seen it in production
699
- author = assemble_author history
700
715
  history['items']&.each do |item|
701
- @changes << ChangeItem.new(raw: item, time: created, author: author)
716
+ @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
702
717
  end
703
718
  end
704
719
  end
705
720
 
706
721
  def load_comments_into_changes
707
722
  @raw['fields']['comment']['comments']&.each do |comment|
708
- raw = {
723
+ raw = comment.merge({
709
724
  'field' => 'comment',
710
725
  'to' => comment['id'],
711
726
  'toString' => comment['body']
712
- }
713
- author = assemble_author comment
727
+ })
714
728
  created = parse_time(comment['created'])
715
- @changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
729
+ @changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
716
730
  end
717
731
  end
718
732
 
@@ -754,7 +768,9 @@ class Issue
754
768
  first_status = first_change.old_value
755
769
  first_status_id = first_change.old_value_id
756
770
  end
757
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
771
+
772
+ creator = raw['fields']['creator']
773
+ ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
758
774
  'field' => field_name,
759
775
  'to' => first_status_id,
760
776
  'toString' => first_status
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IssueCollection < Array
4
+ attr_reader :hidden
5
+
6
+ def self.[] *issues
7
+ collection = new
8
+ issues.each { |i| collection << i }
9
+ collection
10
+ end
11
+
12
+ def initialize
13
+ super
14
+ @hidden = []
15
+ end
16
+
17
+ def reject! &block
18
+ select(&block).each do |issue|
19
+ @hidden << issue
20
+ end
21
+ super
22
+ end
23
+
24
+ def find_by_key key:, include_hidden: false
25
+ block = ->(issue) { issue.key == key }
26
+ issue = find(&block)
27
+ issue = hidden.find(&block) if issue.nil? && include_hidden
28
+ issue
29
+ end
30
+ def clone
31
+ raise 'baboom'
32
+ end
33
+ end
@@ -26,7 +26,10 @@ class JiraGateway
26
26
  end
27
27
 
28
28
  def call_command command
29
- @file_system.log " #{command.gsub(/\s+/, ' ')}"
29
+ log_entry = " #{command.gsub(/\s+/, ' ')}"
30
+ log_entry = log_entry.gsub(@jira_api_token, '[API_TOKEN]') if @jira_api_token
31
+ @file_system.log log_entry
32
+
30
33
  result = `#{command}`
31
34
  @file_system.log result unless $CHILD_STATUS.success?
32
35
  return result if $CHILD_STATUS.success?
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
6
6
  class ProjectConfig
7
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
8
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
9
- :settings, :aggregate_config, :discarded_changes_data
9
+ :settings, :aggregate_config, :discarded_changes_data, :users
10
10
  attr_accessor :time_range, :jira_url, :id
11
11
 
12
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -40,6 +40,7 @@ class ProjectConfig
40
40
  @id = guess_project_id
41
41
  load_project_metadata
42
42
  load_sprints
43
+ load_users
43
44
  end
44
45
 
45
46
  def run load_only: false
@@ -325,6 +326,15 @@ class ProjectConfig
325
326
  raise
326
327
  end
327
328
 
329
+ def load_users
330
+ @users = []
331
+ filename = File.join @target_path, "#{get_file_prefix}_users.json"
332
+ return unless File.exist? filename
333
+
334
+ json = file_system.load_json(filename)
335
+ json.each { |user_data| @users << User.new(raw: user_data) }
336
+ end
337
+
328
338
  def to_time string, end_of_day: false
329
339
  time = end_of_day ? '23:59:59' : '00:00:00'
330
340
  string = "#{string}T#{time}#{exporter.timezone_offset}" if string.match?(/^\d{4}-\d{2}-\d{2}$/)
@@ -358,7 +368,7 @@ class ProjectConfig
358
368
 
359
369
  # To be used by the aggregate_config only. Not intended to be part of the public API
360
370
  def add_issues issues_list
361
- @issues = [] if @issues.nil?
371
+ @issues = IssueCollection.new if @issues.nil?
362
372
  @all_boards = {}
363
373
 
364
374
  issues_list.each do |issue|
@@ -375,7 +385,7 @@ class ProjectConfig
375
385
  'declaration but none are here. Check your config.'
376
386
  end
377
387
 
378
- return @issues = [] if @exporter.downloading?
388
+ return @issues = IssueCollection.new if @exporter.downloading?
379
389
  raise 'No data found. Must do a download before an export' unless data_downloaded?
380
390
 
381
391
  load_data if all_boards.empty?
@@ -387,7 +397,7 @@ class ProjectConfig
387
397
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
388
398
  else
389
399
  file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
390
- return []
400
+ return IssueCollection.new
391
401
  end
392
402
 
393
403
  # Attach related issues
@@ -399,7 +409,8 @@ class ProjectConfig
399
409
 
400
410
  # We'll have some issues that are in the list that weren't part of the initial query. Once we've
401
411
  # attached them in the appropriate places, remove any that aren't part of that initial set.
402
- @issues = issues.select { |i| i.in_initial_query? }
412
+ issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
413
+ @issues = issues
403
414
  end
404
415
 
405
416
  @issues
@@ -440,7 +451,7 @@ class ProjectConfig
440
451
  end
441
452
 
442
453
  def load_issues_from_issues_directory path:, timezone_offset:
443
- issues = []
454
+ issues = IssueCollection.new
444
455
  default_board = nil
445
456
 
446
457
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
@@ -453,7 +464,8 @@ class ProjectConfig
453
464
 
454
465
  boards.each do |board|
455
466
  if board.cycletime.nil?
456
- raise "The board declaration for board #{board.id} must come before the first usage of 'issues' in the configuration"
467
+ raise "The board declaration for board #{board.id} must come before the " \
468
+ "first usage of 'issues' in the configuration"
457
469
  end
458
470
  issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
459
471
  end
@@ -6,5 +6,6 @@
6
6
  "blocked_statuses": [],
7
7
  "flagged_means_blocked": true,
8
8
 
9
- "expedited_priority_names": ["Critical", "Highest"]
9
+ "expedited_priority_names": ["Critical", "Highest"],
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"]
10
11
  }
@@ -12,6 +12,7 @@ class Sprint
12
12
 
13
13
  def id = @raw['id']
14
14
  def active? = (@raw['state'] == 'active')
15
+ def closed? = (@raw['state'] == 'closed')
15
16
 
16
17
  def completed_at? time
17
18
  completed_at = completed_time
@@ -16,6 +16,12 @@ class StatusCollection
16
16
  @list.find { |status| status.id == id }
17
17
  end
18
18
 
19
+ def find_by_id! id
20
+ status = @list.find { |status| status.id == id }
21
+ raise "Can't find any status for id #{id} in #{self}" unless status
22
+ status
23
+ end
24
+
19
25
  def find_all_by_name identifier
20
26
  name, id = parse_name_id identifier
21
27
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def account_id = @raw['accountId']
9
+ def avatar_url = @raw['avatarUrls']['16x16']
10
+ def active? = @raw['active']
11
+ def display_name = @raw['displayName']
12
+ end
data/lib/jirametrics.rb CHANGED
@@ -69,6 +69,7 @@ class JiraMetrics < Thor
69
69
  require 'jirametrics/daily_wip_chart'
70
70
  require 'jirametrics/groupable_issue_chart'
71
71
  require 'jirametrics/css_variable'
72
+ require 'jirametrics/issue_collection'
72
73
 
73
74
  require 'jirametrics/aggregate_config'
74
75
  require 'jirametrics/expedited_chart'
@@ -114,6 +115,8 @@ class JiraMetrics < Thor
114
115
  require 'jirametrics/hierarchy_table'
115
116
  require 'jirametrics/estimation_configuration'
116
117
  require 'jirametrics/board'
118
+ require 'jirametrics/daily_view'
119
+ require 'jirametrics/user'
117
120
  load config_file
118
121
  end
119
122
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12pre9
4
+ version: 2.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-16 00:00:00.000000000 Z
10
+ date: 2025-07-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: random-word
@@ -77,6 +77,7 @@ files:
77
77
  - lib/jirametrics/cycletime_config.rb
78
78
  - lib/jirametrics/cycletime_histogram.rb
79
79
  - lib/jirametrics/cycletime_scatterplot.rb
80
+ - lib/jirametrics/daily_view.rb
80
81
  - lib/jirametrics/daily_wip_by_age_chart.rb
81
82
  - lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb
82
83
  - lib/jirametrics/daily_wip_by_parent_chart.rb
@@ -115,6 +116,7 @@ files:
115
116
  - lib/jirametrics/html/throughput_chart.erb
116
117
  - lib/jirametrics/html_report_config.rb
117
118
  - lib/jirametrics/issue.rb
119
+ - lib/jirametrics/issue_collection.rb
118
120
  - lib/jirametrics/issue_link.rb
119
121
  - lib/jirametrics/jira_gateway.rb
120
122
  - lib/jirametrics/project_config.rb
@@ -129,6 +131,7 @@ files:
129
131
  - lib/jirametrics/throughput_chart.rb
130
132
  - lib/jirametrics/tree_organizer.rb
131
133
  - lib/jirametrics/trend_line_calculator.rb
134
+ - lib/jirametrics/user.rb
132
135
  - lib/jirametrics/value_equality.rb
133
136
  homepage: https://jirametrics.org
134
137
  licenses: