jirametrics 2.7.1 → 2.8

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.
@@ -44,7 +44,7 @@ class Issue
44
44
 
45
45
  def summary = @raw['fields']['summary']
46
46
 
47
- def status = Status.new(raw: @raw['fields']['status'])
47
+ def status = Status.from_raw(@raw['fields']['status'])
48
48
 
49
49
  def labels = @raw['fields']['labels'] || []
50
50
 
@@ -66,35 +66,45 @@ class Issue
66
66
  end
67
67
 
68
68
  def first_time_in_status *status_names
69
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
69
+ @changes.find { |change| change.current_status_matches(*status_names) }
70
70
  end
71
71
 
72
72
  def first_time_not_in_status *status_names
73
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
73
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
74
74
  end
75
75
 
76
76
  def first_time_in_or_right_of_column column_name
77
77
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
78
78
  end
79
79
 
80
+ def first_time_label_added *labels
81
+ @changes.each do |change|
82
+ next unless change.labels?
83
+
84
+ change_labels = change.value.split
85
+ return change if change_labels.any? { |l| labels.include?(l) }
86
+ end
87
+ nil
88
+ end
89
+
80
90
  def still_in_or_right_of_column column_name
81
91
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
82
92
  end
83
93
 
84
94
  def still_in
85
- time = nil
95
+ result = nil
86
96
  @changes.each do |change|
87
97
  next unless change.status?
88
98
 
89
99
  current_status_matched = yield change
90
100
 
91
- if current_status_matched && time.nil?
92
- time = change.time
93
- elsif !current_status_matched && time
94
- time = nil
101
+ if current_status_matched && result.nil?
102
+ result = change
103
+ elsif !current_status_matched && result
104
+ result = nil
95
105
  end
96
106
  end
97
- time
107
+ result
98
108
  end
99
109
  private :still_in
100
110
 
@@ -108,8 +118,8 @@ class Issue
108
118
  # If it ever entered one of these categories and it's still there then what was the last time it entered
109
119
  def still_in_status_category *category_names
110
120
  still_in do |change|
111
- status = find_status_by_name change.value
112
- category_names.include?(status.category_name) || category_names.include?(status.category_id)
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)
113
123
  end
114
124
  end
115
125
 
@@ -117,48 +127,53 @@ class Issue
117
127
  changes.reverse.find { |change| change.status? }
118
128
  end
119
129
 
120
- # Are we currently in this status? If yes, then return the time of the most recent status change.
130
+ # Are we currently in this status? If yes, then return the most recent status change.
121
131
  def currently_in_status *status_names
122
132
  change = most_recent_status_change
123
133
  return false if change.nil?
124
134
 
125
- change.time if change.current_status_matches(*status_names)
135
+ change if change.current_status_matches(*status_names)
126
136
  end
127
137
 
128
- # Are we currently in this status category? If yes, then return the time of the most recent status change.
138
+ # Are we currently in this status category? If yes, then return the most recent status change.
129
139
  def currently_in_status_category *category_names
130
140
  change = most_recent_status_change
131
141
  return false if change.nil?
132
142
 
133
- status = find_status_by_name change.value
134
- change.time if status && category_names.include?(status.category_name)
143
+ status = find_status_by_id change.value_id, name: change.value
144
+ change if status && category_names.include?(status.category.name)
135
145
  end
136
146
 
137
- def find_status_by_name name
138
- status = board.possible_statuses.find_by_name(name)
147
+ def find_status_by_id id, name: nil
148
+ status = board.possible_statuses.find_by_id(id)
139
149
  return status if status
140
150
 
141
- @board.project_config.file_system.log(
142
- "Warning: Status name #{name.inspect} for issue #{key} not found in" \
143
- " #{board.possible_statuses.collect(&:name).inspect}" \
144
- "\n See https://jirametrics.org/faq/#q1\n",
145
- also_write_to_stderr: true
146
- )
147
- status = Status.new(name: name, category_name: 'In Progress')
148
- board.possible_statuses << status
151
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
152
+
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
163
+
149
164
  status
150
165
  end
151
166
 
152
167
  def first_status_change_after_created
153
- @changes.find { |change| change.status? && change.artificial? == false }&.time
168
+ @changes.find { |change| change.status? && change.artificial? == false }
154
169
  end
155
170
 
156
171
  def first_time_in_status_category *category_names
157
172
  @changes.each do |change|
158
173
  next unless change.status?
159
174
 
160
- category = find_status_by_name(change.value).category_name
161
- return change.time if category_names.include? category
175
+ category = find_status_by_id(change.value_id).category.name
176
+ return change if category_names.include? category
162
177
  end
163
178
  nil
164
179
  end
@@ -177,11 +192,11 @@ class Issue
177
192
  end
178
193
 
179
194
  def first_resolution
180
- @changes.find { |change| change.resolution? }&.time
195
+ @changes.find { |change| change.resolution? }
181
196
  end
182
197
 
183
198
  def last_resolution
184
- @changes.reverse.find { |change| change.resolution? }&.time
199
+ @changes.reverse.find { |change| change.resolution? }
185
200
  end
186
201
 
187
202
  def assigned_to
@@ -624,7 +639,7 @@ class Issue
624
639
  # This was probably loaded as a linked issue, which means we don't know what board it really
625
640
  # belonged to. The best we can do is look at the status category. This case should be rare but
626
641
  # it can happen.
627
- status.category_name == 'Done'
642
+ status.category.name == 'Done'
628
643
  else
629
644
  board.cycletime.done? self
630
645
  end
@@ -31,18 +31,23 @@ class ProjectConfig
31
31
  instance_eval(&@block) if @block
32
32
  end
33
33
 
34
+ def data_downloaded?
35
+ File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
36
+ end
37
+
34
38
  def load_data
35
39
  return if @has_loaded_data
36
40
 
37
41
  @has_loaded_data = true
38
42
  load_all_boards
39
43
  @id = guess_project_id
40
- load_status_category_mappings
41
44
  load_project_metadata
42
45
  load_sprints
43
46
  end
44
47
 
45
48
  def run load_only: false
49
+ return if @exporter.downloading?
50
+
46
51
  load_data unless aggregated_project?
47
52
  anonymize_data if @anonymizer_needed
48
53
 
@@ -57,7 +62,8 @@ class ProjectConfig
57
62
  end
58
63
 
59
64
  def load_settings
60
- JSON.parse(file_system.load(File.join(__dir__, 'settings.json')))
65
+ # This is the wierd exception that we don't ever want mocked out so we skip FileSystem entirely.
66
+ JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
61
67
  end
62
68
 
63
69
  def guess_project_id
@@ -109,87 +115,153 @@ class ProjectConfig
109
115
  @board_configs << config
110
116
  end
111
117
 
112
- def file_prefix prefix = nil
113
- @file_prefix = prefix unless prefix.nil?
118
+ def file_prefix prefix
119
+ # The file_prefix has to be set before almost everything else. It really should have been an attribute
120
+ # on the project declaration itself. Hindsight is 20/20.
121
+ if @file_prefix
122
+ raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
123
+ end
124
+
125
+ @file_prefix = prefix
126
+
127
+ # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
128
+ # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
129
+ # on project, we wouldn't have this ugliness. 🤷‍♂️
130
+ load_status_category_mappings
131
+
114
132
  @file_prefix
115
133
  end
116
134
 
117
- def status_category_mapping status:, category:
118
- add_possible_status Status.new(name: status, category_name: category)
135
+ def get_file_prefix # rubocop:disable Naming/AccessorMethodName
136
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
137
+
138
+ @file_prefix
119
139
  end
120
140
 
121
- def load_all_boards
122
- Dir.foreach(@target_path) do |file|
123
- next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
141
+ def status_guesses
142
+ statuses = {}
143
+ issues.each do |issue|
144
+ issue.changes.each do |change|
145
+ next unless change.status?
124
146
 
125
- board_id = $1.to_i
126
- load_board board_id: board_id, filename: "#{@target_path}#{file}"
147
+ (statuses[change.value] ||= Set.new) << change.value_id
148
+ (statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
149
+ end
127
150
  end
128
- raise "No boards found for #{@file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
151
+ statuses
129
152
  end
130
153
 
131
- def load_board board_id:, filename:
132
- board = Board.new(
133
- raw: JSON.parse(file_system.load(filename)), possible_statuses: @possible_statuses
154
+ 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
160
+
161
+ if status_id.nil?
162
+ guesses = status_guesses[status]
163
+ if guesses.nil?
164
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
165
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
166
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
167
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
168
+ return
169
+ end
170
+
171
+ if guesses.size > 1
172
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
173
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
174
+ end
175
+
176
+ status_id = guesses.first
177
+ # TODO: This should be a deprecation instead but we can't currently assert against those
178
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
179
+ "If that's incorrect then specify the status_id."
180
+ end
181
+
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}?"
186
+ end
187
+
188
+ add_possible_status(
189
+ Status.new(
190
+ name: status, id: status_id,
191
+ category_name: category, category_id: found_category_id, category_key: found_category.key
192
+ )
134
193
  )
135
- board.project_config = self
136
- @all_boards[board_id] = board
137
194
  end
138
195
 
139
- def raise_with_message_about_missing_category_information all_issues = @issues
140
- message = +''
141
- message << "Could not determine categories for some of the statuses used in this data set.\n" \
142
- "Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
143
- 'The mappings we do know about are below:'
196
+ def add_possible_status status
197
+ existing_status = @possible_statuses.find_by_id status.id
144
198
 
145
- @possible_statuses.each do |status|
146
- message << "\n status: #{status.name.inspect}, category: #{status.category_name.inspect}"
199
+ if existing_status && existing_status.name != status.name
200
+ raise "Attempting to redefine the name for status #{status.id} from " \
201
+ "#{existing_status.name.inspect} to #{status.name.inspect}"
147
202
  end
148
203
 
149
- message << "\n\nThe ones we're missing are the following:"
204
+ # If it isn't there, add it and go.
205
+ return @possible_statuses << status unless existing_status
150
206
 
151
- find_statuses_with_no_category_information(all_issues).each do |status_name|
152
- message << "\n status: #{status_name.inspect}, category: <unknown>"
207
+ unless status == existing_status
208
+ raise "Redefining status category for status #{status}. " \
209
+ "original: #{existing_status.category}, " \
210
+ "new: #{status.category}"
153
211
  end
154
212
 
155
- raise message
213
+ # 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.
215
+ existing_status
156
216
  end
157
217
 
158
- def find_statuses_with_no_category_information all_issues
159
- missing_statuses = []
160
- all_issues.each do |issue|
161
- issue.changes.each do |change|
162
- next unless change.status?
218
+ def load_all_boards
219
+ Dir.foreach(@target_path) do |file|
220
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
163
221
 
164
- missing_statuses << change.value unless find_status(name: change.value)
165
- end
222
+ board_id = $1.to_i
223
+ load_board board_id: board_id, filename: "#{@target_path}#{file}"
166
224
  end
167
- missing_statuses.uniq
225
+ raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
226
+ end
227
+
228
+ def load_board board_id:, filename:
229
+ board = Board.new(
230
+ raw: file_system.load_json(filename), possible_statuses: @possible_statuses
231
+ )
232
+ board.project_config = self
233
+ @all_boards[board_id] = board
168
234
  end
169
235
 
170
236
  def load_status_category_mappings
171
- filename = "#{@target_path}/#{file_prefix}_statuses.json"
237
+ filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
238
+
172
239
  # We may not always have this file. Load it if we can.
173
240
  return unless File.exist? filename
174
241
 
175
- statuses = JSON.parse(file_system.load(filename))
176
- .map { |snippet| Status.new(raw: snippet) }
177
- statuses
178
- .find_all { |status| status.global? }
179
- .each { |status| add_possible_status status }
180
- statuses
181
- .find_all { |status| status.project_scoped? }
242
+ file_system
243
+ .load_json(filename)
244
+ .map { |snippet| Status.from_raw(snippet) }
182
245
  .each { |status| add_possible_status status }
183
246
  end
184
247
 
185
248
  def load_sprints
186
- Dir.foreach(@target_path) do |file|
187
- next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/
249
+ file_system.foreach(@target_path) do |file|
250
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
251
+
252
+ file_path = File.join(@target_path, file)
253
+ board = @all_boards[$1.to_i]
254
+ unless board
255
+ @exporter.file_system.log(
256
+ 'Found sprint data but can\'t find a matching board in config. ' \
257
+ "File: #{file_path}, Boards: #{@all_boards.keys.sort}"
258
+ )
259
+ next
260
+ end
188
261
 
189
- board_id = $1.to_i
190
262
  timezone_offset = exporter.timezone_offset
191
- JSON.parse(file_system.load("#{target_path}#{file}"))['values']&.each do |json|
192
- @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
263
+ file_system.load_json(file_path)['values']&.each do |json|
264
+ board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
193
265
  end
194
266
  end
195
267
 
@@ -198,56 +270,26 @@ class ProjectConfig
198
270
  end
199
271
  end
200
272
 
201
- def add_possible_status status
202
- existing_status = find_status(name: status.name)
203
-
204
- if status.project_scoped?
205
- # If the project specific status doesn't change anything then we don't care whether it's
206
- # our project or not.
207
- return if existing_status && existing_status.category_name == status.category_name
273
+ def load_project_metadata
274
+ filename = File.join @target_path, "#{get_file_prefix}_meta.json"
275
+ json = file_system.load_json(filename)
208
276
 
209
- raise_ambiguous_project_id if @id.nil?
277
+ @data_version = json['version'] || 1
210
278
 
211
- # Not our project, ignore it.
212
- return unless status.project_id == @id
279
+ start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
280
+ stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
213
281
 
214
- # Replace the old one with this
215
- @possible_statuses.delete(existing_status)
216
- @possible_statuses << status
217
- return
282
+ # If no_earlier_than was set then make sure it's applied here.
283
+ if download_config
284
+ download_config.run
285
+ no_earlier = download_config.no_earlier_than
286
+ if no_earlier
287
+ no_earlier = to_time(no_earlier.to_s)
288
+ start = no_earlier if start < no_earlier
289
+ end
218
290
  end
219
291
 
220
- # If it isn't there, add it and go.
221
- return @possible_statuses << status unless existing_status
222
-
223
- # We're registering the same one twice. Shouldn't be possible with the new status API but it
224
- # did happen with the project specific one.
225
- return if status.category_name == existing_status.category_name
226
-
227
- # If we got this far then someone has called status_category_mapping and is attempting to
228
- # change the category.
229
- raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
230
- end
231
-
232
- def raise_ambiguous_project_id
233
- raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
234
- 'We are unable to automatically detect the id of the project so you will have to set it manually ' \
235
- 'in the configuration like: "project id: 5"'
236
- end
237
-
238
- def find_status name:
239
- @possible_statuses.find_by_name name
240
- end
241
-
242
- def load_project_metadata
243
- filename = File.join @target_path, "#{file_prefix}_meta.json"
244
- json = JSON.parse(file_system.load(filename))
245
-
246
- @data_version = json['version'] || 1
247
-
248
- start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
249
- stop = json['date_end'] || json['time_end']
250
- @time_range = to_time(start)..to_time(stop, end_of_day: true)
292
+ @time_range = start..stop
251
293
 
252
294
  @jira_url = json['jira_url']
253
295
  rescue Errno::ENOENT
@@ -304,11 +346,15 @@ class ProjectConfig
304
346
  raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
305
347
  'declaration but none are here. Check your config.'
306
348
  end
349
+
350
+ return @issues = [] if @exporter.downloading?
351
+ raise 'No data found. Must do a download before an export' unless data_downloaded?
352
+
307
353
  load_data if all_boards.empty?
308
354
 
309
355
  timezone_offset = exporter.timezone_offset
310
356
 
311
- issues_path = File.join @target_path, "#{file_prefix}_issues"
357
+ issues_path = File.join @target_path, "#{get_file_prefix}_issues"
312
358
  if File.exist?(issues_path) && File.directory?(issues_path)
313
359
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
314
360
  else
@@ -370,7 +416,7 @@ class ProjectConfig
370
416
  default_board = nil
371
417
 
372
418
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
373
- content = file_system.load(File.join(path, filename))
419
+ content = file_system.load_json(File.join(path, filename))
374
420
  if board_ids == :unknown
375
421
  boards = [(default_board ||= find_default_board)]
376
422
  else
@@ -378,7 +424,7 @@ class ProjectConfig
378
424
  end
379
425
 
380
426
  boards.each do |board|
381
- issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
427
+ issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
382
428
  end
383
429
  end
384
430
 
@@ -172,7 +172,7 @@ class SprintBurndown < ChartBase
172
172
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
173
173
  end
174
174
 
175
- def data_set_by_story_points sprint:, change_data_for_sprint:
175
+ def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
176
176
  summary_stats = SprintSummaryStats.new
177
177
  summary_stats.completed = 0.0
178
178
 
@@ -4,29 +4,57 @@ require 'jirametrics/value_equality'
4
4
 
5
5
  class Status
6
6
  include ValueEquality
7
- attr_reader :id, :category_name, :category_id, :project_id
7
+ attr_reader :id, :project_id, :category
8
8
  attr_accessor :name
9
9
 
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
10
+ class Category
11
+ attr_reader :id, :name, :key
12
+
13
+ def initialize id:, name:, key:
14
+ @id = id
15
+ @name = name
16
+ @key = key
17
+ end
16
18
 
17
- return unless raw
19
+ def to_s
20
+ "#{name.inspect}:#{id.inspect}"
21
+ end
18
22
 
19
- @raw = raw
20
- @name = raw['name']
21
- @id = raw['id'].to_i
23
+ def new? = (@key == 'new')
24
+ def indeterminate? = (@key == 'indeterminate')
25
+ def done? = (@key == 'done')
26
+ end
22
27
 
28
+ def self.from_raw raw
23
29
  category_config = raw['statusCategory']
24
- @category_name = category_config['name']
25
- @category_id = category_config['id'].to_i
26
30
 
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')
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
36
+
37
+ Status.new(
38
+ name: raw['name'],
39
+ id: raw['id'].to_i,
40
+ category_name: category_config['name'],
41
+ category_id: category_config['id'].to_i,
42
+ category_key: category_config['key'],
43
+ project_id: raw['scope']&.[]('project')&.[]('id'),
44
+ artificial: false
45
+ )
46
+ end
47
+
48
+ def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
49
+ # These checks are needed because nils used to be possible and now they aren't.
50
+ raise 'id cannot be nil' if id.nil?
51
+ raise 'category_id cannot be nil' if category_id.nil?
52
+
53
+ @name = name
54
+ @id = id
55
+ @category = Category.new id: category_id, name: category_name, key: category_key
56
+ @project_id = project_id
57
+ @artificial = artificial
30
58
  end
31
59
 
32
60
  def project_scoped?
@@ -38,18 +66,26 @@ class Status
38
66
  end
39
67
 
40
68
  def to_s
41
- result = "Status(name=#{@name.inspect}," \
42
- " id=#{@id.inspect}," \
43
- " category_name=#{@category_name.inspect}," \
44
- " category_id=#{@category_id.inspect}," \
45
- " project_id=#{@project_id}"
46
- result << ' artificial' if artificial?
47
- result << ')'
48
- result
69
+ "#{name.inspect}:#{id.inspect}"
49
70
  end
50
71
 
51
72
  def artificial?
52
- @raw.nil?
73
+ @artificial
74
+ end
75
+
76
+ def == other
77
+ @id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
78
+ end
79
+
80
+ def inspect
81
+ result = []
82
+ result << "Status(name: #{@name.inspect}"
83
+ result << "id: #{@id.inspect}"
84
+ result << "project_id: #{@project_id}" if @project_id
85
+ category = self.category
86
+ result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
87
+ result << 'artificial' if artificial?
88
+ result.join(', ') << ')'
53
89
  end
54
90
 
55
91
  def value_equality_ignored_variables