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

pulibrary / pdc_describe / 7ae96b6b-0a64-4479-9fa1-8ba6526e2c87

20 Mar 2024 12:42PM UTC coverage: 30.068% (-66.2%) from 96.266%
7ae96b6b-0a64-4479-9fa1-8ba6526e2c87

Pull #1701

circleci

leefaisonr
makes it so that links open in new window
Pull Request #1701: Update language on submission form

1019 of 3389 relevant lines covered (30.07%)

0.4 hits per line

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

40.97
/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
1✔
9
  has_many :user_work, -> { order(updated_at: :desc) }, dependent: :destroy
1✔
10
  has_many :upload_snapshots, -> { order(updated_at: :desc) }, dependent: :destroy
1✔
11
  has_many_attached :pre_curation_uploads, service: :amazon_pre_curation
1✔
12

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

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

19
  attr_accessor :user_entered_doi
1✔
20

21
  alias state_history user_work
1✔
22

23
  delegate :valid_to_submit, :valid_to_draft, :valid_to_approve, to: :work_validator
1✔
24

25
  include AASM
1✔
26

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

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

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

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

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

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

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

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

59
    after_all_events :track_state_change
1✔
60
  end
61

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

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

81
  def editable_in_current_state?(user)
1✔
82
    # anyone with edit privleges can edit a work while it is in draft or awaiting approval
83
    return editable_by?(user) if draft? || awaiting_approval?
×
84

85
    # Only admisitrators can edit a work in other states
86
    administered_by?(user)
×
87
  end
88

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

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

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

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

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

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

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

120
  after_save do |work|
1✔
121
    if work.approved?
×
122
      work.reload
×
123
    end
124
  end
125

126
  validate do |_work|
1✔
127
    work_validator.valid?
×
128
  end
129

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

148
  def title
1✔
149
    resource.main_title
×
150
  end
151

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

165
  def form_attributes
1✔
166
    {
167
      uploads: uploads_attributes
×
168
    }
169
  end
170

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

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

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

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

196
  def resource
1✔
197
    @resource ||= PDCMetadata::Resource.new_from_jsonb(metadata)
1✔
198
  end
199

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

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

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

210
  def files_location_cluster?
1✔
211
    files_location == "file_cluster"
×
212
  end
213

214
  def files_location_other?
1✔
215
    files_location == "file_other"
×
216
  end
217

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

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

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

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

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

243
    work_url = "[#{title}](#{Rails.application.routes.url_helpers.work_url(self)})"
×
244
    message = if curator_user_id.to_i == current_user.id
×
245
                "Self-assigned @#{current_user.uid} as curator for work #{work_url}"
×
246
              else
247
                "Set curator to @#{new_curator.uid} for work #{work_url}"
×
248
              end
249
    WorkActivity.add_work_activity(id, message, current_user.id, activity_type: WorkActivity::SYSTEM)
×
250
  end
251

252
  def add_message(message, current_user_id)
1✔
253
    WorkActivity.add_work_activity(id, message, current_user_id, activity_type: WorkActivity::MESSAGE)
×
254
  end
255

256
  def add_provenance_note(date, note, current_user_id, change_label = "")
1✔
257
    WorkActivity.add_work_activity(id, { note:, change_label: }.to_json, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
×
258
    # WorkActivity.add_work_activity(id, note, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
259
  end
260

261
  def log_changes(resource_compare, current_user_id)
1✔
262
    return if resource_compare.identical?
×
263
    WorkActivity.add_work_activity(id, resource_compare.differences.to_json, current_user_id, activity_type: WorkActivity::CHANGES)
×
264
  end
265

266
  def log_file_changes(current_user_id)
1✔
267
    return if changes.count == 0
×
268
    WorkActivity.add_work_activity(id, changes.to_json, current_user_id, activity_type: WorkActivity::FILE_CHANGES)
×
269
  end
270

271
  def activities
1✔
272
    WorkActivity.activities_for_work(id, WorkActivity::MESSAGE_ACTIVITY_TYPES + WorkActivity::CHANGE_LOG_ACTIVITY_TYPES)
×
273
  end
274

275
  def new_notification_count_for_user(user_id)
1✔
276
    WorkActivityNotification.joins(:work_activity)
×
277
                            .where(user_id:, read_at: nil)
278
                            .where(work_activity: { work_id: id })
279
                            .count
280
  end
281

282
  # Marks as read the notifications for the given user_id in this work.
283
  # In practice, the user_id is the id of the current user and therefore this method marks the current's user
284
  # notifications as read.
285
  def mark_new_notifications_as_read(user_id)
1✔
286
    activities.each do |activity|
×
287
      unread_notifications = WorkActivityNotification.where(user_id:, work_activity_id: activity.id, read_at: nil)
×
288
      unread_notifications.each do |notification|
×
289
        notification.read_at = Time.now.utc
×
290
        notification.save
×
291
      end
292
    end
293
  end
294

295
  def current_transition
1✔
296
    aasm.current_event.to_s.humanize.delete("!")
×
297
  end
298

299
  def uploads
1✔
300
    return post_curation_uploads if approved?
×
301

302
    pre_curation_uploads_fast
×
303
  end
304

305
  # Returns the list of files for the work with some basic information about each of them.
306
  # This method is much faster than `uploads` because it does not return the actual S3File
307
  # objects to the client, instead it returns just a few selected data elements.
308
  def file_list
1✔
309
    s3_files = approved? ? post_curation_uploads : pre_curation_uploads_fast
×
310
    files_info = s3_files.map do |s3_file|
×
311
      {
312
        "safe_id": s3_file.safe_id,
×
313
        "filename": s3_file.filename,
314
        "filename_display": s3_file.filename_display,
315
        "last_modified": s3_file.last_modified,
316
        "last_modified_display": s3_file.last_modified_display,
317
        "size": s3_file.size,
318
        "display_size": s3_file.display_size,
319
        "url": s3_file.url
320
      }
321
    end
322
    files_info
×
323
  end
324

325
  def total_file_size
1✔
326
    @total_file_size ||= begin
×
327
      total_size = 0
×
328
      file_list.each do |file|
×
329
        total_size += file[:size]
×
330
      end
331
      total_size
×
332
    end
333
  end
334

335
  # Fetches the data from S3 directly bypassing ActiveStorage
336
  def pre_curation_uploads_fast
1✔
337
    s3_query_service.client_s3_files.sort_by(&:filename)
×
338
  end
339

340
  # Accesses post-curation S3 Bucket Objects
341
  def post_curation_s3_resources
1✔
342
    if approved?
×
343
      s3_resources
×
344
    else
345
      []
×
346
    end
347
  end
348

349
  # Returns the files in post-curation for the work
350
  def post_curation_uploads(force_post_curation: false)
1✔
351
    if force_post_curation
×
352
      # Always use the post-curation data regardless of the work's status
353
      post_curation_s3_query_service = S3QueryService.new(self, "postcuration")
×
354
      post_curation_s3_query_service.data_profile.fetch(:objects, [])
×
355
    else
356
      # Return the list based of files honoring the work status
357
      post_curation_s3_resources
×
358
    end
359
  end
360

361
  def s3_files
1✔
362
    pre_curation_uploads_fast
×
363
  end
364

365
  def s3_client
1✔
366
    s3_query_service.client
×
367
  end
368

369
  delegate :bucket_name, :prefix, to: :s3_query_service
1✔
370
  delegate :doi_attribute_url, :curator_or_current_uid, to: :datacite_service
1✔
371

372
  # Generates the S3 Object key
373
  # @return [String]
374
  def s3_object_key
1✔
375
    "#{doi}/#{id}"
×
376
  end
377

378
  # Transmit a HEAD request for the S3 Bucket directory for this Work
379
  # @param bucket_name location to be checked to be found
380
  # @return [Aws::S3::Types::HeadObjectOutput]
381
  def find_post_curation_s3_dir(bucket_name:)
1✔
382
    # TODO: Directories really do not exists in S3
383
    #      if we really need this check then we need to do something else to check the bucket
384
    s3_client.head_object({
×
385
                            bucket: bucket_name,
386
                            key: s3_object_key
387
                          })
388
    true
×
389
  rescue Aws::S3::Errors::NotFound
390
    nil
×
391
  end
392

393
  # Generates the JSON serialized expression of the Work
394
  # @param args [Array<Hash>]
395
  # @option args [Boolean] :force_post_curation Force the request of AWS S3
396
  #   Resources, clearing the in-memory cache
397
  # @return [String]
398
  def as_json(*args)
1✔
399
    files = files_as_json(*args)
×
400

401
    # to_json returns a string of serialized JSON.
402
    # as_json returns the corresponding hash.
403
    {
404
      "resource" => resource.as_json,
×
405
      "files" => files,
406
      "group" => group.as_json.except("id"),
407
      "embargo_date" => embargo_date_as_json,
408
      "created_at" => format_date_for_solr(created_at),
409
      "updated_at" => format_date_for_solr(updated_at)
410
    }
411
  end
412

413
  # Format the date for Apache Solr
414
  # @param date [ActiveSupport::TimeWithZone]
415
  # @return [String]
416
  def format_date_for_solr(date)
1✔
417
    date.strftime("%Y-%m-%dT%H:%M:%SZ")
×
418
  end
419

420
  def pre_curation_uploads_count
1✔
421
    s3_query_service.file_count
×
422
  end
423

424
  delegate :ark, :doi, :resource_type, :resource_type=, :resource_type_general, :resource_type_general=,
1✔
425
           :to_xml, to: :resource
426

427
  # S3QueryService object associated with this Work
428
  # @return [S3QueryService]
429
  def s3_query_service
1✔
430
    mode = approved? ? "postcuration" : "precuration"
×
431
    @s3_query_service ||= S3QueryService.new(self, mode)
×
432
  end
433

434
  def past_snapshots
1✔
435
    UploadSnapshot.where(work: self)
×
436
  end
437

438
  # Build or find persisted UploadSnapshot models for this Work
439
  # @return [UploadSnapshot]
440
  def reload_snapshots
1✔
441
    work_changes = []
×
442
    s3_files = pre_curation_uploads_fast
×
443
    s3_filenames = s3_files.map(&:filename)
×
444

445
    upload_snapshot = latest_snapshot
×
446

447
    upload_snapshot.snapshot_deletions(work_changes, s3_filenames)
×
448

449
    upload_snapshot.snapshot_modifications(work_changes, s3_files)
×
450

451
    # Create WorkActivity models with the set of changes
452
    unless work_changes.empty?
×
453
      new_snapshot = UploadSnapshot.new(work: self, url: s3_query_service.prefix)
×
454
      new_snapshot.store_files(s3_files)
×
455
      new_snapshot.save!
×
456
      WorkActivity.add_work_activity(id, work_changes.to_json, nil, activity_type: WorkActivity::FILE_CHANGES)
×
457
    end
458
  end
459

460
  def self.presenter_class
1✔
461
    WorkPresenter
×
462
  end
463

464
  def presenter
1✔
465
    self.class.presenter_class.new(work: self)
×
466
  end
467

468
  def changes
1✔
469
    @changes ||= []
×
470
  end
471

472
  def track_change(action, filename)
1✔
473
    changes << { action:, filename: }
×
474
  end
475

476
  # rubocop:disable Naming/PredicateName
477
  def has_rights?(rights_id)
1✔
478
    resource.rights_many.index { |rights| rights.identifier == rights_id } != nil
×
479
  end
480
  # rubocop:enable Naming/PredicateName
481

482
  # This is the solr id / work show page in PDC Discovery
483
  def pdc_discovery_url
1✔
484
    "https://datacommons.princeton.edu/discovery/catalog/doi-#{doi.tr('/', '-').tr('.', '-')}"
×
485
  end
486

487
  # Determine whether or not the Work is under active embargo
488
  # @return [Boolean]
489
  def embargoed?
1✔
490
    return false if embargo_date.blank?
×
491

492
    current_date = Time.zone.now
×
493
    embargo_date >= current_date
×
494
  end
495

496
  def upload_count
1✔
497
    @upload_count ||= s3_query_service.count_objects
×
498
  end
499

500
  protected
1✔
501

502
    def work_validator
1✔
503
      @work_validator ||= WorkValidator.new(self)
×
504
    end
505

506
    # This must be protected, NOT private for ActiveRecord to work properly with this attribute.
507
    #   Protected will still keep others from setting the metatdata, but allows ActiveRecord the access it needs
508
    def metadata=(metadata)
1✔
509
      super
1✔
510
      @resource = PDCMetadata::Resource.new_from_jsonb(metadata)
1✔
511
    end
512

513
  private
1✔
514

515
    def publish(user)
1✔
516
      datacite_service.publish_doi(user)
×
517
      update_ark_information
×
518
      publish_precurated_files(user)
×
519
      save!
×
520
    end
521

522
    # Update EZID (our provider of ARKs) with the new information for this work.
523
    def update_ark_information
1✔
524
      # We only want to update the ark url under certain conditions.
525
      # Set this value in config/update_ark_url.yml
526
      if Rails.configuration.update_ark_url
×
527
        if ark.present?
×
528
          Ark.update(ark, datacite_service.doi_attribute_url)
×
529
        end
530
      end
531
    end
532

533
    # Generates the key for ActiveStorage::Attachment and Attachment::Blob objects
534
    # @param attachment [ActiveStorage::Attachment]
535
    # @return [String]
536
    def generate_attachment_key(attachment)
1✔
537
      attachment_filename = attachment.filename.to_s
×
538
      attachment_key = attachment.key
×
539

540
      # Files actually coming from S3 include the DOI and bucket as part of the file name
541
      #  Files being attached in another manner may not have it, so we should include it.
542
      #  This is really for testing only.
543
      key_base = "#{doi}/#{id}"
×
544
      attachment_key = [key_base, attachment_filename].join("/") unless attachment_key.include?(key_base)
×
545

546
      attachment_ext = File.extname(attachment_filename)
×
547
      attachment_query = attachment_key.gsub(attachment_ext, "")
×
548
      results = ActiveStorage::Blob.where("key LIKE :query", query: "%#{attachment_query}%")
×
549
      blobs = results.to_a
×
550

551
      if blobs.present?
×
552
        index = blobs.length + 1
×
553
        attachment_key = attachment_key.gsub(/\.([a-zA-Z0-9\.]+)$/, "_#{index}.\\1")
×
554
      end
555

556
      attachment_key
×
557
    end
558

559
    def track_state_change(user, state = aasm.to_state)
1✔
560
      uw = UserWork.new(user_id: user.id, work_id: id, state:)
×
561
      uw.save!
×
562
      WorkActivity.add_work_activity(id, "marked as #{state.to_s.titleize}", user.id, activity_type: WorkActivity::SYSTEM)
×
563
      WorkStateTransitionNotification.new(self, user.id).send
×
564
    end
565

566
    # This needs to be called #before_save
567
    # This ensures that new ActiveStorage::Attachment objects are persisted with custom keys (which are generated from the file name and DOI)
568
    # @param new_attachments [Array<ActiveStorage::Attachment>]
569
    def save_new_attachments(new_attachments:)
1✔
570
      new_attachments.each do |attachment|
×
571
        # There are cases (race conditions?) where the ActiveStorage::Blob objects are not persisted
572
        next if attachment.frozen?
×
573

574
        # This ensures that the custom key for the ActiveStorage::Attachment and ActiveStorage::Blob objects are generated
575
        generated_key = generate_attachment_key(attachment)
×
576
        attachment.blob.key = generated_key
×
577
        attachment.blob.save
×
578

579
        attachment.save
×
580
      end
581
    end
582

583
    # Request S3 Bucket Objects associated with this Work
584
    # @return [Array<S3File>]
585
    def s3_resources
1✔
586
      data_profile = s3_query_service.data_profile
×
587
      data_profile.fetch(:objects, [])
×
588
    end
589
    alias pre_curation_s3_resources s3_resources
1✔
590

591
    def s3_object_persisted?(s3_file)
1✔
592
      uploads_keys = uploads.map(&:key)
×
593
      uploads_keys.include?(s3_file.key)
×
594
    end
595

596
    def publish_precurated_files(user)
1✔
597
      # An error is raised if there are no files to be moved
598
      raise(StandardError, "Attempting to publish a Work without attached uploads for #{s3_object_key}") if pre_curation_uploads_fast.empty? && post_curation_uploads.empty?
×
599

600
      # We need to explicitly access to post-curation services here.
601
      # Lets explicitly create it so the state of the work does not have any impact.
602
      s3_post_curation_query_service = S3QueryService.new(self, "postcuration")
×
603

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

607
      # Copy the pre-curation S3 Objects to the post-curation S3 Bucket...
608
      s3_query_service.publish_files(user)
×
609
    end
610

611
    def latest_snapshot
1✔
612
      return upload_snapshots.first unless upload_snapshots.empty?
×
613

614
      UploadSnapshot.new(work: self, files: [])
×
615
    end
616

617
    def datacite_service
1✔
618
      @datacite_service ||= PULDatacite.new(self)
×
619
    end
620

621
    def files_as_json(*args)
1✔
622
      return [] if embargoed?
×
623

624
      force_post_curation = args.any? { |arg| arg[:force_post_curation] == true }
×
625

626
      # Pre-curation files are not accessible externally,
627
      # so we are not interested in listing them in JSON.
628
      post_curation_uploads(force_post_curation:).map do |upload|
×
629
        {
630
          "filename": upload.filename,
×
631
          "size": upload.size,
632
          "display_size": upload.display_size,
633
          "url": upload.globus_url
634
        }
635
      end
636
    end
637

638
    def embargo_date_as_json
1✔
639
      if embargo_date.present?
×
640
        embargo_datetime = embargo_date.to_datetime
×
641
        embargo_date_iso8601 = embargo_datetime.iso8601
×
642
        # Apache Solr timestamps require the following format:
643
        # 1972-05-20T17:33:18Z
644
        # https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html
645
        embargo_date_iso8601.gsub(/\+.+$/, "Z")
×
646
      end
647
    end
648
end
649
# 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