jirametrics 2.24 → 2.25pre4

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.

Potentially problematic release.


This version of jirametrics might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab33e299bd374f28754ecfbd750f6afd89861ecf64a323f28d4678d7a22959d
4
- data.tar.gz: a0bf98e4d51b86eaa36aef8c6bfb69e8cdba958d3afeaf67da71a37ff6eb3ae5
3
+ metadata.gz: c7772ffefa9f06f4110daac60d497baad46ee6add7464459be7a7632af7c54e3
4
+ data.tar.gz: 8077243946e6ca656854fbc0e532fb1883e87dfe274a09b5ba65a7741f5077a0
5
5
  SHA512:
6
- metadata.gz: 7c017b109ce143403190db03124148def9be7a92fe20961f6f7c42f459482a4056018601f131d6ca6275889e7231e54df54befbccb4279de0073838d50280ff4
7
- data.tar.gz: 07b783818d028d3d95fd1cd861f272fe624c46fe3d4b671f3e2e6fede8fcdc694e5118da888c7c12113838e37711ccd6935a54261d4edf5ceaac4e29c6a08a73
6
+ metadata.gz: 44769e46613e9ef04b5cb452123eb2d56f46abe917d9c6a692ce4ac859bb018db265771fbe0b121275a4ad449921cd808323711d2f211bf343ab666df7b1b90b
7
+ data.tar.gz: ae489cc2e1d661d3531fcda1938e79e44152fbd530f6b5dada0853106cbcf23b7932e1f977be68e9a05879c94315f2efc0ad5c6058cc5ec40cfe44535bd67029
@@ -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]
@@ -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(
@@ -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,7 +67,16 @@ 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
81
  throughput_chart do
73
82
  header_text nil
@@ -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
@@ -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
@@ -202,6 +202,7 @@ class Issue
202
202
  category_ids = find_status_category_ids_by_names category_names
203
203
 
204
204
  status_changes.each do |change|
205
+ puts "Debugging #{self.key} #{change.inspect} #{change.raw.inspect}" if change.value_id.nil?
205
206
  to_status = find_or_create_status(id: change.value_id, name: change.value)
206
207
  id = to_status.category.id
207
208
  return change if category_ids.include? id
@@ -560,7 +561,7 @@ class Issue
560
561
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
561
562
  # It does not include data before issue start or after issue end
562
563
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
563
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
564
+ issue_start, issue_stop = started_stopped_times
564
565
  return [0.0, 0.0] if !issue_start || issue_start > end_time
565
566
 
566
567
  value_add_time = 0.0
@@ -753,12 +754,20 @@ class Issue
753
754
  end
754
755
  end
755
756
 
757
+ def started_stopped_times
758
+ board.cycletime.started_stopped_times(self)
759
+ end
760
+
761
+ def started_stopped_dates
762
+ board.cycletime.started_stopped_dates(self)
763
+ end
764
+
756
765
  def status_changes
757
766
  @changes.select { |change| change.status? }
758
767
  end
759
768
 
760
769
  def status_resolution_at_done
761
- done_time = board.cycletime.started_stopped_times(self).last
770
+ done_time = started_stopped_times.last
762
771
  return [nil, nil] if done_time.nil?
763
772
 
764
773
  status_change = nil
@@ -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 == 0
75
+ text << ' ' if additions != 0 && deletions != 0
76
+ text << "-#{to_human_readable deletions}" unless deletions == 0
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|
@@ -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,15 +12,18 @@ 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
28
  grouping_rules do |issue, rule|
24
29
  rule.label = issue.type
@@ -28,16 +33,21 @@ class ThroughputChart < ChartBase
28
33
  end
29
34
 
30
35
  def run
36
+ # This is saved as an instance variable so that it's accessible later when rendering the description text
37
+ @not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
38
+
31
39
  completed_issues = completed_issues_in_range include_unstarted: true
32
40
  rules_to_issues = group_issues completed_issues
33
41
  data_sets = []
42
+ total_data_set = weekly_throughput_dataset(
43
+ completed_issues: completed_issues,
44
+ label: 'Totals',
45
+ color: CssVariable['--throughput_chart_total_line_color'],
46
+ dashed: true
47
+ )
48
+ @throughput_samples = total_data_set[:data].collect { |d| d[:y] }
34
49
  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
- )
50
+ data_sets << total_data_set
41
51
  end
42
52
 
43
53
  rules_to_issues.each_key do |rules|
@@ -82,17 +92,30 @@ class ThroughputChart < ChartBase
82
92
  result
83
93
  end
84
94
 
95
+ def throughput_forecaster_url
96
+ params = {
97
+ throughputMode: 'data',
98
+ samplesText: @throughput_samples.join(','),
99
+ storyLow: @not_started_count,
100
+ storyHigh: @not_started_count
101
+ }
102
+
103
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
104
+ "https://focusedobjective.com/throughput?#{query}"
105
+ end
106
+
85
107
  def throughput_dataset periods:, completed_issues:
86
108
  periods.collect do |period|
87
109
  closed_issues = completed_issues.filter_map do |issue|
88
- stop_date = issue.board.cycletime.started_stopped_dates(issue).last
110
+ stop_date = issue.started_stopped_dates.last
89
111
  [stop_date, issue] if stop_date && period.include?(stop_date)
90
112
  end
91
113
 
92
114
  date_label = "on #{period.end}"
93
115
  date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
94
116
 
95
- { y: closed_issues.size,
117
+ {
118
+ y: closed_issues.size,
96
119
  x: "#{period.end}T23:59:59",
97
120
  title: ["#{closed_issues.size} items completed #{date_label}"] +
98
121
  closed_issues.collect do |_stop_date, issue|
@@ -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.25pre4
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