Class: CollavreLinear::Creatives::IntegrationsController

Inherits:
ApplicationController show all
Includes:
Collavre::IntegrationPermission, Collavre::IntegrationSetup
Defined in:
app/controllers/collavre_linear/creatives/integrations_controller.rb

Instance Method Summary collapse

Instance Method Details

#createObject

POST /linear/creatives/:creative_id/integration

Links a Creative subtree to a Linear team + project, provisions a webhook for the team, and enqueues an initial full export.



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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'app/controllers/collavre_linear/creatives/integrations_controller.rb', line 18

def create
   = Current.user.
  unless 
    render json: { error: I18n.t("collavre_linear.errors.not_connected") },
           status: :unprocessable_entity
    return
  end

  team_id          = params[:team_id].to_s.presence
  linear_project_id = params[:linear_project_id].to_s.presence

  unless team_id && linear_project_id
    render json: { error: I18n.t("collavre_linear.errors.missing_params") },
           status: :unprocessable_entity
    return
  end

  # Serialize concurrent link attempts within the same tree. The overlap
  # checks below are check-then-save with NO DB constraint spanning
  # ancestors/descendants, so two requests linking a parent and a child
  # Creative could both pass the overlap check and double-link the subtree.
  # Every Creative in a tree shares one root, so a row lock on that root
  # forces overlapping attempts to run one at a time; the loser then sees
  # the winner's ProjectLink and is rejected. (project_taken's CROSS-tree
  # race stays covered by the linear_project_id unique index +
  # RecordNotUnique backstop below, since disjoint trees have distinct roots
  # and would not serialize on this lock.)
  overlapping_link = false
  link = nil
  begin
    @origin.root.with_lock do
      # Reject linking inside/around an already-linked subtree. A second
      # ProjectLink on an ancestor/descendant would sync against the wrong
      # project: IssueLink is unique per Creative and the exporter resolves
      # both ProjectLink and IssueLink per creative WITHOUT account scope, so
      # the new project would silently update the old project's issues. The
      # check must span ALL accounts — a second admin with their own Linear
      # account could otherwise hijack the subtree. Re-linking @origin to the
      # SAME project stays idempotent (find_or_initialize_by below).
      overlapping_ids =
        (@origin.self_and_ancestors.ids + @origin.self_and_descendants.ids).uniq - [ @origin.id ]
      overlapping = overlapping_ids.any? &&
        CollavreLinear::ProjectLink.where(creative_id: overlapping_ids).exists?

      # @origin may only carry the idempotent re-link target (same account
      # AND same project). Any other existing link on @origin conflicts: a
      # different project (the exporter would keep updating the old issue),
      # or the same subtree claimed by another account. The ancestor/
      # descendant check above excludes @origin, so guard @origin here.
      origin_conflict = @origin.linear_project_links
                               .where.not(account: , linear_project_id: linear_project_id)
                               .exists?

      # One Linear project maps to exactly one Collavre root. Inbound
      # webhooks resolve the project with an UNSCOPED
      # find_by(linear_project_id:), so a second ProjectLink for the same
      # project on a DISJOINT creative (not an ancestor/descendant, so the
      # overlap check above misses it) would make imports/updates land on
      # whichever row the DB returns while both roots export into it. Reject
      # globally; re-linking @origin stays idempotent.
      project_taken = CollavreLinear::ProjectLink
                        .where(linear_project_id: linear_project_id)
                        .where.not(creative_id: @origin.id)
                        .exists?

      if overlapping || origin_conflict || project_taken
        overlapping_link = true
      else
        link = @origin.linear_project_links.find_or_initialize_by(
          account:           ,
          linear_project_id: linear_project_id
        )
        link.team_id = team_id
        link.save!
      end
    end
  rescue ActiveRecord::RecordNotUnique
    # Race-safe backstop for the project_taken check above: the check is
    # check-then-save, so two DISJOINT-tree requests (which do not share the
    # root lock) can both pass it; the linear_project_id unique index rejects
    # the loser's insert. Surface the same conflict instead of a 500.
    overlapping_link = true
  end

  if overlapping_link
    render json: { error: I18n.t("collavre_linear.errors.overlapping_link") },
           status: :unprocessable_entity
    return
  end

  # Existing descendants never fire after-commit callbacks during linking,
  # so enqueue an outbound export for the whole subtree — otherwise a
  # populated tree would only create the root Linear issue. Idempotent:
  # the exporter skips unchanged content via its content hash.
  enqueue_subtree_sync

  # Inbound sync needs a webhook Linear can't auto-provision (app actors
  # lack the admin scope), so the admin sets it up by hand and pastes the
  # secret Linear generates via update_secret. The modal shows that guide.
  render json: { success: true, project_link: serialize_link(link) }
rescue ActiveRecord::RecordInvalid => e
  render json: { error: e.message }, status: :unprocessable_entity
end

#destroyObject

DELETE /linear/creatives/:creative_id/integration

Unlinks the Creative from its Linear project. The webhook is set up by hand in Linear (we never learn its id), so there is nothing to deregister here — the admin removes it in Linear's settings if desired.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'app/controllers/collavre_linear/creatives/integrations_controller.rb', line 127

def destroy
   = Current.user.
  unless 
    render json: { error: I18n.t("collavre_linear.errors.not_connected") },
           status: :unprocessable_entity
    return
  end

  link = @origin.linear_project_links.find_by(account: )
  unless link
    render json: { error: I18n.t("collavre_linear.errors.not_found") },
           status: :not_found
    return
  end

  link.destroy!

  render json: { success: true }
end

#optionsObject

GET /linear/creatives/:creative_id/integration/options

Teams and projects the connected account can see, so the link modal can offer dropdowns instead of asking the user to type raw Linear IDs.



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/controllers/collavre_linear/creatives/integrations_controller.rb', line 218

def options
   = Current.user.
  unless 
    render json: { error: I18n.t("collavre_linear.errors.not_connected") },
           status: :unprocessable_entity
    return
  end

  client = CollavreLinear::Client.new()
  render json: { teams: client.list_teams, projects: client.list_projects }
rescue CollavreLinear::Client::Error => e
  # Surface the Linear-side failure (expired token, revoked scope) instead
  # of leaving the dropdowns silently empty.
  render json: { error: e.message }, status: :bad_gateway
end

#resyncObject

POST /linear/creatives/:creative_id/integration/resync

Re-enqueues a full outbound export for the whole subtree. Mirrors the create path: enqueuing only the root would never recover stale/missing descendant issues. The ParentNotExportedError + retry_on deferral in OutboundSyncJob protects against parent-before-child races, so enqueuing the full subtree is safe.



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'app/controllers/collavre_linear/creatives/integrations_controller.rb', line 154

def resync
   = Current.user.
  unless 
    render json: { error: I18n.t("collavre_linear.errors.not_connected") },
           status: :unprocessable_entity
    return
  end

  unless @origin.linear_project_links.where(account: ).exists?
    render json: { error: I18n.t("collavre_linear.errors.not_found") },
           status: :not_found
    return
  end

  # Resync is the human resolution path the conflict notice points to, so it
  # must break the :conflict freeze — CreativeExporter#update_issue! (and the
  # inbound applier) HALT on conflicted links, so without this the enqueued
  # jobs would report success while making no API call and leaving the
  # conflict stuck. Reopen conflicted links as :dirty so the export re-runs
  # and pushes the local (kept) content Linear never received.
  clear_conflicted_links

  enqueue_subtree_sync

  render json: { success: true, message: I18n.t("collavre_linear.integration.resync_started") }
end

#update_secretObject

POST /linear/creatives/:creative_id/integration/secret

Store the signing secret the admin copied from Linear's webhook settings. Linear owns the secret (it generates one per webhook and won't let us pick it), so the admin pastes it here and we propagate it to the team's sibling links — the one value all of the team's deliveries verify against.



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'app/controllers/collavre_linear/creatives/integrations_controller.rb', line 187

def update_secret
   = Current.user.
  unless 
    render json: { error: I18n.t("collavre_linear.errors.not_connected") },
           status: :unprocessable_entity
    return
  end

  link = @origin.linear_project_links.find_by(account: )
  unless link
    render json: { error: I18n.t("collavre_linear.errors.not_found") },
           status: :not_found
    return
  end

  secret = params[:webhook_secret].to_s.strip
  if secret.blank?
    render json: { error: I18n.t("collavre_linear.errors.missing_secret") },
           status: :unprocessable_entity
    return
  end

  link.update_webhook_secret!(secret)

  render json: { success: true }
end