Class: CollavreLinear::OutboundCommentSyncJob

Inherits:
ApplicationJob
  • Object
show all
Defined in:
app/jobs/collavre_linear/outbound_comment_sync_job.rb

Overview

Pushes a single Collavre::Comment to its linked Linear issue as a comment.

CollavreLinear::OutboundCommentSyncJob.perform_later(comment.id)

Idempotent: the IssueLink row is locked and the CommentLink existence is re-checked inside the lock, so concurrent performs (or a duplicate enqueue) never post the same chat message to Linear twice.

Instance Method Summary collapse

Instance Method Details

#perform(comment_id) ⇒ Object



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/jobs/collavre_linear/outbound_comment_sync_job.rb', line 16

def perform(comment_id)
  comment = ::Collavre::Comment.find(comment_id)

  issue_link = comment.creative&.linear_issue_links&.first
  return if issue_link&.linear_issue_id.blank?

   = issue_link.project_link.
  return unless 

  # Captured inside the lock so the self-echo rescue can reconcile AFTER the
  # lock's transaction rolls back — rescuing the unique violation inside the
  # `with_lock` transaction would poison it (Postgres aborts the whole tx).
  posted = nil

  begin
    issue_link.with_lock do
      # Re-check under the lock: an inbound mirror or a prior run may have
      # linked this comment already. One Collavre comment -> one Linear comment.
      return if CollavreLinear::CommentLink.exists?(comment_id: comment.id)

      # Re-check visibility at run time: the comment was public/Main when the
      # observer enqueued this create, but may have been made private or moved
      # out of Main since. No CommentLink exists yet, so the observer cannot
      # enqueue a delete for that change — posting the now-hidden body would
      # leak it. Same predicate the update job re-runs; here it must no-op.
      return unless CollavreLinear::CommentSyncability.syncable?(comment)

      posted = CollavreLinear::Client.new().create_comment(
        issue_id: issue_link.linear_issue_id,
        body:     CollavreLinear::CommentFormatter.outbound_body(comment)
      )
      return if posted[:id].blank?

      # Record Linear's updatedAt for this version so the inbound applier can
      # recognise the create webhook (and any stale echo of it) as our own by
      # timestamp rather than by comparing against the mutable local body.
      CollavreLinear::CommentLink.create!(
        comment_id:        comment.id,
        linear_comment_id: posted[:id],
        issue_link:        issue_link,
        remote_updated_at: posted[:updatedAt]
      )
    end
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
    # Self-echo race: the inbound create webhook for the comment we just posted
    # landed first (app_actor_id is nil, so EchoGuard can't drop it), mirrored
    # it locally, and grabbed the unique linear_comment_id. Adopt that link onto
    # our original comment and drop the duplicate mirror — otherwise the comment
    # is double-represented and our original stays unlinked, so a later edit
    # re-posts it. Mirrors CreativeExporter#reconcile_self_echo!. Any other
    # validation error must still surface.
    raise if e.is_a?(ActiveRecord::RecordInvalid) && !duplicate_comment_link_error?(e)

    reconcile_comment_self_echo!(comment, issue_link, posted)
  end
rescue ActiveRecord::RecordNotFound
  Rails.logger.info(
    "[CollavreLinear::OutboundCommentSyncJob] Comment #{comment_id} not found; skipping"
  )
end