jirametrics 2.10 → 2.30

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +62 -17
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +63 -11
  12. data/lib/jirametrics/board_config.rb +5 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +49 -19
  17. data/lib/jirametrics/chart_base.rb +147 -7
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +128 -71
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  37. data/lib/jirametrics/examples/standard_project.rb +42 -27
  38. data/lib/jirametrics/expedited_chart.rb +3 -1
  39. data/lib/jirametrics/exporter.rb +28 -8
  40. data/lib/jirametrics/file_config.rb +10 -12
  41. data/lib/jirametrics/file_system.rb +59 -3
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +6 -2
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +12 -3
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  56. data/lib/jirametrics/html/index.css +323 -63
  57. data/lib/jirametrics/html/index.erb +17 -19
  58. data/lib/jirametrics/html/index.js +164 -0
  59. data/lib/jirametrics/html/legacy_colors.css +174 -0
  60. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  61. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  62. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  63. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  64. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  65. data/lib/jirametrics/html_generator.rb +32 -0
  66. data/lib/jirametrics/html_report_config.rb +52 -55
  67. data/lib/jirametrics/issue.rb +347 -103
  68. data/lib/jirametrics/issue_collection.rb +33 -0
  69. data/lib/jirametrics/issue_printer.rb +97 -0
  70. data/lib/jirametrics/jira_gateway.rb +81 -14
  71. data/lib/jirametrics/mcp_server.rb +531 -0
  72. data/lib/jirametrics/project_config.rb +151 -18
  73. data/lib/jirametrics/pull_request.rb +30 -0
  74. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  75. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  76. data/lib/jirametrics/pull_request_review.rb +13 -0
  77. data/lib/jirametrics/raw_javascript.rb +17 -0
  78. data/lib/jirametrics/settings.json +6 -1
  79. data/lib/jirametrics/sprint.rb +13 -0
  80. data/lib/jirametrics/sprint_burndown.rb +45 -37
  81. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  82. data/lib/jirametrics/status.rb +3 -0
  83. data/lib/jirametrics/status_collection.rb +7 -0
  84. data/lib/jirametrics/stitcher.rb +81 -0
  85. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  86. data/lib/jirametrics/throughput_chart.rb +73 -23
  87. data/lib/jirametrics/time_based_histogram.rb +139 -0
  88. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  89. data/lib/jirametrics/user.rb +12 -0
  90. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  91. data/lib/jirametrics.rb +83 -64
  92. metadata +66 -6
@@ -29,6 +29,8 @@ class SprintBurndown < ChartBase
29
29
  </div>
30
30
  #{describe_non_working_days}
31
31
  TEXT
32
+ @x_axis_title = 'Date'
33
+ @y_axis_title = 'Items remaining'
32
34
  end
33
35
 
34
36
  def options= arg
@@ -48,8 +50,9 @@ class SprintBurndown < ChartBase
48
50
  end
49
51
 
50
52
  def run
51
- sprints = sprints_in_time_range all_boards[board_id]
52
- return nil if sprints.empty?
53
+ return nil unless current_board.scrum?
54
+
55
+ sprints = sprints_in_time_range current_board
53
56
 
54
57
  change_data_by_sprint = {}
55
58
  sprints.each do |sprint|
@@ -63,7 +66,7 @@ class SprintBurndown < ChartBase
63
66
  result = +''
64
67
  result << render_top_text(binding)
65
68
 
66
- possible_colours = (1..5).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
69
+ possible_colours = (1..ChartBase::OKABE_ITO_PALETTE.size).collect { |i| CssVariable["--sprint-burndown-sprint-color-#{i}"] }
67
70
  charts_to_generate = []
68
71
  charts_to_generate << [:data_set_by_story_points, 'Story Points'] if @use_story_points
69
72
  charts_to_generate << [:data_set_by_story_counts, 'Story Count'] if @use_story_counts
@@ -110,6 +113,9 @@ class SprintBurndown < ChartBase
110
113
 
111
114
  def sprints_in_time_range board
112
115
  board.sprints.select do |sprint|
116
+ # If it's never been started then it's just a holding area. Ignore it.
117
+ next if sprint.future?
118
+
113
119
  sprint_end_time = sprint.completed_time || sprint.end_time
114
120
  sprint_start_time = sprint.start_time
115
121
  next false if sprint_start_time.nil?
@@ -121,12 +127,14 @@ class SprintBurndown < ChartBase
121
127
 
122
128
  # select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
123
129
  def changes_for_one_issue issue:, sprint:
124
- story_points = 0.0
130
+ estimate = 0.0
125
131
  ever_in_sprint = false
126
132
  currently_in_sprint = false
127
133
  change_data = []
128
134
 
129
- issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
135
+ estimate_display_name = current_board.estimation_configuration.display_name
136
+
137
+ issue_completed_time = issue.started_stopped_times.last
130
138
  completed_has_been_tracked = false
131
139
 
132
140
  issue.changes.each do |change|
@@ -140,26 +148,26 @@ class SprintBurndown < ChartBase
140
148
  if currently_in_sprint == false && in_change_item
141
149
  action = :enter_sprint
142
150
  ever_in_sprint = true
143
- value = story_points
151
+ value = estimate
144
152
  elsif currently_in_sprint && in_change_item == false
145
153
  action = :leave_sprint
146
- value = -story_points
154
+ value = -estimate
147
155
  end
148
156
  currently_in_sprint = in_change_item
149
- elsif change.story_points? && (issue_completed_time.nil? || change.time < issue_completed_time)
157
+ elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
150
158
  action = :story_points
151
- story_points = change.value.to_f
152
- value = story_points - change.old_value.to_f
159
+ estimate = change.value.to_f
160
+ value = estimate - change.old_value.to_f
153
161
  elsif completed_has_been_tracked == false && change.time == issue_completed_time
154
162
  completed_has_been_tracked = true
155
163
  action = :issue_stopped
156
- value = -story_points
164
+ value = -estimate
157
165
  end
158
166
 
159
167
  next unless action
160
168
 
161
169
  change_data << SprintIssueChangeData.new(
162
- time: change.time, issue: issue, action: action, value: value, story_points: story_points
170
+ time: change.time, issue: issue, action: action, value: value, estimate: estimate
163
171
  )
164
172
  end
165
173
 
@@ -176,7 +184,7 @@ class SprintBurndown < ChartBase
176
184
  summary_stats = SprintSummaryStats.new
177
185
  summary_stats.completed = 0.0
178
186
 
179
- story_points = 0.0
187
+ estimate = 0.0
180
188
  start_data_written = false
181
189
  data_set = []
182
190
 
@@ -185,11 +193,11 @@ class SprintBurndown < ChartBase
185
193
  change_data_for_sprint.each do |change_data|
186
194
  if start_data_written == false && change_data.time >= sprint.start_time
187
195
  data_set << {
188
- y: story_points,
196
+ y: estimate,
189
197
  x: chart_format(sprint.start_time),
190
- title: "Sprint started with #{story_points} points"
198
+ title: "Sprint started with #{estimate} points"
191
199
  }
192
- summary_stats.started = story_points
200
+ summary_stats.started = estimate
193
201
  start_data_written = true
194
202
  end
195
203
 
@@ -198,12 +206,12 @@ class SprintBurndown < ChartBase
198
206
  case change_data.action
199
207
  when :enter_sprint
200
208
  issues_currently_in_sprint << change_data.issue.key
201
- story_points += change_data.story_points
209
+ estimate += change_data.estimate
202
210
  when :leave_sprint
203
211
  issues_currently_in_sprint.delete change_data.issue.key
204
- story_points -= change_data.story_points
212
+ estimate -= change_data.estimate
205
213
  when :story_points
206
- story_points += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
214
+ estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
207
215
  end
208
216
 
209
217
  next unless change_data.time >= sprint.start_time
@@ -213,26 +221,26 @@ class SprintBurndown < ChartBase
213
221
  when :story_points
214
222
  next unless issues_currently_in_sprint.include? change_data.issue.key
215
223
 
216
- old_story_points = change_data.story_points - change_data.value
217
- message = "Story points changed from #{old_story_points} points to #{change_data.story_points} points"
224
+ old_estimate = change_data.estimate - change_data.value
225
+ message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
218
226
  summary_stats.points_values_changed = true
219
227
  when :enter_sprint
220
- message = "Added to sprint with #{change_data.story_points || 'no'} points"
221
- summary_stats.added += change_data.story_points
228
+ message = "Added to sprint with #{change_data.estimate || 'no'} points"
229
+ summary_stats.added += change_data.estimate
222
230
  when :issue_stopped
223
- story_points -= change_data.story_points
224
- message = "Completed with #{change_data.story_points || 'no'} points"
231
+ estimate -= change_data.estimate
232
+ message = "Completed with #{change_data.estimate || 'no'} points"
225
233
  issues_currently_in_sprint.delete change_data.issue.key
226
- summary_stats.completed += change_data.story_points
234
+ summary_stats.completed += change_data.estimate
227
235
  when :leave_sprint
228
- message = "Removed from sprint with #{change_data.story_points || 'no'} points"
229
- summary_stats.removed += change_data.story_points
236
+ message = "Removed from sprint with #{change_data.estimate || 'no'} points"
237
+ summary_stats.removed += change_data.estimate
230
238
  else
231
239
  raise "Unexpected action: #{change_data.action}"
232
240
  end
233
241
 
234
242
  data_set << {
235
- y: story_points,
243
+ y: estimate,
236
244
  x: chart_format(change_data.time),
237
245
  title: "#{change_data.issue.key} #{message}"
238
246
  }
@@ -241,27 +249,27 @@ class SprintBurndown < ChartBase
241
249
  unless start_data_written
242
250
  # There was nothing that triggered us to write the sprint started block so do it now.
243
251
  data_set << {
244
- y: story_points,
252
+ y: estimate,
245
253
  x: chart_format(sprint.start_time),
246
- title: "Sprint started with #{story_points} points"
254
+ title: "Sprint started with #{estimate} points"
247
255
  }
248
- summary_stats.started = story_points
256
+ summary_stats.started = estimate
249
257
  end
250
258
 
251
259
  if sprint.completed_time
252
260
  data_set << {
253
- y: story_points,
261
+ y: estimate,
254
262
  x: chart_format(sprint.completed_time),
255
- title: "Sprint ended with #{story_points} points unfinished"
263
+ title: "Sprint ended with #{estimate} points unfinished"
256
264
  }
257
- summary_stats.remaining = story_points
265
+ summary_stats.remaining = estimate
258
266
  end
259
267
 
260
268
  unless sprint.completed_at?(time_range.end)
261
269
  data_set << {
262
- y: story_points,
270
+ y: estimate,
263
271
  x: chart_format(time_range.end),
264
- title: "Sprint still active. #{story_points} points still in progress."
272
+ title: "Sprint still active. #{estimate} points still in progress."
265
273
  }
266
274
  end
267
275
 
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class SprintIssueChangeData
6
6
  include ValueEquality
7
- attr_reader :time, :action, :value, :issue, :story_points
7
+ attr_reader :time, :action, :value, :issue, :estimate
8
8
 
9
- def initialize time:, action:, value:, issue:, story_points:
9
+ def initialize time:, action:, value:, issue:, estimate:
10
10
  @time = time
11
11
  @action = action
12
12
  @value = value
13
13
  @issue = issue
14
- @story_points = story_points
14
+ @estimate = estimate
15
15
  end
16
16
 
17
17
  def inspect
@@ -36,7 +36,10 @@ class Status
36
36
  end
37
37
 
38
38
  def self.from_raw raw
39
+ raise 'raw cannot be nil' if raw.nil?
40
+
39
41
  category_config = raw['statusCategory']
42
+ raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
40
43
 
41
44
  Status.new(
42
45
  name: raw['name'],
@@ -16,6 +16,13 @@ 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
+
23
+ status
24
+ end
25
+
19
26
  def find_all_by_name identifier
20
27
  name, id = parse_name_id identifier
21
28
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Stitcher < HtmlGenerator
4
+ class StitchContent
5
+ include ValueEquality
6
+
7
+ attr_reader :file, :title, :content, :type
8
+
9
+ def initialize file:, title:, type:, content:
10
+ @file = file
11
+ @title = title
12
+ @content = content
13
+ @type = type
14
+ end
15
+ end
16
+
17
+ attr_reader :loaded_files, :all_stitches
18
+
19
+ def initialize file_system:
20
+ super()
21
+ self.file_system = file_system
22
+ @all_stitches = []
23
+ @loaded_files = []
24
+ end
25
+
26
+ def run stitch_file:
27
+ output_filename = make_output_filename stitch_file
28
+ file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
29
+ erb = ERB.new file_system.load(stitch_file)
30
+ @sections = [[erb.result(binding), :body]]
31
+ create_html output_filename: output_filename, settings: {}
32
+ end
33
+
34
+ def make_output_filename input_filename
35
+ if /^(.+)\.erb$/ =~ input_filename
36
+ "#{$1}.html"
37
+ else
38
+ "#{input_filename}.html"
39
+ end
40
+ end
41
+
42
+ def grab_by_title title, from_file:, type: 'chart'
43
+ parse_file from_file
44
+ stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
45
+ return stitch_content.content if stitch_content
46
+
47
+ file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
48
+ ''
49
+ end
50
+
51
+ def parse_file filename
52
+ return false if @loaded_files.include? filename
53
+
54
+ # To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
55
+ regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
56
+ content = nil
57
+ file_system.load(filename).lines do |line|
58
+ matches = line.match(regex)
59
+ if matches
60
+ if matches[:seam] == 'start'
61
+ content = +''
62
+ else
63
+ if content.nil? || content.strip.empty?
64
+ file_system.warning "Seam found with no content in #{filename.inspect}: " \
65
+ "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
66
+ "title=#{matches[:title].strip.inspect}"
67
+ end
68
+ @all_stitches << Stitcher::StitchContent.new(
69
+ file: filename, title: matches[:title], type: matches[:type], content: content
70
+ )
71
+ content = nil
72
+ end
73
+ elsif content
74
+ content << line
75
+ end
76
+ end
77
+
78
+ @loaded_files << filename
79
+ true
80
+ end
81
+ end
@@ -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,42 +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
24
+ @x_axis_title = nil
25
+ @y_axis_title = 'Count of items'
18
26
 
19
27
  init_configuration_block(block) do
20
- grouping_rules do |issue, rule|
21
- rule.label = issue.type
22
- rule.color = color_for type: issue.type
23
- end
28
+ grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
24
29
  end
25
30
  end
26
31
 
27
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
+
28
36
  completed_issues = completed_issues_in_range include_unstarted: true
29
37
  rules_to_issues = group_issues completed_issues
30
38
  data_sets = []
31
- if rules_to_issues.size > 1
32
- data_sets << weekly_throughput_dataset(
33
- completed_issues: completed_issues,
34
- label: 'Totals',
35
- color: CssVariable['--throughput_chart_total_line_color'],
36
- dashed: true
37
- )
38
- 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
39
47
 
40
48
  rules_to_issues.each_key do |rules|
41
49
  data_sets << weekly_throughput_dataset(
42
- 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
43
52
  )
44
53
  end
45
54
 
46
55
  wrap_and_render(binding, __FILE__)
47
56
  end
48
57
 
58
+ def default_grouping_rules issue, rule
59
+ rule.label = issue.type
60
+ rule.color = color_for type: issue.type
61
+ end
62
+
49
63
  def calculate_time_periods
50
64
  first_day = @date_range.begin
51
65
  first_day = case first_day.wday
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
65
79
  end
66
80
  end
67
81
 
68
- def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
82
+ def calculate_custom_periods
83
+ last_days = @issue_periods.values.compact.uniq.sort
84
+ last_days.each_with_index.map do |last_day, i|
85
+ first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
86
+ first_day..last_day
87
+ end
88
+ end
89
+
90
+ def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
91
+ periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
69
92
  result = {
70
93
  label: label,
71
- data: throughput_dataset(periods: calculate_time_periods, completed_issues: completed_issues),
94
+ label_hint: label_hint,
95
+ data: throughput_dataset(
96
+ periods: periods, completed_issues: completed_issues, label_hint: label_hint
97
+ ),
72
98
  fill: false,
73
99
  showLine: true,
74
100
  borderColor: color,
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
79
105
  result
80
106
  end
81
107
 
82
- def throughput_dataset periods:, completed_issues:
108
+ def throughput_forecaster_url
109
+ params = {
110
+ throughputMode: 'data',
111
+ samplesText: @throughput_samples.join(','),
112
+ storyLow: @not_started_count,
113
+ storyHigh: @not_started_count
114
+ }
115
+
116
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
117
+ "https://focusedobjective.com/throughput?#{query}"
118
+ end
119
+
120
+ def throughput_dataset periods:, completed_issues:, label_hint: nil
121
+ custom_mode = @issue_periods&.values&.any?
83
122
  periods.collect do |period|
84
123
  closed_issues = completed_issues.filter_map do |issue|
85
- stop_date = issue.board.cycletime.started_stopped_dates(issue).last
86
- [stop_date, issue] if stop_date && period.include?(stop_date)
124
+ stop_date = issue.started_stopped_dates.last
125
+ next unless stop_date
126
+
127
+ if custom_mode
128
+ [stop_date, issue] if @issue_periods[issue] == period.end
129
+ elsif period.include?(stop_date)
130
+ [stop_date, issue]
131
+ end
87
132
  end
88
133
 
89
134
  date_label = "on #{period.end}"
90
135
  date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
91
136
 
92
- { y: closed_issues.size,
137
+ with_label_hint = label_hint ? " with #{label_hint}" : ''
138
+ {
139
+ y: closed_issues.size,
93
140
  x: "#{period.end}T23:59:59",
94
- title: ["#{closed_issues.size} items completed #{date_label}"] +
95
- closed_issues.collect { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
141
+ title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
142
+ closed_issues.collect do |_stop_date, issue|
143
+ hint = @issue_hints&.fetch(issue, nil)
144
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
145
+ end
96
146
  }
97
147
  end
98
148
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class TimeBasedHistogram < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ attr_reader :show_stats
9
+
10
+ def initialize
11
+ super
12
+
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+ end
16
+
17
+ def percentiles percs = nil
18
+ @percentiles = percs unless percs.nil?
19
+ @percentiles
20
+ end
21
+
22
+ def disable_stats
23
+ @show_stats = false
24
+ end
25
+
26
+ def run
27
+ histogram_items = all_items
28
+ rules_to_items = group_issues histogram_items
29
+
30
+ the_stats = {}
31
+
32
+ overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
33
+ the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
34
+ data_sets = rules_to_items.keys.collect do |rules|
35
+ the_label = rules.label
36
+ the_histogram = histogram_data_for(items: rules_to_items[rules])
37
+ if @show_stats
38
+ the_stats[the_label] = stats_for(
39
+ histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
40
+ )
41
+ end
42
+
43
+ data_set_for(
44
+ histogram_data: the_histogram,
45
+ label: the_label,
46
+ color: rules.color
47
+ )
48
+ end
49
+
50
+ if data_sets.empty?
51
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
52
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
53
+ end
54
+
55
+ wrap_and_render(binding, __FILE__)
56
+ end
57
+
58
+ def histogram_data_for items:
59
+ items_hash = {}
60
+ items.each do |item|
61
+ days = value_for_item item
62
+ (items_hash[days] ||= []) << item if days.positive?
63
+ end
64
+ items_hash
65
+ end
66
+
67
+ def stats_for histogram_data:, percentiles:
68
+ return {} if histogram_data.empty?
69
+
70
+ total_values = histogram_data.values.sum
71
+
72
+ # Calculate the average
73
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
74
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
75
+
76
+ # Find the mode (or modes!) and the spread of the distribution
77
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
78
+ max_freq = sorted_histogram[-1][1]
79
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
80
+
81
+ minmax = histogram_data.keys.minmax
82
+
83
+ # Calculate percentiles
84
+ sorted_values = histogram_data.keys.sort
85
+ cumulative_counts = {}
86
+ cumulative_sum = 0
87
+
88
+ sorted_values.each do |value|
89
+ cumulative_sum += histogram_data[value]
90
+ cumulative_counts[value] = cumulative_sum
91
+ end
92
+
93
+ percentile_results = {}
94
+ percentiles.each do |percentile|
95
+ rank = (percentile / 100.0) * total_values
96
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
97
+ percentile_results[percentile] = percentile_value
98
+ end
99
+
100
+ {
101
+ average: average,
102
+ mode: mode.collect(&:first).sort,
103
+ min: minmax[0],
104
+ max: minmax[1],
105
+ percentiles: percentile_results
106
+ }
107
+ end
108
+
109
+ def sort_items items
110
+ items
111
+ end
112
+
113
+ def label_for_item item, hint:
114
+ raise NotImplementedError, "#{self.class} must implement label_for_item"
115
+ end
116
+
117
+ def data_set_for histogram_data:, label:, color:
118
+ {
119
+ type: 'bar',
120
+ label: label,
121
+ data: histogram_data.keys.sort.filter_map do |days|
122
+ items = histogram_data[days]
123
+ next if items.empty?
124
+
125
+ {
126
+ x: days,
127
+ y: items.size,
128
+ title: [title_for_item(count: items.size, value: days)] +
129
+ sort_items(items).collect do |item|
130
+ hint = @issue_hints&.fetch(item, nil)
131
+ label_for_item(item, hint: hint)
132
+ end
133
+ }
134
+ end,
135
+ backgroundColor: color,
136
+ borderRadius: 0
137
+ }
138
+ end
139
+ end