jirametrics 2.8 → 2.11

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +1 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +50 -2
  6. data/lib/jirametrics/board.rb +32 -4
  7. data/lib/jirametrics/board_config.rb +6 -2
  8. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  9. data/lib/jirametrics/change_item.rb +8 -1
  10. data/lib/jirametrics/chart_base.rb +40 -10
  11. data/lib/jirametrics/css_variable.rb +1 -1
  12. data/lib/jirametrics/cycletime_config.rb +10 -4
  13. data/lib/jirametrics/cycletime_histogram.rb +65 -2
  14. data/lib/jirametrics/data_quality_report.rb +53 -34
  15. data/lib/jirametrics/downloader.rb +0 -14
  16. data/lib/jirametrics/examples/standard_project.rb +2 -2
  17. data/lib/jirametrics/exporter.rb +10 -20
  18. data/lib/jirametrics/file_config.rb +21 -4
  19. data/lib/jirametrics/file_system.rb +23 -4
  20. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  21. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  22. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  23. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  24. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  25. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  26. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  27. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  28. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  29. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  30. data/lib/jirametrics/html/index.css +11 -2
  31. data/lib/jirametrics/html/index.erb +8 -1
  32. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  33. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  34. data/lib/jirametrics/html_report_config.rb +18 -23
  35. data/lib/jirametrics/issue.rb +77 -32
  36. data/lib/jirametrics/jira_gateway.rb +16 -3
  37. data/lib/jirametrics/project_config.rb +102 -45
  38. data/lib/jirametrics/status.rb +26 -7
  39. data/lib/jirametrics/status_collection.rb +69 -68
  40. data/lib/jirametrics/value_equality.rb +2 -2
  41. data/lib/jirametrics.rb +0 -1
  42. metadata +5 -9
  43. data/lib/jirametrics/discard_changes_before.rb +0 -37
@@ -13,7 +13,12 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ # We only check for this here because if a board isn't passed in then things will fail much
17
+ # later and be hard to find. Let's find out early.
16
18
  raise "No board for issue #{key}" if board.nil?
19
+
20
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
21
+ # changelogs.
17
22
  return unless @raw['changelog']
18
23
 
19
24
  load_history_into_changes
@@ -44,14 +49,26 @@ class Issue
44
49
 
45
50
  def summary = @raw['fields']['summary']
46
51
 
47
- def status = Status.from_raw(@raw['fields']['status'])
48
-
49
52
  def labels = @raw['fields']['labels'] || []
50
53
 
51
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
52
55
 
53
56
  def resolution = @raw['fields']['resolution']&.[]('name')
54
57
 
58
+ def status
59
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
60
+ @status
61
+ end
62
+
63
+ def status= status
64
+ @status = status
65
+ end
66
+
67
+ def due_date
68
+ text = @raw['fields']['duedate']
69
+ text.nil? ? nil : Date.parse(text)
70
+ end
71
+
55
72
  def url
56
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
57
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -93,9 +110,7 @@ class Issue
93
110
 
94
111
  def still_in
95
112
  result = nil
96
- @changes.each do |change|
97
- next unless change.status?
98
-
113
+ status_changes.each do |change|
99
114
  current_status_matched = yield change
100
115
 
101
116
  if current_status_matched && result.nil?
@@ -117,14 +132,19 @@ class Issue
117
132
 
118
133
  # If it ever entered one of these categories and it's still there then what was the last time it entered
119
134
  def still_in_status_category *category_names
135
+ category_ids = find_status_category_ids_by_names category_names
136
+
120
137
  still_in do |change|
121
- status = find_status_by_id change.value_id, name: change.value
122
- category_names.include?(status.category.name) || category_names.include?(status.category.id)
138
+ status = find_or_create_status id: change.value_id, name: change.value
139
+ category_ids.include? status.category.id
123
140
  end
124
141
  end
125
142
 
126
143
  def most_recent_status_change
127
- changes.reverse.find { |change| change.status? }
144
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
145
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
146
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
147
+ status_changes.last
128
148
  end
129
149
 
130
150
  # Are we currently in this status? If yes, then return the most recent status change.
@@ -137,43 +157,50 @@ class Issue
137
157
 
138
158
  # Are we currently in this status category? If yes, then return the most recent status change.
139
159
  def currently_in_status_category *category_names
160
+ category_ids = find_status_category_ids_by_names category_names
161
+
140
162
  change = most_recent_status_change
141
163
  return false if change.nil?
142
164
 
143
- status = find_status_by_id change.value_id, name: change.value
144
- change if status && category_names.include?(status.category.name)
165
+ status = find_or_create_status id: change.value_id, name: change.value
166
+ change if status && category_ids.include?(status.category.id)
145
167
  end
146
168
 
147
- def find_status_by_id id, name: nil
169
+ def find_or_create_status id:, name:
148
170
  status = board.possible_statuses.find_by_id(id)
149
- return status if status
150
171
 
151
- status = board.possible_statuses.fabricate_status_for id: id, name: name
172
+ unless status
173
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
174
+ # list this status as one it actually found
175
+ found_statuses = board.possible_statuses.to_s
176
+
177
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
152
178
 
153
- message = +'The history for issue '
154
- message << key
155
- message << ' references a status ('
156
- message << name.inspect << ':' if name
157
- message << id.to_s
158
- message << ') that can\'t be found in ['
159
- message << board.possible_statuses.collect(&:to_s).join(', ')
160
- message << "]. We are guessing that this belongs to the #{status.category} status category "
161
- message << 'and that may be wrong. See https://jirametrics.org/faq/#q1 for more details'
162
- board.project_config.file_system.warning message
179
+ message = +'The history for issue '
180
+ message << key
181
+ message << ' references the status ('
182
+ message << "#{name.inspect}:#{id.inspect}"
183
+ message << ') that can\'t be found. We are guessing that this belongs to the '
184
+ message << status.category.to_s
185
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
186
+ message << 'details on defining statuses.'
187
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
188
+ end
163
189
 
164
190
  status
165
191
  end
166
192
 
167
193
  def first_status_change_after_created
168
- @changes.find { |change| change.status? && change.artificial? == false }
194
+ status_changes.find { |change| change.artificial? == false }
169
195
  end
170
196
 
171
197
  def first_time_in_status_category *category_names
172
- @changes.each do |change|
173
- next unless change.status?
198
+ category_ids = find_status_category_ids_by_names category_names
174
199
 
175
- category = find_status_by_id(change.value_id).category.name
176
- return change if category_names.include? category
200
+ status_changes.each do |change|
201
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
202
+ id = to_status.category.id
203
+ return change if category_ids.include? id
177
204
  end
178
205
  nil
179
206
  end
@@ -593,12 +620,17 @@ class Issue
593
620
  end
594
621
 
595
622
  (changes + (@discarded_changes || [])).each do |change|
596
- value = change.value
597
- old_value = change.old_value
623
+ if change.status?
624
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
625
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
626
+ else
627
+ value = compact_text(change.value).inspect
628
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
629
+ end
598
630
 
599
631
  message = +''
600
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
601
- message << compact_text(value).inspect
632
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
633
+ message << value
602
634
  if change.artificial?
603
635
  message << ' (Artificial entry)' if change.artificial?
604
636
  else
@@ -645,6 +677,10 @@ class Issue
645
677
  end
646
678
  end
647
679
 
680
+ def status_changes
681
+ @changes.select { |change| change.status? }
682
+ end
683
+
648
684
  private
649
685
 
650
686
  def assemble_author raw
@@ -720,4 +756,13 @@ class Issue
720
756
  'toString' => first_status
721
757
  }
722
758
  end
759
+
760
+ def find_status_category_ids_by_names category_names
761
+ category_names.filter_map do |name|
762
+ list = board.possible_statuses.find_all_categories_by_name name
763
+ raise "No status categories found for name: #{name}" if list.empty?
764
+
765
+ list
766
+ end.flatten.collect(&:id)
767
+ end
723
768
  end
@@ -14,9 +14,15 @@ class JiraGateway
14
14
  def call_url relative_url:
15
15
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
16
  result = call_command command
17
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
17
+ begin
18
+ json = JSON.parse(result)
19
+ rescue # rubocop:disable Style/RescueStandardError
20
+ raise "Error when parsing result: #{result.inspect}"
21
+ end
22
+
23
+ raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
24
+
25
+ json
20
26
  end
21
27
 
22
28
  def call_command command
@@ -61,4 +67,11 @@ class JiraGateway
61
67
  command << " --url \"#{url}\""
62
68
  command
63
69
  end
70
+
71
+ def json_successful? json
72
+ return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
73
+ return false if json.is_a?(Array) && json.first == 'errorMessage'
74
+
75
+ true
76
+ end
64
77
  end
@@ -4,11 +4,9 @@ require 'time'
4
4
  require 'jirametrics/status_collection'
5
5
 
6
6
  class ProjectConfig
7
- include DiscardChangesBefore
8
-
9
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
10
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
11
- :settings, :aggregate_config
9
+ :settings, :aggregate_config, :discarded_changes_data
12
10
  attr_accessor :time_range, :jira_url, :id
13
11
 
14
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -32,14 +30,13 @@ class ProjectConfig
32
30
  end
33
31
 
34
32
  def data_downloaded?
35
- File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
33
+ file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
36
34
  end
37
35
 
38
36
  def load_data
39
37
  return if @has_loaded_data
40
38
 
41
39
  @has_loaded_data = true
42
- load_all_boards
43
40
  @id = guess_project_id
44
41
  load_project_metadata
45
42
  load_sprints
@@ -53,16 +50,13 @@ class ProjectConfig
53
50
 
54
51
  return if load_only
55
52
 
56
- @board_configs.each do |board_config|
57
- board_config.run
58
- end
59
53
  @file_configs.each do |file_config|
60
54
  file_config.run
61
55
  end
62
56
  end
63
57
 
64
58
  def load_settings
65
- # This is the wierd exception that we don't ever want mocked out so we skip FileSystem entirely.
59
+ # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
66
60
  JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
67
61
  end
68
62
 
@@ -112,6 +106,7 @@ class ProjectConfig
112
106
 
113
107
  def board id:, &block
114
108
  config = BoardConfig.new(id: id, block: block, project_config: self)
109
+ config.run if data_downloaded?
115
110
  @board_configs << config
116
111
  end
117
112
 
@@ -128,6 +123,8 @@ class ProjectConfig
128
123
  # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
129
124
  # on project, we wouldn't have this ugliness. 🤷‍♂️
130
125
  load_status_category_mappings
126
+ load_status_history
127
+ load_all_boards
131
128
 
132
129
  @file_prefix
133
130
  end
@@ -138,29 +135,28 @@ class ProjectConfig
138
135
  @file_prefix
139
136
  end
140
137
 
141
- def status_guesses
142
- statuses = {}
138
+ # Walk across all the issues and find any status with that name. Return a list of ids that match.
139
+ def find_ids_by_status_name_across_all_issues name
140
+ ids = Set.new
141
+
143
142
  issues.each do |issue|
144
143
  issue.changes.each do |change|
145
144
  next unless change.status?
146
145
 
147
- (statuses[change.value] ||= Set.new) << change.value_id
148
- (statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
146
+ ids << change.value_id.to_i if change.value == name
147
+ ids << change.old_value_id.to_i if change.old_value == name
149
148
  end
150
149
  end
151
- statuses
150
+ ids.to_a
152
151
  end
153
152
 
154
153
  def status_category_mapping status:, category:
155
- # Status might just be a name like "Review" or it might be a name:id pair like "Review:4"
156
- parse_status = ->(line) { line =~ /^(.+):(\d+)$/ ? [$1, $2.to_i] : [line, nil] }
157
-
158
- status, status_id = parse_status.call status
159
- category, category_id = parse_status.call category
154
+ status, status_id = possible_statuses.parse_name_id status
155
+ category, category_id = possible_statuses.parse_name_id category
160
156
 
161
157
  if status_id.nil?
162
- guesses = status_guesses[status]
163
- if guesses.nil?
158
+ guesses = find_ids_by_status_name_across_all_issues status
159
+ if guesses.empty?
164
160
  file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
165
161
  "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
166
162
  "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
@@ -174,21 +170,31 @@ class ProjectConfig
174
170
  end
175
171
 
176
172
  status_id = guesses.first
177
- # TODO: This should be a deprecation instead but we can't currently assert against those
178
173
  file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
179
174
  "If that's incorrect then specify the status_id."
180
175
  end
181
176
 
182
- found_category = possible_statuses.find_category_by_name category
183
- found_category_id = found_category&.id # possible_statuses.find_category_id_by_name category
184
- if category_id && category_id != found_category_id
185
- raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category_id}?"
177
+ possible_categories = possible_statuses.find_all_categories_by_name category
178
+ if possible_categories.empty?
179
+ all = possible_statuses.find_all_categories.join(', ')
180
+ raise "No status categories found for name #{category.inspect} in [#{all}]. " \
181
+ 'Either fix the name or add an ID.'
182
+ elsif possible_categories.size > 1
183
+ # Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
184
+ raise "More than one status category found with the name #{category.inspect} in " \
185
+ "[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
186
+ end
187
+
188
+ found_category = possible_categories.first
189
+
190
+ if category_id && category_id != found_category.id
191
+ raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
186
192
  end
187
193
 
188
194
  add_possible_status(
189
195
  Status.new(
190
196
  name: status, id: status_id,
191
- category_name: category, category_id: found_category_id, category_key: found_category.key
197
+ category_name: category, category_id: found_category.id, category_key: found_category.key
192
198
  )
193
199
  )
194
200
  end
@@ -211,7 +217,14 @@ class ProjectConfig
211
217
  end
212
218
 
213
219
  # We're registering one we already knew about. This may happen if someone specified a status_category_mapping
214
- # for something that was already returned from jira. This isn't problem so just ignore it.
220
+ # for something that was already returned from jira.
221
+ #
222
+ # You may be looking at this code and thinking of changing it to spit out a warning since obviously
223
+ # the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
224
+ # status API, it's possible for two different people to make a request to the same API at the same time and get
225
+ # back a different set of statuses. So that means that some people might need more status/categories mappings than
226
+ # other people for exactly the same instance. See this article for more on that API:
227
+ # https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
215
228
  existing_status
216
229
  end
217
230
 
@@ -222,7 +235,6 @@ class ProjectConfig
222
235
  board_id = $1.to_i
223
236
  load_board board_id: board_id, filename: "#{@target_path}#{file}"
224
237
  end
225
- raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
226
238
  end
227
239
 
228
240
  def load_board board_id:, filename:
@@ -235,9 +247,7 @@ class ProjectConfig
235
247
 
236
248
  def load_status_category_mappings
237
249
  filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
238
-
239
- # We may not always have this file. Load it if we can.
240
- return unless File.exist? filename
250
+ return unless file_system.file_exist? filename
241
251
 
242
252
  file_system
243
253
  .load_json(filename)
@@ -245,6 +255,22 @@ class ProjectConfig
245
255
  .each { |status| add_possible_status status }
246
256
  end
247
257
 
258
+ def load_status_history
259
+ filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
260
+ return unless file_system.file_exist? filename
261
+
262
+ file_system.log ' Loading historical statuses', also_write_to_stderr: true
263
+ file_system
264
+ .load_json(filename)
265
+ .map { |snippet| Status.from_raw(snippet) }
266
+ .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
267
+
268
+ possible_statuses
269
+ rescue => e # rubocop:disable Style/RescueStandardError
270
+ file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
271
+ 'malformed file then it should be fixed on the next download.'
272
+ end
273
+
248
274
  def load_sprints
249
275
  file_system.foreach(@target_path) do |file|
250
276
  next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
@@ -478,21 +504,52 @@ class ProjectConfig
478
504
  Anonymizer.new(project_config: self).run
479
505
  end
480
506
 
481
- def discard_changes_before_hook issues_cutoff_times
482
- issues_cutoff_times.each do |issue, cutoff_time|
483
- days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
484
- message = "#{issue.key}(#{issue.type}) discarding #{days} "
485
- if days == 1
486
- message << "day of data on #{cutoff_time.to_date}"
487
- else
488
- message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
507
+ def file_system
508
+ @exporter.file_system
509
+ end
510
+
511
+ def discard_changes_before status_becomes: nil, &block
512
+ if status_becomes
513
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
514
+
515
+ block = lambda do |issue|
516
+ trigger_statuses = status_becomes.collect do |status_name|
517
+ if status_name == :backlog
518
+ issue.board.backlog_statuses
519
+ else
520
+ possible_statuses.find_all_by_name status_name
521
+ end
522
+ end.flatten
523
+
524
+ next if trigger_statuses.empty?
525
+
526
+ trigger_status_ids = trigger_statuses.collect(&:id)
527
+
528
+ time = nil
529
+ issue.status_changes.each do |change|
530
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
531
+ end
532
+ time
489
533
  end
490
- exporter.file_system.log message
491
534
  end
492
- exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
493
- end
494
535
 
495
- def file_system
496
- @exporter.file_system
536
+ issues.each do |issue|
537
+ cutoff_time = block.call(issue)
538
+ next if cutoff_time.nil?
539
+
540
+ original_start_time = issue.board.cycletime.started_stopped_times(issue).first
541
+ next if original_start_time.nil?
542
+
543
+ issue.discard_changes_before cutoff_time
544
+
545
+ next unless cutoff_time
546
+ next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
547
+
548
+ (@discarded_changes_data ||= []) << {
549
+ cutoff_time: cutoff_time,
550
+ original_start_time: original_start_time,
551
+ issue: issue
552
+ }
553
+ end
497
554
  end
498
555
  end
@@ -3,7 +3,6 @@
3
3
  require 'jirametrics/value_equality'
4
4
 
5
5
  class Status
6
- include ValueEquality
7
6
  attr_reader :id, :project_id, :category
8
7
  attr_accessor :name
9
8
 
@@ -20,19 +19,27 @@ class Status
20
19
  "#{name.inspect}:#{id.inspect}"
21
20
  end
22
21
 
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
+
23
33
  def new? = (@key == 'new')
24
34
  def indeterminate? = (@key == 'indeterminate')
25
35
  def done? = (@key == 'done')
26
36
  end
27
37
 
28
38
  def self.from_raw raw
29
- category_config = raw['statusCategory']
39
+ raise "raw cannot be nil" if raw.nil?
30
40
 
31
- legal_keys = %w[new indeterminate done]
32
- unless legal_keys.include? category_config['key']
33
- puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
34
- "#{category_config}"
35
- end
41
+ category_config = raw['statusCategory']
42
+ raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
36
43
 
37
44
  Status.new(
38
45
  name: raw['name'],
@@ -74,9 +81,21 @@ class Status
74
81
  end
75
82
 
76
83
  def == other
84
+ return false unless other.is_a? Status
85
+
77
86
  @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
78
87
  end
79
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
+
80
99
  def inspect
81
100
  result = []
82
101
  result << "Status(name: #{@name.inspect}"