Class: CollavreLinear::Creatives::IntegrationsController
- Inherits:
-
ApplicationController
- Object
- ApplicationController
- ApplicationController
- CollavreLinear::Creatives::IntegrationsController
- Includes:
- Collavre::IntegrationPermission, Collavre::IntegrationSetup
- Defined in:
- app/controllers/collavre_linear/creatives/integrations_controller.rb
Instance Method Summary collapse
-
#create ⇒ Object
POST /linear/creatives/:creative_id/integration.
-
#destroy ⇒ Object
DELETE /linear/creatives/:creative_id/integration.
-
#options ⇒ Object
GET /linear/creatives/:creative_id/integration/options.
-
#resync ⇒ Object
POST /linear/creatives/:creative_id/integration/resync.
-
#update_secret ⇒ Object
POST /linear/creatives/:creative_id/integration/secret.
Instance Method Details
#create ⇒ Object
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 account = Current.user.linear_account unless account 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: 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: 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. }, status: :unprocessable_entity end |
#destroy ⇒ Object
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 account = Current.user.linear_account unless account render json: { error: I18n.t("collavre_linear.errors.not_connected") }, status: :unprocessable_entity return end link = @origin.linear_project_links.find_by(account: 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 |
#options ⇒ Object
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 account = Current.user.linear_account unless account render json: { error: I18n.t("collavre_linear.errors.not_connected") }, status: :unprocessable_entity return end client = CollavreLinear::Client.new(account) 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. }, status: :bad_gateway end |
#resync ⇒ Object
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 account = Current.user.linear_account unless account render json: { error: I18n.t("collavre_linear.errors.not_connected") }, status: :unprocessable_entity return end unless @origin.linear_project_links.where(account: 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_secret ⇒ Object
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 account = Current.user.linear_account unless account render json: { error: I18n.t("collavre_linear.errors.not_connected") }, status: :unprocessable_entity return end link = @origin.linear_project_links.find_by(account: 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 |