• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / pdc_describe / cace366a-ffad-45f1-9b60-678e607fa527

14 May 2024 02:21PM UTC coverage: 60.862% (-35.0%) from 95.908%
cace366a-ffad-45f1-9b60-678e607fa527

push

circleci

jrgriffiniii
wip

1 of 3 new or added lines in 2 files covered. (33.33%)

1194 existing lines in 57 files now uncovered.

2076 of 3411 relevant lines covered (60.86%)

22.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.73
/app/models/work.rb
1
# frozen_string_literal: true
2

3
# rubocop:disable Metrics/ClassLength
4
class Work < ApplicationRecord
1✔
5
  # Errors for cases where there is no valid Group
6
  class InvalidGroupError < ::ArgumentError; end
1✔
7

8
  has_many :work_activity, -> { order(updated_at: :desc) }, dependent: :destroy
33✔
9
  has_many :user_work, -> { order(updated_at: :desc) }, dependent: :destroy
2✔
10
  has_many :upload_snapshots, -> { order(updated_at: :desc) }, dependent: :destroy
24✔
11

12
  belongs_to :group, class_name: "Group"
1✔
13
  belongs_to :curator, class_name: "User", foreign_key: "curator_user_id", optional: true
1✔
14

15
  attribute :work_type, :string, default: "DATASET"
1✔
16
  attribute :profile, :string, default: "DATACITE"
1✔
17

18
  attr_accessor :user_entered_doi
1✔
19

20
  alias state_history user_work
1✔
21

22
  delegate :valid_to_submit, :valid_to_draft, :valid_to_approve, :valid_to_complete, to: :work_validator
1✔
23

24
  include AASM
1✔
25

26
  aasm column: :state do
1✔
27
    state :none, initial: true
1✔
28
    state :draft, :awaiting_approval, :approved, :withdrawn, :deletion_marker
1✔
29

30
    event :draft, after: :draft_doi do
1✔
31
      transitions from: :none, to: :draft, guard: :valid_to_draft
1✔
32
    end
33

34
    event :complete_submission do
1✔
35
      transitions from: :draft, to: :awaiting_approval, guard: :valid_to_complete
1✔
36
    end
37

38
    event :request_changes do
1✔
39
      transitions from: :awaiting_approval, to: :awaiting_approval, guard: :valid_to_submit
1✔
40
    end
41

42
    event :approve do
1✔
43
      transitions from: :awaiting_approval, to: :approved, guard: :valid_to_approve, after: :publish
1✔
44
    end
45

46
    event :withdraw do
1✔
47
      transitions from: [:draft, :awaiting_approval, :approved], to: :withdrawn
1✔
48
    end
49

50
    event :resubmit do
1✔
51
      transitions from: :withdrawn, to: :draft
1✔
52
    end
53

54
    event :remove do
1✔
55
      transitions from: :withdrawn, to: :deletion_marker
1✔
56
    end
57

58
    after_all_events :track_state_change
1✔
59
  end
60

61
  def state=(new_state)
1✔
62
    new_state_sym = new_state.to_sym
38✔
63
    valid_states = self.class.aasm.states.map(&:name)
38✔
64
    raise(StandardError, "Invalid state '#{new_state}'") unless valid_states.include?(new_state_sym)
38✔
65
    aasm_write_state_without_persistence(new_state_sym)
38✔
66
  end
67

68
  ##
69
  # Is this work editable by a given user?
70
  # A work is editable when:
71
  # * it is being edited by the person who made it
72
  # * it is being edited by a group admin of the group where is resides
73
  # * it is being edited by a super admin
74
  # @param [User]
75
  # @return [Boolean]
76
  def editable_by?(user)
1✔
77
    submitted_by?(user) || administered_by?(user)
76✔
78
  end
79

80
  def editable_in_current_state?(user)
1✔
81
    # anyone with edit privleges can edit a work while it is in draft
82
    return editable_by?(user) if draft?
41✔
83

84
    # Only admisitrators can edit a work in other states
85
    administered_by?(user)
3✔
86
  end
87

88
  def submitted_by?(user)
1✔
89
    created_by_user_id == user.id
76✔
90
  end
91

92
  def administered_by?(user)
1✔
93
    user.has_role?(:group_admin, group)
15✔
94
  end
95

96
  class << self
1✔
97
    def find_by_doi(doi)
1✔
98
      prefix = "10.34770/"
1✔
99
      doi = "#{prefix}#{doi}" unless doi.blank? || doi.start_with?(prefix)
1✔
100
      Work.find_by!("metadata @> ?", JSON.dump(doi:))
1✔
101
    end
102

103
    def find_by_ark(ark)
1✔
104
      prefix = "ark:/"
30✔
105
      ark = "#{prefix}#{ark}" unless ark.blank? || ark.start_with?(prefix)
30✔
106
      Work.find_by!("metadata @> ?", JSON.dump(ark:))
30✔
107
    end
108

109
    delegate :resource_type_general_values, to: PDCMetadata::Resource
1✔
110
  end
111

112
  include Rails.application.routes.url_helpers
1✔
113

114
  before_save do |work|
1✔
115
    # Ensure that the metadata JSONB postgres field is persisted properly
116
    work.metadata = JSON.parse(work.resource.to_json)
57✔
117
  end
118

119
  after_save do |work|
1✔
120
    if work.approved?
57✔
121
      work.reload
3✔
122
    end
123
  end
124

125
  validate do |_work|
1✔
126
    work_validator.valid?
58✔
127
  end
128

129
  # Overload ActiveRecord.reload method
130
  # https://apidock.com/rails/ActiveRecord/Base/reload
131
  #
132
  # NOTE: Usually `after_save` is a better place to put this kind of code:
133
  #
134
  #   after_save do |work|
135
  #     work.resource = nil
136
  #   end
137
  #
138
  # but that does not work in this case because the block points to a different
139
  # memory object for `work` than the we want we want to reload.
140
  def reload(options = nil)
1✔
141
    super
6✔
142
    # Force `resource` to be reloaded
143
    @resource = nil
6✔
144
    self
6✔
145
  end
146

147
  def title
1✔
148
    resource.main_title
17✔
149
  end
150

151
  def uploads_attributes
1✔
152
    return [] if approved? # once approved we no longer allow the updating of uploads via the application
20✔
153
    uploads.map do |upload|
18✔
154
      {
155
        id: upload.id,
4✔
156
        key: upload.key,
157
        filename: upload.filename.to_s,
158
        created_at: upload.created_at,
159
        url: upload.url
160
      }
161
    end
162
  end
163

164
  def form_attributes
1✔
165
    {
166
      uploads: uploads_attributes
20✔
167
    }
168
  end
169

170
  def draft_doi
1✔
171
    return if resource.doi.present?
2✔
172
    resource.doi = datacite_service.draft_doi
1✔
173
    save!
1✔
174
  end
175

176
  # Return the DOI formatted as a URL, so it can be used as a link on display pages
177
  # @return [String] A url formatted version of the DOI
178
  def doi_url
1✔
UNCOV
179
    return "https://doi.org/#{doi}" unless doi.starts_with?("https://doi.org")
×
180
    doi
×
181
  end
182

183
  def created_by_user
1✔
184
    User.find(created_by_user_id)
38✔
185
  rescue ActiveRecord::RecordNotFound
UNCOV
186
    nil
×
187
  end
188

189
  def resource=(resource)
1✔
190
    @resource = resource
43✔
191
    # Ensure that the metadata JSONB postgres field is persisted properly
192
    self.metadata = JSON.parse(resource.to_json)
43✔
193
  end
194

195
  def resource
1✔
196
    @resource ||= PDCMetadata::Resource.new_from_jsonb(metadata)
2,650✔
197
  end
198

199
  def url
1✔
200
    return unless persisted?
×
201

202
    @url ||= url_for(self)
×
203
  end
204

205
  def files_location_upload?
1✔
UNCOV
206
    files_location.blank? || files_location == "file_upload"
×
207
  end
208

209
  def files_location_cluster?
1✔
210
    files_location == "file_cluster"
11✔
211
  end
212

213
  def files_location_other?
1✔
214
    files_location == "file_other"
11✔
215
  end
216

217
  def change_curator(curator_user_id, current_user)
1✔
UNCOV
218
    if curator_user_id == "no-one"
×
UNCOV
219
      clear_curator(current_user)
×
220
    else
UNCOV
221
      update_curator(curator_user_id, current_user)
×
222
    end
223
  end
224

225
  def clear_curator(current_user)
1✔
226
    # Update the curator on the Work
UNCOV
227
    self.curator_user_id = nil
×
UNCOV
228
    save!
×
229

230
    # ...and log the activity
UNCOV
231
    WorkActivity.add_work_activity(id, "Unassigned existing curator", current_user.id, activity_type: WorkActivity::SYSTEM)
×
232
  end
233

234
  def update_curator(curator_user_id, current_user)
1✔
235
    # Update the curator on the Work
UNCOV
236
    self.curator_user_id = curator_user_id
×
UNCOV
237
    save!
×
238

239
    # ...and log the activity
UNCOV
240
    new_curator = User.find(curator_user_id)
×
241

UNCOV
242
    work_url = "[#{title}](#{Rails.application.routes.url_helpers.work_url(self)})"
×
243

244
    # Troubleshooting https://github.com/pulibrary/pdc_describe/issues/1783
UNCOV
245
    if work_url.include?("/describe/describe/")
×
246
      Rails.logger.error("URL #{work_url} included /describe/describe/ and was fixed. See https://github.com/pulibrary/pdc_describe/issues/1783")
×
247
      work_url = work_url.gsub("/describe/describe/", "/describe/")
×
248
    end
249

UNCOV
250
    message = if curator_user_id.to_i == current_user.id
×
UNCOV
251
                "Self-assigned @#{current_user.uid} as curator for work #{work_url}"
×
252
              else
UNCOV
253
                "Set curator to @#{new_curator.uid} for work #{work_url}"
×
254
              end
UNCOV
255
    WorkActivity.add_work_activity(id, message, current_user.id, activity_type: WorkActivity::SYSTEM)
×
256
  end
257

258
  def add_message(message, current_user_id)
1✔
UNCOV
259
    WorkActivity.add_work_activity(id, message, current_user_id, activity_type: WorkActivity::MESSAGE)
×
260
  end
261

262
  def add_provenance_note(date, note, current_user_id, change_label = "")
1✔
263
    WorkActivity.add_work_activity(id, { note:, change_label: }.to_json, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
10✔
264
    # WorkActivity.add_work_activity(id, note, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
265
  end
266

267
  def log_changes(resource_compare, current_user_id)
1✔
268
    return if resource_compare.identical?
10✔
269
    WorkActivity.add_work_activity(id, resource_compare.differences.to_json, current_user_id, activity_type: WorkActivity::CHANGES)
9✔
270
  end
271

272
  def log_file_changes(current_user_id)
1✔
273
    return if changes.count == 0
1✔
274
    WorkActivity.add_work_activity(id, changes.to_json, current_user_id, activity_type: WorkActivity::FILE_CHANGES)
1✔
275
  end
276

277
  def activities
1✔
278
    WorkActivity.activities_for_work(id, WorkActivity::MESSAGE_ACTIVITY_TYPES + WorkActivity::CHANGE_LOG_ACTIVITY_TYPES)
11✔
279
  end
280

281
  def new_notification_count_for_user(user_id)
1✔
282
    WorkActivityNotification.joins(:work_activity)
3✔
283
                            .where(user_id:, read_at: nil)
284
                            .where(work_activity: { work_id: id })
285
                            .count
286
  end
287

288
  # Marks as read the notifications for the given user_id in this work.
289
  # In practice, the user_id is the id of the current user and therefore this method marks the current's user
290
  # notifications as read.
291
  def mark_new_notifications_as_read(user_id)
1✔
292
    activities.each do |activity|
11✔
293
      unread_notifications = WorkActivityNotification.where(user_id:, work_activity_id: activity.id, read_at: nil)
15✔
294
      unread_notifications.each do |notification|
15✔
295
        notification.read_at = Time.now.utc
1✔
296
        notification.save
1✔
297
      end
298
    end
299
  end
300

301
  def current_transition
1✔
UNCOV
302
    aasm.current_event.to_s.humanize.delete("!")
×
303
  end
304

305
  # Retrieve the S3 file uploads associated with the Work
306
  # @return [Array<S3File>]
307
  def uploads
1✔
308
    return post_curation_uploads if approved?
36✔
309

310
    pre_curation_uploads
34✔
311
  end
312

313
  # Retrieve the S3 file uploads named "README"
314
  # @return [Array<S3File>]
315
  def readme_uploads
1✔
UNCOV
316
    uploads.select { |s3_file| s3_file.filename.include?("README") }
×
317
  end
318

319
  # Retrieve the S3 file uploads which are research artifacts proper (not README or other files providing metadata/documentation)
320
  # @return [Array<S3File>]
321
  def artifact_uploads
1✔
NEW
322
    uploads.reject { |s3_file| s3_file.filename.include?("README") }
×
323
  end
324

325
  # Returns the list of files for the work with some basic information about each of them.
326
  # This method is much faster than `uploads` because it does not return the actual S3File
327
  # objects to the client, instead it returns just a few selected data elements.
328
  def file_list
1✔
329
    s3_files = approved? ? post_curation_uploads : pre_curation_uploads
40✔
330
    files_info = s3_files.map do |s3_file|
40✔
331
      {
332
        "safe_id": s3_file.safe_id,
6✔
333
        "filename": s3_file.filename,
334
        "filename_display": s3_file.filename_display,
335
        "last_modified": s3_file.last_modified,
336
        "last_modified_display": s3_file.last_modified_display,
337
        "size": s3_file.size,
338
        "display_size": s3_file.display_size,
339
        "url": s3_file.url
340
      }
341
    end
342
    files_info
40✔
343
  end
344

345
  def total_file_size
1✔
346
    @total_file_size ||= begin
11✔
347
      total_size = 0
11✔
348
      file_list.each do |file|
11✔
349
        total_size += file[:size]
1✔
350
      end
351
      total_size
11✔
352
    end
353
  end
354

355
  # Fetches the data from S3 directly bypassing ActiveStorage
356
  def pre_curation_uploads
1✔
357
    s3_query_service.client_s3_files.sort_by(&:filename)
72✔
358
  end
359

360
  # Accesses post-curation S3 Bucket Objects
361
  def post_curation_s3_resources
1✔
362
    if approved?
5✔
363
      s3_resources
4✔
364
    else
365
      []
1✔
366
    end
367
  end
368

369
  # Returns the files in post-curation for the work
370
  def post_curation_uploads(force_post_curation: false)
1✔
371
    if force_post_curation
5✔
372
      # Always use the post-curation data regardless of the work's status
UNCOV
373
      post_curation_s3_query_service = S3QueryService.new(self, "postcuration")
×
UNCOV
374
      post_curation_s3_query_service.data_profile.fetch(:objects, [])
×
375
    else
376
      # Return the list based of files honoring the work status
377
      post_curation_s3_resources
5✔
378
    end
379
  end
380

381
  def s3_files
1✔
382
    pre_curation_uploads
×
383
  end
384

385
  def s3_client
1✔
UNCOV
386
    s3_query_service.client
×
387
  end
388

389
  delegate :bucket_name, :prefix, to: :s3_query_service
1✔
390
  delegate :doi_attribute_url, :curator_or_current_uid, to: :datacite_service
1✔
391

392
  # Generates the S3 Object key
393
  # @return [String]
394
  def s3_object_key
1✔
UNCOV
395
    "#{doi}/#{id}"
×
396
  end
397

398
  # Transmit a HEAD request for the S3 Bucket directory for this Work
399
  # @param bucket_name location to be checked to be found
400
  # @return [Aws::S3::Types::HeadObjectOutput]
401
  def find_post_curation_s3_dir(bucket_name:)
1✔
402
    # TODO: Directories really do not exists in S3
403
    #      if we really need this check then we need to do something else to check the bucket
UNCOV
404
    s3_client.head_object({
×
405
                            bucket: bucket_name,
406
                            key: s3_object_key
407
                          })
UNCOV
408
    true
×
409
  rescue Aws::S3::Errors::NotFound
UNCOV
410
    nil
×
411
  end
412

413
  # Generates the JSON serialized expression of the Work
414
  # @param args [Array<Hash>]
415
  # @option args [Boolean] :force_post_curation Force the request of AWS S3
416
  #   Resources, clearing the in-memory cache
417
  # @return [String]
418
  def as_json(*args)
1✔
419
    files = files_as_json(*args)
1✔
420

421
    # to_json returns a string of serialized JSON.
422
    # as_json returns the corresponding hash.
423
    {
424
      "resource" => resource.as_json,
1✔
425
      "files" => files,
426
      "group" => group.as_json.except("id"),
427
      "embargo_date" => embargo_date_as_json,
428
      "created_at" => format_date_for_solr(created_at),
429
      "updated_at" => format_date_for_solr(updated_at)
430
    }
431
  end
432

433
  # Format the date for Apache Solr
434
  # @param date [ActiveSupport::TimeWithZone]
435
  # @return [String]
436
  def format_date_for_solr(date)
1✔
437
    date.strftime("%Y-%m-%dT%H:%M:%SZ")
2✔
438
  end
439

440
  def pre_curation_uploads_count
1✔
UNCOV
441
    s3_query_service.file_count
×
442
  end
443

444
  delegate :ark, :doi, :resource_type, :resource_type=, :resource_type_general, :resource_type_general=,
1✔
445
           :to_xml, to: :resource
446

447
  # S3QueryService object associated with this Work
448
  # @return [S3QueryService]
449
  def s3_query_service
1✔
450
    mode = approved? ? "postcuration" : "precuration"
117✔
451
    @s3_query_service ||= S3QueryService.new(self, mode)
117✔
452
  end
453

454
  def past_snapshots
1✔
455
    UploadSnapshot.where(work: self)
×
456
  end
457

458
  # Build or find persisted UploadSnapshot models for this Work
459
  # @param [integer] user_id optional user to assign the snapshot to
460
  # @return [UploadSnapshot]
461
  def reload_snapshots(user_id: nil)
1✔
UNCOV
462
    work_changes = []
×
UNCOV
463
    s3_files = pre_curation_uploads
×
UNCOV
464
    s3_filenames = s3_files.map(&:filename)
×
465

UNCOV
466
    upload_snapshot = latest_snapshot
×
467

UNCOV
468
    upload_snapshot.snapshot_deletions(work_changes, s3_filenames)
×
469

UNCOV
470
    upload_snapshot.snapshot_modifications(work_changes, s3_files)
×
471

472
    # Create WorkActivity models with the set of changes
UNCOV
473
    unless work_changes.empty?
×
UNCOV
474
      new_snapshot = UploadSnapshot.new(work: self, url: s3_query_service.prefix)
×
UNCOV
475
      new_snapshot.store_files(s3_files)
×
UNCOV
476
      new_snapshot.save!
×
UNCOV
477
      WorkActivity.add_work_activity(id, work_changes.to_json, user_id, activity_type: WorkActivity::FILE_CHANGES)
×
478
    end
479
  end
480

481
  def self.presenter_class
1✔
482
    WorkPresenter
18✔
483
  end
484

485
  def presenter
1✔
486
    self.class.presenter_class.new(work: self)
18✔
487
  end
488

489
  def changes
1✔
490
    @changes ||= []
14✔
491
  end
492

493
  def track_change(action, filename)
1✔
494
    changes << { action:, filename: }
1✔
495
  end
496

497
  # rubocop:disable Naming/PredicateName
498
  def has_rights?(rights_id)
1✔
499
    resource.rights_many.index { |rights| rights.identifier == rights_id } != nil
351✔
500
  end
501
  # rubocop:enable Naming/PredicateName
502

503
  # This is the solr id / work show page in PDC Discovery
504
  def pdc_discovery_url
1✔
505
    "https://datacommons.princeton.edu/discovery/catalog/doi-#{doi.tr('/', '-').tr('.', '-')}"
22✔
506
  end
507

508
  # Determine whether or not the Work is under active embargo
509
  # @return [Boolean]
510
  def embargoed?
1✔
511
    return false if embargo_date.blank?
23✔
512

UNCOV
513
    current_date = Time.zone.now
×
UNCOV
514
    embargo_date >= current_date
×
515
  end
516

517
  def upload_count
1✔
518
    @upload_count ||= s3_query_service.count_objects
131✔
519
  end
520

521
  protected
1✔
522

523
    def work_validator
1✔
524
      @work_validator ||= WorkValidator.new(self)
63✔
525
    end
526

527
    # This must be protected, NOT private for ActiveRecord to work properly with this attribute.
528
    #   Protected will still keep others from setting the metatdata, but allows ActiveRecord the access it needs
529
    def metadata=(metadata)
1✔
530
      super
100✔
531
      @resource = PDCMetadata::Resource.new_from_jsonb(metadata)
100✔
532
    end
533

534
  private
1✔
535

536
    def publish(user)
1✔
UNCOV
537
      datacite_service.publish_doi(user)
×
UNCOV
538
      update_ark_information
×
UNCOV
539
      publish_precurated_files(user)
×
UNCOV
540
      save!
×
541
    end
542

543
    # Update EZID (our provider of ARKs) with the new information for this work.
544
    def update_ark_information
1✔
545
      # We only want to update the ark url under certain conditions.
546
      # Set this value in config/update_ark_url.yml
UNCOV
547
      if Rails.configuration.update_ark_url
×
UNCOV
548
        if ark.present?
×
UNCOV
549
          Ark.update(ark, datacite_service.doi_attribute_url)
×
550
        end
551
      end
552
    end
553

554
    def track_state_change(user, state = aasm.to_state)
1✔
555
      uw = UserWork.new(user_id: user.id, work_id: id, state:)
3✔
556
      uw.save!
3✔
557
      WorkActivity.add_work_activity(id, "marked as #{state.to_s.titleize}", user.id, activity_type: WorkActivity::SYSTEM)
3✔
558
      WorkStateTransitionNotification.new(self, user.id).send
3✔
559
    end
560

561
    # Request S3 Bucket Objects associated with this Work
562
    # @return [Array<S3File>]
563
    def s3_resources
1✔
564
      data_profile = s3_query_service.data_profile
4✔
565
      data_profile.fetch(:objects, [])
4✔
566
    end
567
    alias pre_curation_s3_resources s3_resources
1✔
568

569
    def s3_object_persisted?(s3_file)
1✔
570
      uploads_keys = uploads.map(&:key)
×
571
      uploads_keys.include?(s3_file.key)
×
572
    end
573

574
    def publish_precurated_files(user)
1✔
575
      # We need to explicitly check the to post-curation bucket here.
UNCOV
576
      s3_post_curation_query_service = S3QueryService.new(self, "postcuration")
×
577

UNCOV
578
      s3_dir = find_post_curation_s3_dir(bucket_name: s3_post_curation_query_service.bucket_name)
×
UNCOV
579
      raise(StandardError, "Attempting to publish a Work with an existing S3 Bucket directory for: #{s3_object_key}") unless s3_dir.nil?
×
580

581
      # Copy the pre-curation S3 Objects to the post-curation S3 Bucket...
UNCOV
582
      s3_query_service.publish_files(user)
×
583
    end
584

585
    def latest_snapshot
1✔
UNCOV
586
      return upload_snapshots.first unless upload_snapshots.empty?
×
587

UNCOV
588
      UploadSnapshot.new(work: self, files: [])
×
589
    end
590

591
    def datacite_service
1✔
592
      @datacite_service ||= PULDatacite.new(self)
1✔
593
    end
594

595
    def files_as_json(*args)
1✔
596
      return [] if embargoed?
1✔
597

598
      force_post_curation = args.any? { |arg| arg[:force_post_curation] == true }
2✔
599

600
      # Pre-curation files are not accessible externally,
601
      # so we are not interested in listing them in JSON.
602
      post_curation_uploads(force_post_curation:).map do |upload|
1✔
603
        {
UNCOV
604
          "filename": upload.filename,
×
605
          "size": upload.size,
606
          "display_size": upload.display_size,
607
          "url": upload.globus_url
608
        }
609
      end
610
    end
611

612
    def embargo_date_as_json
1✔
613
      if embargo_date.present?
1✔
UNCOV
614
        embargo_datetime = embargo_date.to_datetime
×
UNCOV
615
        embargo_date_iso8601 = embargo_datetime.iso8601
×
616
        # Apache Solr timestamps require the following format:
617
        # 1972-05-20T17:33:18Z
618
        # https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html
UNCOV
619
        embargo_date_iso8601.gsub(/\+.+$/, "Z")
×
620
      end
621
    end
622
end
623
# rubocop:enable Metrics/ClassLength
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc