jirametrics 2.4 → 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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  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 +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  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 +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  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 +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  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 +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'jirametrics/pull_request_review'
5
+
6
+ class PullRequest
7
+ attr_reader :raw
8
+
9
+ def initialize raw:
10
+ @raw = raw
11
+ end
12
+
13
+ def number = @raw['number']
14
+ def repo = @raw['repo']
15
+ def url = @raw['url']
16
+ def title = @raw['title']
17
+ def branch = @raw['branch']
18
+ def state = @raw['state']
19
+ def issue_keys = @raw['issue_keys']
20
+
21
+ def opened_at = Time.parse(@raw['opened_at'])
22
+ def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
23
+ def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
24
+
25
+ def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
26
+ def additions = @raw['additions']
27
+ def deletions = @raw['deletions']
28
+ def changed_files = @raw['changed_files']
29
+ def lines_changed = (additions || 0) + (deletions || 0)
30
+ end
@@ -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
+ rule.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,88 @@
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
+ rule.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
+ if @cycletime_unit == :days
52
+ tz = timezone_offset || '+00:00'
53
+ opened = pull_request.opened_at.getlocal(tz).to_date
54
+ closed = pull_request.closed_at.getlocal(tz).to_date
55
+ (closed - opened).to_i + 1
56
+ else
57
+ divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
58
+ ((pull_request.closed_at - pull_request.opened_at) / divisor).round
59
+ end
60
+ end
61
+
62
+ def label_cycletime value
63
+ case @cycletime_unit
64
+ when :minutes then label_minutes(value)
65
+ when :hours then label_hours(value)
66
+ when :days then label_days(value)
67
+ end
68
+ end
69
+
70
+ def title_value pull_request, rules: nil
71
+ age_label = label_cycletime y_value(pull_request)
72
+ keys = pull_request.issue_keys.join(', ')
73
+ "#{keys} | #{pull_request.title} | #{rules.label} | Age:#{age_label}#{lines_changed_text(pull_request)}"
74
+ end
75
+
76
+ def lines_changed_text pull_request
77
+ return '' unless pull_request.changed_files
78
+
79
+ additions = pull_request.additions || 0
80
+ deletions = pull_request.deletions || 0
81
+ text = +' | Lines changed: ['
82
+ text << "+#{to_human_readable additions}" unless additions.zero?
83
+ text << ' ' if additions != 0 && deletions != 0
84
+ text << "-#{to_human_readable deletions}" unless deletions.zero?
85
+ text << "], Files changed: #{to_human_readable pull_request.changed_files}"
86
+ text
87
+ end
88
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class PullRequestReview
6
+ def initialize raw:
7
+ @raw = raw
8
+ end
9
+
10
+ def author = @raw['author']
11
+ def state = @raw['state']
12
+ def submitted_at = Time.parse(@raw['submitted_at'])
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # When strings are serialized into JSON, they're converted to actual strings. The purpose
4
+ # of this class is to allow raw javascript to be passed through.
5
+ class RawJavascript
6
+ def initialize content
7
+ @content = content
8
+ end
9
+
10
+ def to_json(*_args)
11
+ @content
12
+ end
13
+
14
+ def == other
15
+ other.is_a?(RawJavascript) && to_json == other.to_json
16
+ end
17
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rules
4
- def ignore
5
- @ignore = true
4
+ def ignore value = true # rubocop:disable Style/OptionalBooleanParameter
5
+ @ignore = value
6
6
  end
7
7
 
8
8
  def ignored?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SelfOrIssueDispatcher
4
+ # rubocop:disable Style/ArgumentsForwarding
4
5
  def method_missing method_name, *args, &block
5
6
  raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
6
7
 
@@ -8,6 +9,7 @@ module SelfOrIssueDispatcher
8
9
  issue.__send__ method_name, *args, &block
9
10
  end
10
11
  end
12
+ # rubocop:enable Style/ArgumentsForwarding
11
13
 
12
14
  def respond_to_missing?(method_name, include_all = false)
13
15
  ::Issue.method_defined?(method_name.to_sym) || super
@@ -2,6 +2,14 @@
2
2
  "stalled_threshold_days": 5,
3
3
  "stalled_statuses": [],
4
4
 
5
- "blocked_link_text": [],
6
- "blocked_statuses": []
5
+ "blocked_link_text": ["is blocked by"],
6
+ "blocked_statuses": [],
7
+ "flagged_means_blocked": true,
8
+
9
+ "expedited_priority_names": ["Critical", "Highest"],
10
+ "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
11
+
12
+ "cache_cycletime_calculations": true,
13
+
14
+ "date_annotations": []
7
15
  }
@@ -12,6 +12,8 @@ class Sprint
12
12
 
13
13
  def id = @raw['id']
14
14
  def active? = (@raw['state'] == 'active')
15
+ def closed? = (@raw['state'] == 'closed')
16
+ def future? = (@raw['state'] == 'future')
15
17
 
16
18
  def completed_at? time
17
19
  completed_at = completed_time
@@ -35,6 +37,17 @@ class Sprint
35
37
  def goal = @raw['goal']
36
38
  def name = @raw['name']
37
39
 
40
+ def day_count
41
+ return '' if future?
42
+
43
+ if closed?
44
+ days = (completed_time.to_date - start_time.to_date).to_i + 1
45
+ else
46
+ days = (end_time.to_date - start_time.to_date).to_i + 1
47
+ end
48
+ "#{days} days"
49
+ end
50
+
38
51
  private
39
52
 
40
53
  def parse_time time_string
@@ -18,7 +18,7 @@ class SprintBurndown < ChartBase
18
18
  attr_accessor :board_id
19
19
 
20
20
  def initialize
21
- super()
21
+ super
22
22
 
23
23
  @summary_stats = {}
24
24
  header_text 'Sprint burndown'
@@ -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.stopped_time(issue)
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
 
@@ -172,11 +180,11 @@ class SprintBurndown < ChartBase
172
180
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
173
181
  end
174
182
 
175
- def data_set_by_story_points sprint:, change_data_for_sprint:
183
+ def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
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
@@ -3,30 +3,65 @@
3
3
  require 'jirametrics/value_equality'
4
4
 
5
5
  class Status
6
- include ValueEquality
7
- attr_reader :id, :category_name, :category_id, :project_id
6
+ attr_reader :id, :project_id, :category
8
7
  attr_accessor :name
9
8
 
10
- def initialize name: nil, id: nil, category_name: nil, category_id: nil, project_id: nil, raw: nil
11
- @name = name
12
- @id = id
13
- @category_name = category_name
14
- @category_id = category_id
15
- @project_id = project_id
9
+ class Category
10
+ attr_reader :id, :name, :key
11
+
12
+ def initialize id:, name:, key:
13
+ @id = id
14
+ @name = name
15
+ @key = key
16
+ end
16
17
 
17
- return unless raw
18
+ def to_s
19
+ "#{name.inspect}:#{id.inspect}"
20
+ end
18
21
 
19
- @raw = raw
20
- @name = raw['name']
21
- @id = raw['id'].to_i
22
+ def <=> other
23
+ id <=> other.id
24
+ end
25
+
26
+ def == other
27
+ id == other.id
28
+ end
29
+
30
+ def eql?(other) = id.eql?(other.id)
31
+ def hash = id.hash
32
+
33
+ def new? = (@key == 'new')
34
+ def indeterminate? = (@key == 'indeterminate')
35
+ def done? = (@key == 'done')
36
+ end
37
+
38
+ def self.from_raw raw
39
+ raise 'raw cannot be nil' if raw.nil?
22
40
 
23
41
  category_config = raw['statusCategory']
24
- @category_name = category_config['name']
25
- @category_id = category_config['id'].to_i
42
+ raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
43
+
44
+ Status.new(
45
+ name: raw['name'],
46
+ id: raw['id'].to_i,
47
+ category_name: category_config['name'],
48
+ category_id: category_config['id'].to_i,
49
+ category_key: category_config['key'],
50
+ project_id: raw['scope']&.[]('project')&.[]('id'),
51
+ artificial: false
52
+ )
53
+ end
54
+
55
+ def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
56
+ # These checks are needed because nils used to be possible and now they aren't.
57
+ raise 'id cannot be nil' if id.nil?
58
+ raise 'category_id cannot be nil' if category_id.nil?
26
59
 
27
- # If this is a NextGen project then this status may be project specific. When this field is
28
- # nil then the status is global.
29
- @project_id = raw['scope']&.[]('project')&.[]('id')
60
+ @name = name
61
+ @id = id
62
+ @category = Category.new id: category_id, name: category_name, key: category_key
63
+ @project_id = project_id
64
+ @artificial = artificial
30
65
  end
31
66
 
32
67
  def project_scoped?
@@ -38,8 +73,38 @@ class Status
38
73
  end
39
74
 
40
75
  def to_s
41
- "Status(name=#{@name.inspect}, id=#{@id.inspect}," \
42
- " category_name=#{@category_name.inspect}, category_id=#{@category_id.inspect}, project_id=#{@project_id})"
76
+ "#{name.inspect}:#{id.inspect}"
77
+ end
78
+
79
+ def artificial?
80
+ @artificial
81
+ end
82
+
83
+ def == other
84
+ return false unless other.is_a? Status
85
+
86
+ @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
87
+ end
88
+
89
+ def eql?(other)
90
+ self == other
91
+ end
92
+
93
+ def <=> other
94
+ result = @name.casecmp(other.name)
95
+ result = @id <=> other.id if result.zero?
96
+ result
97
+ end
98
+
99
+ def inspect
100
+ result = []
101
+ result << "Status(name: #{@name.inspect}"
102
+ result << "id: #{@id.inspect}"
103
+ result << "project_id: #{@project_id}" if @project_id
104
+ category = self.category
105
+ result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
106
+ result << 'artificial' if artificial?
107
+ result.join(', ') << ')'
43
108
  end
44
109
 
45
110
  def value_equality_ignored_variables