jirametrics 2.24 → 2.25pre7

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +3 -3
  3. data/lib/jirametrics/aging_work_table.rb +3 -4
  4. data/lib/jirametrics/atlassian_document_format.rb +8 -19
  5. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  6. data/lib/jirametrics/chart_base.rb +6 -0
  7. data/lib/jirametrics/cycletime_histogram.rb +1 -1
  8. data/lib/jirametrics/cycletime_scatterplot.rb +2 -2
  9. data/lib/jirametrics/daily_view.rb +4 -3
  10. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  11. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  12. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  13. data/lib/jirametrics/daily_wip_chart.rb +3 -1
  14. data/lib/jirametrics/data_quality_report.rb +4 -4
  15. data/lib/jirametrics/dependency_chart.rb +1 -1
  16. data/lib/jirametrics/examples/standard_project.rb +12 -11
  17. data/lib/jirametrics/expedited_chart.rb +1 -1
  18. data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -1
  19. data/lib/jirametrics/github_gateway.rb +1 -1
  20. data/lib/jirametrics/grouping_rules.rb +1 -1
  21. data/lib/jirametrics/html/daily_wip_chart.erb +33 -1
  22. data/lib/jirametrics/html/index.css +3 -0
  23. data/lib/jirametrics/html/throughput_chart.erb +35 -1
  24. data/lib/jirametrics/html/time_based_histogram.erb +2 -0
  25. data/lib/jirametrics/html_report_config.rb +19 -15
  26. data/lib/jirametrics/issue.rb +18 -2
  27. data/lib/jirametrics/issue_printer.rb +1 -1
  28. data/lib/jirametrics/project_config.rb +1 -1
  29. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  30. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +81 -0
  31. data/lib/jirametrics/sprint_burndown.rb +1 -1
  32. data/lib/jirametrics/status.rb +1 -1
  33. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  34. data/lib/jirametrics/throughput_chart.rb +50 -22
  35. data/lib/jirametrics/time_based_scatterplot.rb +3 -3
  36. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab33e299bd374f28754ecfbd750f6afd89861ecf64a323f28d4678d7a22959d
4
- data.tar.gz: a0bf98e4d51b86eaa36aef8c6bfb69e8cdba958d3afeaf67da71a37ff6eb3ae5
3
+ metadata.gz: 98bcc110a47cd8dfe55eafb58b699d27604f2fb8d24a7ed357e6dd970998af8d
4
+ data.tar.gz: 2501f1064f33999d31ce42a1dcdf29fd023fb5b60fc06f01294d064d60210fc4
5
5
  SHA512:
6
- metadata.gz: 7c017b109ce143403190db03124148def9be7a92fe20961f6f7c42f459482a4056018601f131d6ca6275889e7231e54df54befbccb4279de0073838d50280ff4
7
- data.tar.gz: 07b783818d028d3d95fd1cd861f272fe624c46fe3d4b671f3e2e6fede8fcdc694e5118da888c7c12113838e37711ccd6935a54261d4edf5ceaac4e29c6a08a73
6
+ metadata.gz: 0761c74f71511217ed38496578d2863df6e5c1fe38d2932fd6fa3c1fed6dd626f5bcbc8bc015ce06aa91d098c55dbff5f1944daba386e39cdb9319f66d658cee
7
+ data.tar.gz: b3657601410caceb3b186994785609f55d67f80094c945cd34203f68ff8dcdd494ff3d51375498071cfe0f316bf69aef38eac75e3a0e3acfb1462c8b797dc5cd
@@ -66,7 +66,7 @@ class AgingWorkBarChart < ChartBase
66
66
 
67
67
  def adjust_time_date_ranges_to_start_from_earliest_issue_start aging_issues
68
68
  earliest_start_time = aging_issues.collect do |issue|
69
- issue.board.cycletime.started_stopped_times(issue).first
69
+ issue.started_stopped_times.first
70
70
  end.min
71
71
  return if earliest_start_time.nil? || earliest_start_time >= @time_range.begin
72
72
 
@@ -102,7 +102,7 @@ class AgingWorkBarChart < ChartBase
102
102
 
103
103
  def select_aging_issues issues:
104
104
  issues.select do |issue|
105
- started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
105
+ started_time, stopped_time = issue.started_stopped_times
106
106
  next false unless started_time && stopped_time.nil?
107
107
 
108
108
  age = (date_range.end - started_time.to_date).to_i + 1
@@ -128,7 +128,7 @@ class AgingWorkBarChart < ChartBase
128
128
 
129
129
  def collect_status_ranges issue:, now:
130
130
  ranges = []
131
- issue_started_time = issue.board.cycletime.started_stopped_times(issue).first
131
+ issue_started_time = issue.started_stopped_times.first
132
132
  previous_start = nil
133
133
  previous_status = nil
134
134
  issue.status_changes.each do |change|
@@ -50,15 +50,14 @@ class AgingWorkTable < ChartBase
50
50
 
51
51
  def expedited_but_not_started
52
52
  @issues.select do |issue|
53
- started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
53
+ started_time, stopped_time = issue.started_stopped_times
54
54
  started_time.nil? && stopped_time.nil? && issue.expedited?
55
55
  end.sort_by(&:created)
56
56
  end
57
57
 
58
58
  def select_aging_issues
59
59
  aging_issues = @issues.select do |issue|
60
- cycletime = issue.board.cycletime
61
- started, stopped = cycletime.started_stopped_times(issue)
60
+ started, stopped = issue.started_stopped_times
62
61
  next false if started.nil? || stopped
63
62
  next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
64
63
 
@@ -77,7 +76,7 @@ class AgingWorkTable < ChartBase
77
76
  end
78
77
 
79
78
  def blocked_text issue
80
- started_time, _stopped_time = issue.board.cycletime.started_stopped_times(issue)
79
+ started_time, _stopped_time = issue.started_stopped_times
81
80
  return nil if started_time.nil?
82
81
 
83
82
  current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
@@ -44,9 +44,9 @@ class AtlassianDocumentFormat
44
44
  when 'codeBlock' then ['<code>', '</code>']
45
45
  when 'date'
46
46
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
47
- when 'decisionItem' then ['<li>', '</li>']
47
+ when 'decisionItem', 'listItem' then ['<li>', '</li>']
48
48
  when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
49
- when 'emoji' then [node_attrs['text'], nil]
49
+ when 'emoji', 'status' then [node_attrs['text'], nil]
50
50
  when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
51
51
  when 'hardBreak' then ['<br />', nil]
52
52
  when 'heading'
@@ -55,7 +55,6 @@ class AtlassianDocumentFormat
55
55
  when 'inlineCard'
56
56
  url = node_attrs['url']
57
57
  ["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
58
- when 'listItem' then ['<li>', '</li>']
59
58
  when 'media'
60
59
  text = node_attrs['alt'] || node_attrs['id']
61
60
  ["Media: #{text}", nil]
@@ -65,7 +64,6 @@ class AtlassianDocumentFormat
65
64
  when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
66
65
  when 'paragraph' then ['<p>', '</p>']
67
66
  when 'rule' then ['<hr />', nil]
68
- when 'status' then [node_attrs['text'], nil]
69
67
  when 'table' then ['<table>', '</table>']
70
68
  when 'tableCell' then ['<td>', '</td>']
71
69
  when 'tableHeader' then ['<th>', '</th>']
@@ -87,38 +85,29 @@ class AtlassianDocumentFormat
87
85
  adf_node_render(node) do |n|
88
86
  node_attrs = n['attrs']
89
87
  case n['type']
90
- when 'blockquote' then ['', nil]
91
- when 'bulletList' then ['', nil]
92
- when 'codeBlock' then ['', nil]
88
+ when 'blockquote', 'bulletList', 'codeBlock',
89
+ 'mediaSingle', 'mediaGroup',
90
+ 'orderedList', 'table', 'taskList' then ['', nil]
93
91
  when 'date'
94
92
  [Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
95
93
  when 'decisionItem' then ['- ', "\n"]
96
94
  when 'decisionList' then ["Decisions:\n", nil]
97
- when 'emoji' then [node_attrs['text'], nil]
95
+ when 'emoji', 'mention', 'status' then [node_attrs['text'], nil]
98
96
  when 'expand' then ["#{node_attrs['title']}\n", nil]
99
97
  when 'hardBreak' then ["\n", nil]
100
- when 'heading' then ['', "\n"]
98
+ when 'heading', 'paragraph', 'tableRow' then ['', "\n"]
101
99
  when 'inlineCard' then [node_attrs['url'], nil]
102
100
  when 'listItem' then ['- ', nil]
103
101
  when 'media'
104
102
  text = node_attrs['alt'] || node_attrs['id']
105
103
  ["Media: #{text}", nil]
106
- when 'mediaSingle', 'mediaGroup' then ['', nil]
107
- when 'mention' then [node_attrs['text'], nil]
108
- when 'orderedList' then ['', nil]
109
104
  when 'panel' then ["#{node_attrs['panelType'].upcase}\n", nil]
110
- when 'paragraph' then ['', "\n"]
111
105
  when 'rule' then ["---\n", nil]
112
- when 'status' then [node_attrs['text'], nil]
113
- when 'table' then ['', nil]
114
- when 'tableCell' then ['', "\t"]
115
- when 'tableHeader' then ['', "\t"]
116
- when 'tableRow' then ['', "\n"]
106
+ when 'tableCell', 'tableHeader' then ['', "\t"]
117
107
  when 'text' then [n['text'], nil]
118
108
  when 'taskItem'
119
109
  state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
120
110
  ["#{state} ", "\n"]
121
- when 'taskList' then ['', nil]
122
111
  else
123
112
  ["[Unparseable: #{n['type']}]\n", nil]
124
113
  end
@@ -10,7 +10,7 @@ class BoardMovementCalculator
10
10
  end
11
11
 
12
12
  def moves_backwards? issue
13
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
13
+ started, stopped = issue.started_stopped_times
14
14
  return false unless started
15
15
 
16
16
  previous_column = nil
@@ -70,7 +70,7 @@ class BoardMovementCalculator
70
70
  @issues.filter_map do |issue|
71
71
  this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
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)
73
+ issue_start, issue_done = issue.started_stopped_times
74
74
 
75
75
  # Skip if we can't tell when it started.
76
76
  next if issue_start.nil?
@@ -86,6 +86,12 @@ class ChartBase
86
86
  "#{hours} hour#{'s' unless hours == 1}"
87
87
  end
88
88
 
89
+ def label_minutes minutes
90
+ return 'unknown' if minutes.nil?
91
+
92
+ "#{minutes} minute#{'s' unless minutes == 1}"
93
+ end
94
+
89
95
  def label_issues count
90
96
  "#{count} issue#{'s' unless count == 1}"
91
97
  end
@@ -32,7 +32,7 @@ class CycletimeHistogram < TimeBasedHistogram
32
32
  stopped_issues = completed_issues_in_range include_unstarted: true
33
33
 
34
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
35
- stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
35
+ stopped_issues.select { |issue| issue.started_stopped_times.first }
36
36
  end
37
37
 
38
38
  def value_for_item issue
@@ -40,14 +40,14 @@ class CycletimeScatterplot < TimeBasedScatterplot
40
40
  end
41
41
 
42
42
  def x_value item
43
- item.board.cycletime.started_stopped_times(item).last
43
+ item.started_stopped_times.last
44
44
  end
45
45
 
46
46
  def y_value item
47
47
  item.board.cycletime.cycletime(item)
48
48
  end
49
49
 
50
- def title_value item
50
+ def title_value item, rules: nil
51
51
  hint = @issue_hints&.fetch(item, nil)
52
52
  "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
53
53
  end
@@ -36,7 +36,7 @@ class DailyView < ChartBase
36
36
 
37
37
  def select_aging_issues
38
38
  aging_issues = issues.select do |issue|
39
- started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
39
+ started_at, stopped_at = issue.started_stopped_times
40
40
  started_at && !stopped_at
41
41
  end
42
42
 
@@ -73,7 +73,7 @@ class DailyView < ChartBase
73
73
 
74
74
  def make_blocked_stalled_lines issue
75
75
  today = date_range.end
76
- started_date = issue.board.cycletime.started_stopped_times(issue).first&.to_date
76
+ started_date = issue.started_stopped_times.first&.to_date
77
77
  return [] unless started_date
78
78
 
79
79
  blocked_stalled = issue.blocked_stalled_by_date(
@@ -256,7 +256,8 @@ class DailyView < ChartBase
256
256
  description = issue.raw['fields']['description']
257
257
  return [] unless description
258
258
 
259
- text = "<div class='foldable startFolded'>Description</div><div>#{atlassian_document_format.to_html(description)}</div>"
259
+ text = "<div class='foldable startFolded'>Description</div>" \
260
+ "<div>#{atlassian_document_format.to_html(description)}</div>"
260
261
  [[text]]
261
262
  end
262
263
 
@@ -49,7 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- started, stopped = issue.board.cycletime.started_stopped_dates(issue)
52
+ started, stopped = issue.started_stopped_dates
53
53
 
54
54
  if stopped && started.nil? # We can't tell when it started
55
55
  @has_completed_but_not_started = true
@@ -39,7 +39,7 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
42
+ started, stopped = issue.started_stopped_times
43
43
  stopped_date = stopped&.to_date
44
44
  started_date = started&.to_date
45
45
 
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
26
26
  end
27
27
 
28
28
  def default_grouping_rules issue:, rules:
29
- parent = issue.parent&.key
29
+ parent = issue.parent
30
30
  if parent
31
- rules.label = parent
31
+ rules.label = parent.key
32
+ rules.label_hint = "#{parent.key} : #{parent.summary}"
32
33
  else
33
34
  rules.label = 'No parent'
35
+ rules.label_hint = 'No parent'
34
36
  rules.group_priority = 1000
35
37
  rules.color = '--body-background'
36
38
  end
@@ -104,7 +104,8 @@ class DailyWipChart < ChartBase
104
104
  .select { |_issue, rules| rules.group == grouping_rule.group }
105
105
  .sort_by { |issue, _rules| issue.key_as_i }
106
106
  .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
107
- title = ["#{display_label} (#{label_issues issue_strings.size})"] + issue_strings
107
+ title_label = grouping_rule.label_hint || display_label
108
+ title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
108
109
 
109
110
  {
110
111
  x: date,
@@ -123,6 +124,7 @@ class DailyWipChart < ChartBase
123
124
  {
124
125
  type: 'bar',
125
126
  label: display_label,
127
+ label_hint: grouping_rule.label_hint,
126
128
  data: data,
127
129
  backgroundColor: background_color,
128
130
  borderColor: CssVariable['--wip-chart-border-color'],
@@ -121,7 +121,7 @@ class DataQualityReport < ChartBase
121
121
 
122
122
  def initialize_entries
123
123
  @entries = @issues.filter_map do |issue|
124
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
124
+ started, stopped = issue.started_stopped_times
125
125
  next if stopped && stopped < time_range.begin
126
126
  next if started && started > time_range.end
127
127
 
@@ -274,7 +274,7 @@ class DataQualityReport < ChartBase
274
274
 
275
275
  started_subtasks = []
276
276
  entry.issue.subtasks.each do |subtask|
277
- started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
277
+ started_subtasks << subtask if subtask.started_stopped_times.first
278
278
  end
279
279
 
280
280
  return if started_subtasks.empty?
@@ -293,7 +293,7 @@ class DataQualityReport < ChartBase
293
293
  next unless settings['blocked_link_text'].include?(link.label)
294
294
 
295
295
  this_active = !entry.stopped
296
- other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
296
+ other_active = !link.other_issue.started_stopped_times.last
297
297
  next unless this_active && !other_active
298
298
 
299
299
  entry.report(
@@ -326,7 +326,7 @@ class DataQualityReport < ChartBase
326
326
  return unless entry.stopped
327
327
 
328
328
  subtask_labels = entry.issue.subtasks.filter_map do |subtask|
329
- subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
329
+ subtask_started, subtask_stopped = subtask.started_stopped_times
330
330
 
331
331
  if !subtask_started && !subtask_stopped
332
332
  "#{subtask_label subtask} (Not even started)"
@@ -231,7 +231,7 @@ class DependencyChart < ChartBase
231
231
  elsif is_done
232
232
  line2 << 'Done'
233
233
  else
234
- started_at = issue.board.cycletime.started_stopped_times(issue).first
234
+ started_at = issue.started_stopped_times.first
235
235
  if started_at.nil?
236
236
  line2 << 'Not started'
237
237
  else
@@ -67,19 +67,19 @@ class Exporter
67
67
  cycletime_histogram
68
68
 
69
69
  throughput_chart do
70
- description_text '<h2>Number of items completed, grouped by issue type</h2>'
70
+ description_text <<~TEXT
71
+ <div>Throughput data is very useful for#{' '}
72
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
73
+ to determine when we'll be done. Try it now with the
74
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
75
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
76
+ <%= @not_started_count %> items you currently have in your backlog.
77
+ </div>
78
+ <h2>Number of items completed, grouped by issue type</h2>'
79
+ TEXT
71
80
  end
72
- throughput_chart do
73
- header_text nil
81
+ throughput_by_completed_resolution_chart do
74
82
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
75
- grouping_rules do |issue, rules|
76
- status, resolution = issue.status_resolution_at_done
77
- if resolution
78
- rules.label = "#{status.name}:#{resolution}"
79
- else
80
- rules.label = status.name
81
- end
82
- end
83
83
  end
84
84
 
85
85
  aging_work_in_progress_chart
@@ -91,6 +91,7 @@ class Exporter
91
91
  flow_efficiency_scatterplot if show_experimental_charts
92
92
  sprint_burndown
93
93
  estimate_accuracy_chart
94
+ expedited_chart
94
95
  dependency_chart
95
96
  end
96
97
  end
@@ -50,7 +50,7 @@ class ExpeditedChart < ChartBase
50
50
  end
51
51
 
52
52
  if data_sets.empty?
53
- '<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
53
+ '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
54
54
  else
55
55
  wrap_and_render(binding, __FILE__)
56
56
  end
@@ -62,7 +62,9 @@ class FlowEfficiencyScatterplot < ChartBase
62
62
  create_dataset(issues: issues, label: rules.label, color: rules.color)
63
63
  end
64
64
 
65
- return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
65
+ if data_sets.empty?
66
+ return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show."
67
+ end
66
68
 
67
69
  wrap_and_render(binding, __FILE__)
68
70
  end
@@ -96,7 +96,7 @@ class GithubGateway
96
96
  # This extra check seems to only matter on Windows. On the mac, auth failures don't pass status.success?
97
97
  if stderr.include?('SAML enforcement')
98
98
  raise "GitHub CLI is not authorized to access #{@repo}. " \
99
- "Run: gh auth refresh -h github.com -s read:org"
99
+ 'Run: gh auth refresh -h github.com -s read:org'
100
100
  end
101
101
 
102
102
  raise "GitHub CLI command failed for #{@repo}: #{stderr}" unless status.success?
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class GroupingRules < Rules
4
- attr_accessor :label, :issue_hint
4
+ attr_accessor :label, :issue_hint, :label_hint
5
5
  attr_reader :color
6
6
 
7
7
  def eql? other
@@ -3,6 +3,11 @@
3
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
4
4
  </div>
5
5
  <script>
6
+ if (!Chart.Tooltip.positioners.legendItem) {
7
+ Chart.Tooltip.positioners.legendItem = function(items) {
8
+ return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
9
+ };
10
+ }
6
11
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
7
12
  {
8
13
  type: 'bar',
@@ -43,9 +48,16 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
43
48
  },
44
49
  plugins: {
45
50
  tooltip: {
51
+ position: 'legendItem',
46
52
  callbacks: {
53
+ title: function(contexts) {
54
+ if (contexts[0]?.chart._legendHoverIndex != null) return '';
55
+ },
47
56
  label: function(context) {
48
- return context.dataset.data[context.dataIndex].title
57
+ if (context.chart._legendHoverIndex != null) {
58
+ return context.dataset.label_hint || '';
59
+ }
60
+ return context.dataset.data[context.dataIndex].title;
49
61
  }
50
62
  }
51
63
  },
@@ -56,6 +68,26 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
56
68
  }
57
69
  },
58
70
  legend: {
71
+ onHover: function(event, legendItem, legend) {
72
+ const chart = legend.chart;
73
+ const dataset = chart.data.datasets[legendItem.datasetIndex];
74
+ if (!dataset?.label_hint) return;
75
+ chart._legendHoverIndex = legendItem.datasetIndex;
76
+ chart._legendHoverPosition = { x: event.x, y: event.y };
77
+ const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
78
+ if (firstNonZero === -1) return;
79
+ chart.tooltip.setActiveElements(
80
+ [{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
81
+ { x: event.x, y: event.y }
82
+ );
83
+ chart.update();
84
+ },
85
+ onLeave: function(event, legendItem, legend) {
86
+ legend.chart._legendHoverIndex = null;
87
+ legend.chart._legendHoverPosition = null;
88
+ legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
89
+ legend.chart.update();
90
+ },
59
91
  labels: {
60
92
  filter: function(item, chart) {
61
93
  // Logic to remove a particular legend item goes here
@@ -87,6 +87,9 @@
87
87
  body {
88
88
  background-color: var(--body-background);
89
89
  color: var(--default-text-color);
90
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
91
+ font-size: 14px;
92
+ line-height: 1.5;
90
93
  }
91
94
 
92
95
  dl, dd, dt {
@@ -3,6 +3,11 @@
3
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
4
4
  </div>
5
5
  <script>
6
+ if (!Chart.Tooltip.positioners.legendItem) {
7
+ Chart.Tooltip.positioners.legendItem = function(items) {
8
+ return this.chart._legendHoverPosition || Chart.Tooltip.positioners.average.call(this, items);
9
+ };
10
+ }
6
11
  new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
7
12
  type: 'scatter',
8
13
  data: {
@@ -40,9 +45,16 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
40
45
  },
41
46
  plugins: {
42
47
  tooltip: {
48
+ position: 'legendItem',
43
49
  callbacks: {
50
+ title: function(contexts) {
51
+ if (contexts[0]?.chart._legendHoverIndex != null) return '';
52
+ },
44
53
  label: function(context) {
45
- return context.dataset.data[context.dataIndex].title
54
+ if (context.chart._legendHoverIndex != null) {
55
+ return context.dataset.label_hint || '';
56
+ }
57
+ return context.dataset.data[context.dataIndex].title;
46
58
  }
47
59
  }
48
60
  },
@@ -51,6 +63,28 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
51
63
  <%= working_days_annotation %>
52
64
  <%= date_annotation %>
53
65
  }
66
+ },
67
+ legend: {
68
+ onHover: function(event, legendItem, legend) {
69
+ const chart = legend.chart;
70
+ const dataset = chart.data.datasets[legendItem.datasetIndex];
71
+ if (!dataset?.label_hint) return;
72
+ chart._legendHoverIndex = legendItem.datasetIndex;
73
+ chart._legendHoverPosition = { x: event.x, y: event.y };
74
+ const firstNonZero = dataset.data.findIndex(d => d?.y !== 0);
75
+ if (firstNonZero === -1) return;
76
+ chart.tooltip.setActiveElements(
77
+ [{ datasetIndex: legendItem.datasetIndex, index: firstNonZero }],
78
+ { x: event.x, y: event.y }
79
+ );
80
+ chart.update();
81
+ },
82
+ onLeave: function(event, legendItem, legend) {
83
+ legend.chart._legendHoverIndex = null;
84
+ legend.chart._legendHoverPosition = null;
85
+ legend.chart.tooltip.setActiveElements([], { x: 0, y: 0 });
86
+ legend.chart.update();
87
+ }
54
88
  }
55
89
  }
56
90
  }
@@ -1,3 +1,4 @@
1
+ <div>
1
2
  <%= seam_start %>
2
3
  <div class="chart">
3
4
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
@@ -119,3 +120,4 @@ if show_stats
119
120
  end
120
121
  %>
121
122
  <%= seam_end 'stats_table' %>
123
+ </div>
@@ -20,21 +20,6 @@ class HtmlReportConfig < HtmlGenerator
20
20
  module_eval lines.join("\n"), __FILE__, __LINE__
21
21
  end
22
22
 
23
- define_chart name: 'aging_work_bar_chart', classname: 'AgingWorkBarChart'
24
- define_chart name: 'aging_work_table', classname: 'AgingWorkTable'
25
- define_chart name: 'cycletime_scatterplot', classname: 'CycletimeScatterplot'
26
- define_chart name: 'daily_wip_chart', classname: 'DailyWipChart'
27
- define_chart name: 'daily_wip_by_age_chart', classname: 'DailyWipByAgeChart'
28
- define_chart name: 'daily_wip_by_blocked_stalled_chart', classname: 'DailyWipByBlockedStalledChart'
29
- define_chart name: 'daily_wip_by_parent_chart', classname: 'DailyWipByParentChart'
30
- define_chart name: 'throughput_chart', classname: 'ThroughputChart'
31
- define_chart name: 'expedited_chart', classname: 'ExpeditedChart'
32
- define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
33
- define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
34
- define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
35
- define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
36
- define_chart name: 'daily_view', classname: 'DailyView'
37
-
38
23
  define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
39
24
  deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
40
25
  define_chart name: 'story_point_accuracy_chart', classname: 'EstimateAccuracyChart',
@@ -48,6 +33,25 @@ class HtmlReportConfig < HtmlGenerator
48
33
  @charts = [] # Where we store all the charts we executed so we can assert against them.
49
34
  end
50
35
 
36
+ def method_missing name, &block
37
+ class_name = name.to_s.split('_').map(&:capitalize).join
38
+ klass = Object.const_get(class_name)
39
+ raise NameError unless klass < ChartBase
40
+
41
+ block ||= ->(_) {}
42
+ execute_chart klass.new(block)
43
+ rescue NameError
44
+ super
45
+ end
46
+
47
+ def respond_to_missing? name, include_private = false
48
+ class_name = name.to_s.split('_').map(&:capitalize).join
49
+ klass = Object.const_get(class_name)
50
+ klass < ChartBase
51
+ rescue NameError
52
+ super
53
+ end
54
+
51
55
  def cycletime label = nil, &block
52
56
  @file_config.project_config.all_boards.each_value do |board|
53
57
  raise 'Multiple cycletimes not supported' if board.cycletime
@@ -560,7 +560,7 @@ class Issue
560
560
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
561
561
  # It does not include data before issue start or after issue end
562
562
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
563
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
563
+ issue_start, issue_stop = started_stopped_times
564
564
  return [0.0, 0.0] if !issue_start || issue_start > end_time
565
565
 
566
566
  value_add_time = 0.0
@@ -753,12 +753,20 @@ class Issue
753
753
  end
754
754
  end
755
755
 
756
+ def started_stopped_times
757
+ board.cycletime.started_stopped_times(self)
758
+ end
759
+
760
+ def started_stopped_dates
761
+ board.cycletime.started_stopped_dates(self)
762
+ end
763
+
756
764
  def status_changes
757
765
  @changes.select { |change| change.status? }
758
766
  end
759
767
 
760
768
  def status_resolution_at_done
761
- done_time = board.cycletime.started_stopped_times(self).last
769
+ done_time = started_stopped_times.last
762
770
  return [nil, nil] if done_time.nil?
763
771
 
764
772
  status_change = nil
@@ -812,6 +820,14 @@ class Issue
812
820
  created = parse_time(history['created'])
813
821
 
814
822
  history['items']&.each do |item|
823
+ if item['field'] == 'status' && item['to'].nil?
824
+ board.project_config.file_system.log(
825
+ "Issue #{key} has a status change without a 'to' id " \
826
+ "(from #{item['fromString'].inspect} to #{item['toString'].inspect}). Using id 0."
827
+ )
828
+ item = item.merge('to' => '0')
829
+ end
830
+
815
831
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
816
832
  end
817
833
  end
@@ -21,7 +21,7 @@ class IssuePrinter
21
21
  history = [] # time, type, detail
22
22
 
23
23
  if issue.board.cycletime
24
- started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
24
+ started_at, stopped_at = issue.started_stopped_times
25
25
  history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
26
26
  history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
27
27
  else
@@ -623,7 +623,7 @@ class ProjectConfig
623
623
  cutoff_time = block.call(issue)
624
624
  next if cutoff_time.nil?
625
625
 
626
- original_start_time = issue.board.cycletime.started_stopped_times(issue).first
626
+ original_start_time = issue.started_stopped_times.first
627
627
  next if original_start_time.nil?
628
628
 
629
629
  issue.discard_changes_before cutoff_time
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeHistogram < TimeBasedHistogram
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @x_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'PR Histogram'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This cycletime Histogram shows how many pull requests completed in a certain timeframe. This can be
16
+ useful for determining how many different types of work are flowing through, based on the
17
+ lengths of time they take.
18
+ </div>
19
+ HTML
20
+
21
+ init_configuration_block(block) do
22
+ grouping_rules do |pull_request, _rule|
23
+ rules.label = pull_request.repo
24
+ end
25
+ end
26
+ end
27
+
28
+ def cycletime_unit unit
29
+ unless %i[minutes hours days].include?(unit)
30
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
31
+ end
32
+
33
+ @cycletime_unit = unit
34
+ @x_axis_title = "Cycle time in #{unit}"
35
+ end
36
+
37
+ def all_items
38
+ result = []
39
+ issues.each do |issue|
40
+ next unless issue.github_prs
41
+
42
+ issue.github_prs.each do |pr|
43
+ next unless pr.closed_at
44
+
45
+ result << pr
46
+ end
47
+ end
48
+ result.uniq
49
+ end
50
+
51
+ def value_for_item item
52
+ divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
53
+ ((item.closed_at - item.opened_at) / divisor).ceil
54
+ end
55
+
56
+ def label_cycletime value
57
+ case @cycletime_unit
58
+ when :minutes then label_minutes(value)
59
+ when :hours then label_hours(value)
60
+ when :days then label_days(value)
61
+ end
62
+ end
63
+
64
+ def title_for_item count:, value:
65
+ "#{count} PR#{'s' unless count == 1} closed in #{label_cycletime value}"
66
+ end
67
+
68
+ def sort_items items
69
+ items.sort_by(&:opened_at)
70
+ end
71
+
72
+ def label_for_item item, hint:
73
+ label = "#{item.number} #{item.title}"
74
+ label << hint if hint
75
+ label
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
6
+ def initialize block
7
+ super()
8
+
9
+ @cycletime_unit = :days
10
+ @y_axis_title = 'Cycle time in days'
11
+
12
+ header_text 'Pull Request (PR) Scatterplot'
13
+ description_text <<-HTML
14
+ <div class="p">
15
+ This graph shows the cycle time for all closed pull requests (time from opened to closed).
16
+ </div>
17
+ #{describe_non_working_days}
18
+ HTML
19
+
20
+ init_configuration_block(block) do
21
+ grouping_rules do |pull_request, _rule|
22
+ rules.label = pull_request.repo
23
+ end
24
+ end
25
+ end
26
+
27
+ def cycletime_unit unit
28
+ unless %i[minutes hours days].include?(unit)
29
+ raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
30
+ end
31
+
32
+ @cycletime_unit = unit
33
+ @y_axis_title = "Cycle time in #{unit}"
34
+ end
35
+
36
+ def all_items
37
+ result = []
38
+ issues.each do |issue|
39
+ issue.github_prs&.each do |pr|
40
+ result << pr if pr.closed_at
41
+ end
42
+ end
43
+ result
44
+ end
45
+
46
+ def x_value pull_request
47
+ pull_request.closed_at
48
+ end
49
+
50
+ def y_value pull_request
51
+ divisor = { minutes: 60, hours: 3600, days: 86_400 }[@cycletime_unit]
52
+ ((pull_request.closed_at - pull_request.opened_at) / divisor).round
53
+ end
54
+
55
+ def label_cycletime value
56
+ case @cycletime_unit
57
+ when :minutes then label_minutes(value)
58
+ when :hours then label_hours(value)
59
+ when :days then label_days(value)
60
+ end
61
+ end
62
+
63
+ def title_value pull_request, rules: nil
64
+ age_label = label_cycletime y_value(pull_request)
65
+ "#{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
66
+ end
67
+
68
+ def lines_changed_text pull_request
69
+ return '' unless pull_request.changed_files
70
+
71
+ additions = pull_request.additions || 0
72
+ deletions = pull_request.deletions || 0
73
+ text = +' | Lines changed: ['
74
+ text << "+#{to_human_readable additions}" unless additions.zero?
75
+ text << ' ' if additions != 0 && deletions != 0
76
+ text << "-#{to_human_readable deletions}" unless deletions.zero?
77
+ text << "], Files changed: #{to_human_readable pull_request.changed_files}"
78
+ text
79
+ end
80
+
81
+ end
@@ -134,7 +134,7 @@ class SprintBurndown < ChartBase
134
134
 
135
135
  estimate_display_name = current_board.estimation_configuration.display_name
136
136
 
137
- issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
137
+ issue_completed_time = issue.started_stopped_times.last
138
138
  completed_has_been_tracked = false
139
139
 
140
140
  issue.changes.each do |change|
@@ -36,7 +36,7 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
- raise "raw cannot be nil" if raw.nil?
39
+ raise 'raw cannot be nil' if raw.nil?
40
40
 
41
41
  category_config = raw['statusCategory']
42
42
  raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/throughput_chart'
4
+
5
+ class ThroughputByCompletedResolutionChart < ThroughputChart
6
+ def initialize block
7
+ super
8
+ header_text 'Throughput, grouped by completion status and resolution'
9
+ description_text nil
10
+ end
11
+
12
+ def default_grouping_rules issue, rules
13
+ status, resolution = issue.status_resolution_at_done
14
+ if resolution
15
+ rules.label = "#{status.name}:#{resolution}"
16
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}, resolution: #{resolution.inspect}"
17
+ else
18
+ rules.label = status.name
19
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}"
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+
3
5
  class ThroughputChart < ChartBase
4
6
  include GroupableIssueChart
5
7
 
@@ -10,45 +12,54 @@ class ThroughputChart < ChartBase
10
12
 
11
13
  header_text 'Throughput Chart'
12
14
  description_text <<-TEXT
13
- <div class="p">
14
- This chart shows how many items we completed per week
15
+ <div>Throughput data is very useful for#{' '}
16
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
17
+ to determine when we'll be done. Try it now with the
18
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
19
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
20
+ <%= @not_started_count %> items you currently have in your backlog.
15
21
  </div>
16
22
  #{describe_non_working_days}
17
23
  TEXT
18
24
  @x_axis_title = nil
19
25
  @y_axis_title = 'Count of items'
20
26
 
21
-
22
27
  init_configuration_block(block) do
23
- grouping_rules do |issue, rule|
24
- rule.label = issue.type
25
- rule.color = color_for type: issue.type
26
- end
28
+ grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
27
29
  end
28
30
  end
29
31
 
30
32
  def run
33
+ # This is saved as an instance variable so that it's accessible later when rendering the description text
34
+ @not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
35
+
31
36
  completed_issues = completed_issues_in_range include_unstarted: true
32
37
  rules_to_issues = group_issues completed_issues
33
38
  data_sets = []
34
- if rules_to_issues.size > 1
35
- data_sets << weekly_throughput_dataset(
36
- completed_issues: completed_issues,
37
- label: 'Totals',
38
- color: CssVariable['--throughput_chart_total_line_color'],
39
- dashed: true
40
- )
41
- end
39
+ total_data_set = weekly_throughput_dataset(
40
+ completed_issues: completed_issues,
41
+ label: 'Totals',
42
+ color: CssVariable['--throughput_chart_total_line_color'],
43
+ dashed: true
44
+ )
45
+ @throughput_samples = total_data_set[:data].collect { |d| d[:y] }
46
+ data_sets << total_data_set if rules_to_issues.size > 1
42
47
 
43
48
  rules_to_issues.each_key do |rules|
44
49
  data_sets << weekly_throughput_dataset(
45
- completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
50
+ completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
51
+ label_hint: rules.label_hint
46
52
  )
47
53
  end
48
54
 
49
55
  wrap_and_render(binding, __FILE__)
50
56
  end
51
57
 
58
+ def default_grouping_rules issue, rule
59
+ rule.label = issue.type
60
+ rule.color = color_for type: issue.type
61
+ end
62
+
52
63
  def calculate_time_periods
53
64
  first_day = @date_range.begin
54
65
  first_day = case first_day.wday
@@ -68,10 +79,13 @@ class ThroughputChart < ChartBase
68
79
  end
69
80
  end
70
81
 
71
- def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
82
+ def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
72
83
  result = {
73
84
  label: label,
74
- data: throughput_dataset(periods: calculate_time_periods, completed_issues: completed_issues),
85
+ label_hint: label_hint,
86
+ data: throughput_dataset(
87
+ periods: calculate_time_periods, completed_issues: completed_issues, label_hint: label_hint
88
+ ),
75
89
  fill: false,
76
90
  showLine: true,
77
91
  borderColor: color,
@@ -82,19 +96,33 @@ class ThroughputChart < ChartBase
82
96
  result
83
97
  end
84
98
 
85
- def throughput_dataset periods:, completed_issues:
99
+ def throughput_forecaster_url
100
+ params = {
101
+ throughputMode: 'data',
102
+ samplesText: @throughput_samples.join(','),
103
+ storyLow: @not_started_count,
104
+ storyHigh: @not_started_count
105
+ }
106
+
107
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
108
+ "https://focusedobjective.com/throughput?#{query}"
109
+ end
110
+
111
+ def throughput_dataset periods:, completed_issues:, label_hint: nil
86
112
  periods.collect do |period|
87
113
  closed_issues = completed_issues.filter_map do |issue|
88
- stop_date = issue.board.cycletime.started_stopped_dates(issue).last
114
+ stop_date = issue.started_stopped_dates.last
89
115
  [stop_date, issue] if stop_date && period.include?(stop_date)
90
116
  end
91
117
 
92
118
  date_label = "on #{period.end}"
93
119
  date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
94
120
 
95
- { y: closed_issues.size,
121
+ with_label_hint = label_hint ? " with #{label_hint}" : ''
122
+ {
123
+ y: closed_issues.size,
96
124
  x: "#{period.end}T23:59:59",
97
- title: ["#{closed_issues.size} items completed #{date_label}"] +
125
+ title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
98
126
  closed_issues.collect do |_stop_date, issue|
99
127
  hint = @issue_hints&.fetch(issue, nil)
100
128
  "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
@@ -30,7 +30,7 @@ class TimeBasedScatterplot < ChartBase
30
30
  label = rules.label
31
31
  color = rules.color
32
32
  percent_line = calculate_percent_line items_by_type
33
- data = items_by_type.filter_map { |item| data_for_item(item) }
33
+ data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
34
34
  data_sets << {
35
35
  label: "#{label} (85% at #{label_days(percent_line)})",
36
36
  data: data,
@@ -79,7 +79,7 @@ class TimeBasedScatterplot < ChartBase
79
79
  }
80
80
  end
81
81
 
82
- def data_for_item item
82
+ def data_for_item item, rules: nil
83
83
  y = y_value(item)
84
84
  return nil if y < 1 # These will get called out on the quality report
85
85
 
@@ -88,7 +88,7 @@ class TimeBasedScatterplot < ChartBase
88
88
  {
89
89
  y: y,
90
90
  x: chart_format(x_value(item)),
91
- title: [title_value(item)]
91
+ title: [title_value(item, rules: rules)]
92
92
  }
93
93
  end
94
94
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jirametrics
3
3
  version: !ruby/object:Gem::Version
4
- version: '2.24'
4
+ version: 2.25pre7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
@@ -130,6 +130,8 @@ files:
130
130
  - lib/jirametrics/jira_gateway.rb
131
131
  - lib/jirametrics/project_config.rb
132
132
  - lib/jirametrics/pull_request.rb
133
+ - lib/jirametrics/pull_request_cycle_time_histogram.rb
134
+ - lib/jirametrics/pull_request_cycle_time_scatterplot.rb
133
135
  - lib/jirametrics/pull_request_review.rb
134
136
  - lib/jirametrics/raw_javascript.rb
135
137
  - lib/jirametrics/rules.rb
@@ -141,6 +143,7 @@ files:
141
143
  - lib/jirametrics/status.rb
142
144
  - lib/jirametrics/status_collection.rb
143
145
  - lib/jirametrics/stitcher.rb
146
+ - lib/jirametrics/throughput_by_completed_resolution_chart.rb
144
147
  - lib/jirametrics/throughput_chart.rb
145
148
  - lib/jirametrics/time_based_histogram.rb
146
149
  - lib/jirametrics/time_based_scatterplot.rb