collavre_linear 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/app/controllers/collavre_linear/application_controller.rb +21 -0
- data/app/controllers/collavre_linear/auth_controller.rb +135 -0
- data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
- data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
- data/app/javascript/collavre_linear.js +199 -0
- data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
- data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
- data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
- data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
- data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
- data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
- data/app/models/collavre_linear/account.rb +29 -0
- data/app/models/collavre_linear/application_record.rb +7 -0
- data/app/models/collavre_linear/comment_link.rb +12 -0
- data/app/models/collavre_linear/issue_link.rb +20 -0
- data/app/models/collavre_linear/project_link.rb +76 -0
- data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
- data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
- data/app/services/collavre_linear/client.rb +425 -0
- data/app/services/collavre_linear/comment_formatter.rb +34 -0
- data/app/services/collavre_linear/comment_syncability.rb +33 -0
- data/app/services/collavre_linear/creative_exporter.rb +352 -0
- data/app/services/collavre_linear/echo_guard.rb +36 -0
- data/app/services/collavre_linear/field_mapper.rb +119 -0
- data/app/services/collavre_linear/inbound_applier.rb +599 -0
- data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
- data/app/views/collavre_linear/auth/setup.html.erb +63 -0
- data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
- data/config/initializers/integration_settings.rb +8 -0
- data/config/locales/en.yml +49 -0
- data/config/locales/ko.yml +49 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
- data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
- data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
- data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
- data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
- data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
- data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
- data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
- data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
- data/lib/collavre_linear/engine.rb +122 -0
- data/lib/collavre_linear/version.rb +3 -0
- data/lib/collavre_linear.rb +5 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5cc482eb53e316ac259f9565368c50a59cbd1dce01be3b74100c41b239919731
|
|
4
|
+
data.tar.gz: cccca601d9c57cd0d45ee603d50b46663d6d3daac3373e93a4b4e3b9b59348b1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e4a88144150cd9d67ff55433fde009579709cb572cdc08a7cd5209c6d187394917a0efdcd8bbd6a73d60e85fc46ae5d21e8ac731b015b0a0973819e73b51cd61
|
|
7
|
+
data.tar.gz: 28ee1fc888af6a9a86fd2b18f30883668d273111ad01912a14cc73e0eb598ae412c0efd0733e850013e4da2e81bd4b888b564e74e0e654619df849457d13ec2c
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
class ApplicationController < ::ApplicationController
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Public HTTPS URL Linear will POST inbound webhook deliveries to. The
|
|
8
|
+
# engine is `isolate_namespace`d and mounted at MOUNT_PATH (/linear), so we
|
|
9
|
+
# fold the mount prefix back in explicitly via `script_name:` — the
|
|
10
|
+
# generated URL MUST be the fully mounted /linear/webhook, never the
|
|
11
|
+
# engine-internal /webhook, or Linear delivers to a 404 and inbound sync
|
|
12
|
+
# never runs.
|
|
13
|
+
def linear_webhook_url
|
|
14
|
+
CollavreLinear::Engine.routes.url_helpers.webhook_url(
|
|
15
|
+
Rails.application.config.action_mailer.default_url_options.merge(
|
|
16
|
+
script_name: CollavreLinear::Engine::MOUNT_PATH
|
|
17
|
+
)
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
class AuthController < ApplicationController
|
|
5
|
+
# callback is reached after the user authorizes on Linear's site; no
|
|
6
|
+
# session exists yet at that point so we allow unauthenticated access.
|
|
7
|
+
# We then require a logged-in user to associate the account.
|
|
8
|
+
before_action :require_authenticated_user!, only: %i[callback store_creative setup]
|
|
9
|
+
|
|
10
|
+
# GET /linear/auth/callback?code=...&state=...
|
|
11
|
+
# Exchange the authorization code, create/update the Account, capture
|
|
12
|
+
# the OAuth app actor id via the GraphQL viewer query.
|
|
13
|
+
def callback
|
|
14
|
+
expected_state = session.delete(:linear_oauth_state)
|
|
15
|
+
unless params[:state].present? && params[:state] == expected_state
|
|
16
|
+
redirect_to collavre.creatives_path,
|
|
17
|
+
alert: I18n.t("collavre_linear.auth.invalid_state",
|
|
18
|
+
default: "Invalid OAuth state. Please try connecting again.")
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
tokens = OAuthTokenService.exchange(params[:code])
|
|
23
|
+
|
|
24
|
+
# Build a temporary account so we can call the viewer query
|
|
25
|
+
temp_account = CollavreLinear::Account.new(
|
|
26
|
+
access_token: tokens[:access_token]
|
|
27
|
+
)
|
|
28
|
+
client = CollavreLinear::Client.new(temp_account)
|
|
29
|
+
viewer = client.viewer_and_app_actor
|
|
30
|
+
|
|
31
|
+
# Never reassign a Linear identity already owned by another Collavre user
|
|
32
|
+
# (that would steal their account + project links). Bind by the CURRENT
|
|
33
|
+
# user instead (user_id is unique — one Linear account per user), so a
|
|
34
|
+
# reconnect with a different Linear UID updates in place rather than
|
|
35
|
+
# violating the unique index.
|
|
36
|
+
if CollavreLinear::Account.where(linear_uid: viewer[:user_id])
|
|
37
|
+
.where.not(user_id: Current.user.id).exists?
|
|
38
|
+
redirect_to collavre.creatives_path,
|
|
39
|
+
alert: I18n.t("collavre_linear.auth.already_linked_other")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
account = CollavreLinear::Account.find_or_initialize_by(
|
|
44
|
+
user_id: Current.user.id
|
|
45
|
+
)
|
|
46
|
+
account.linear_uid = viewer[:user_id]
|
|
47
|
+
account.access_token = tokens[:access_token]
|
|
48
|
+
account.refresh_token = tokens[:refresh_token]
|
|
49
|
+
account.token_expires_at = Time.current + tokens[:expires_in].to_i.seconds if tokens[:expires_in]
|
|
50
|
+
account.app_actor_id = viewer[:app_actor_id]
|
|
51
|
+
account.workspace_id = viewer[:organization_id]
|
|
52
|
+
account.save!
|
|
53
|
+
|
|
54
|
+
creative_id = session.delete(:linear_creative_id)
|
|
55
|
+
if creative_id.present?
|
|
56
|
+
# Redirect to the MOUNTED path (/linear/auth/setup). The engine is
|
|
57
|
+
# detached, so we fold its mount prefix in explicitly via `script_name:`
|
|
58
|
+
# — the bare engine-internal /auth/setup would 404 in the popup.
|
|
59
|
+
redirect_to linear_engine.setup_auth_path(
|
|
60
|
+
creative_id: creative_id,
|
|
61
|
+
script_name: CollavreLinear::Engine::MOUNT_PATH
|
|
62
|
+
)
|
|
63
|
+
else
|
|
64
|
+
redirect_to collavre.creatives_path,
|
|
65
|
+
notice: I18n.t("collavre_linear.auth.connected", default: "Linear connected.")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GET /linear/auth/setup?creative_id=...
|
|
70
|
+
# Setup wizard entry point — mirrors the collavre_github pattern.
|
|
71
|
+
def setup
|
|
72
|
+
@creative_id = params[:creative_id]
|
|
73
|
+
|
|
74
|
+
unless @creative_id.present?
|
|
75
|
+
render plain: I18n.t("collavre_linear.integration.missing_creative",
|
|
76
|
+
default: "creative_id is required"),
|
|
77
|
+
status: :bad_request
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@creative = Collavre::Creative.find_by(id: @creative_id)
|
|
82
|
+
unless @creative
|
|
83
|
+
render plain: I18n.t("collavre_linear.integration.missing_creative",
|
|
84
|
+
default: "Creative not found"),
|
|
85
|
+
status: :not_found
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
unless @creative.has_permission?(Current.user, :admin)
|
|
90
|
+
render plain: I18n.t("collavre_linear.errors.forbidden",
|
|
91
|
+
default: "Access denied"),
|
|
92
|
+
status: :forbidden
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
render layout: false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# POST /linear/auth/store_creative
|
|
100
|
+
# Persist creative_id and generate a CSRF state token in the session, then
|
|
101
|
+
# redirect the popup window straight to Linear's OAuth screen. The form
|
|
102
|
+
# POSTs into a popup with no JS handler, so returning JSON here would just
|
|
103
|
+
# render raw JSON in the popup and never start OAuth — a 303 redirect makes
|
|
104
|
+
# the popup follow through to Linear.
|
|
105
|
+
def store_creative
|
|
106
|
+
missing = OAuthTokenService.missing_config
|
|
107
|
+
if missing.any?
|
|
108
|
+
render plain: I18n.t("collavre_linear.auth.oauth_config_missing",
|
|
109
|
+
keys: missing.join(", ")),
|
|
110
|
+
status: :service_unavailable
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
session[:linear_creative_id] = params[:creative_id]
|
|
115
|
+
state = SecureRandom.hex(24)
|
|
116
|
+
session[:linear_oauth_state] = state
|
|
117
|
+
authorize_url = OAuthTokenService.authorize_url(state: state, creative_id: params[:creative_id])
|
|
118
|
+
redirect_to authorize_url, allow_other_host: true, status: :see_other
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def require_authenticated_user!
|
|
124
|
+
return if Current.user
|
|
125
|
+
|
|
126
|
+
redirect_to collavre.new_session_path,
|
|
127
|
+
alert: I18n.t("collavre_linear.auth.login_first",
|
|
128
|
+
default: "Please log in first.")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def linear_engine
|
|
132
|
+
CollavreLinear::Engine.routes.url_helpers
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
module Creatives
|
|
5
|
+
class IntegrationsController < ApplicationController
|
|
6
|
+
include Collavre::IntegrationSetup
|
|
7
|
+
include Collavre::IntegrationPermission
|
|
8
|
+
|
|
9
|
+
before_action :set_creative
|
|
10
|
+
before_action :set_origin
|
|
11
|
+
before_action :ensure_read_permission
|
|
12
|
+
before_action :ensure_admin_permission, only: %i[create destroy resync update_secret options]
|
|
13
|
+
|
|
14
|
+
# POST /linear/creatives/:creative_id/integration
|
|
15
|
+
#
|
|
16
|
+
# Links a Creative subtree to a Linear team + project, provisions a
|
|
17
|
+
# webhook for the team, and enqueues an initial full export.
|
|
18
|
+
def create
|
|
19
|
+
account = Current.user.linear_account
|
|
20
|
+
unless account
|
|
21
|
+
render json: { error: I18n.t("collavre_linear.errors.not_connected") },
|
|
22
|
+
status: :unprocessable_entity
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
team_id = params[:team_id].to_s.presence
|
|
27
|
+
linear_project_id = params[:linear_project_id].to_s.presence
|
|
28
|
+
|
|
29
|
+
unless team_id && linear_project_id
|
|
30
|
+
render json: { error: I18n.t("collavre_linear.errors.missing_params") },
|
|
31
|
+
status: :unprocessable_entity
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Serialize concurrent link attempts within the same tree. The overlap
|
|
36
|
+
# checks below are check-then-save with NO DB constraint spanning
|
|
37
|
+
# ancestors/descendants, so two requests linking a parent and a child
|
|
38
|
+
# Creative could both pass the overlap check and double-link the subtree.
|
|
39
|
+
# Every Creative in a tree shares one root, so a row lock on that root
|
|
40
|
+
# forces overlapping attempts to run one at a time; the loser then sees
|
|
41
|
+
# the winner's ProjectLink and is rejected. (project_taken's CROSS-tree
|
|
42
|
+
# race stays covered by the linear_project_id unique index +
|
|
43
|
+
# RecordNotUnique backstop below, since disjoint trees have distinct roots
|
|
44
|
+
# and would not serialize on this lock.)
|
|
45
|
+
overlapping_link = false
|
|
46
|
+
link = nil
|
|
47
|
+
begin
|
|
48
|
+
@origin.root.with_lock do
|
|
49
|
+
# Reject linking inside/around an already-linked subtree. A second
|
|
50
|
+
# ProjectLink on an ancestor/descendant would sync against the wrong
|
|
51
|
+
# project: IssueLink is unique per Creative and the exporter resolves
|
|
52
|
+
# both ProjectLink and IssueLink per creative WITHOUT account scope, so
|
|
53
|
+
# the new project would silently update the old project's issues. The
|
|
54
|
+
# check must span ALL accounts — a second admin with their own Linear
|
|
55
|
+
# account could otherwise hijack the subtree. Re-linking @origin to the
|
|
56
|
+
# SAME project stays idempotent (find_or_initialize_by below).
|
|
57
|
+
overlapping_ids =
|
|
58
|
+
(@origin.self_and_ancestors.ids + @origin.self_and_descendants.ids).uniq - [ @origin.id ]
|
|
59
|
+
overlapping = overlapping_ids.any? &&
|
|
60
|
+
CollavreLinear::ProjectLink.where(creative_id: overlapping_ids).exists?
|
|
61
|
+
|
|
62
|
+
# @origin may only carry the idempotent re-link target (same account
|
|
63
|
+
# AND same project). Any other existing link on @origin conflicts: a
|
|
64
|
+
# different project (the exporter would keep updating the old issue),
|
|
65
|
+
# or the same subtree claimed by another account. The ancestor/
|
|
66
|
+
# descendant check above excludes @origin, so guard @origin here.
|
|
67
|
+
origin_conflict = @origin.linear_project_links
|
|
68
|
+
.where.not(account: account, linear_project_id: linear_project_id)
|
|
69
|
+
.exists?
|
|
70
|
+
|
|
71
|
+
# One Linear project maps to exactly one Collavre root. Inbound
|
|
72
|
+
# webhooks resolve the project with an UNSCOPED
|
|
73
|
+
# find_by(linear_project_id:), so a second ProjectLink for the same
|
|
74
|
+
# project on a DISJOINT creative (not an ancestor/descendant, so the
|
|
75
|
+
# overlap check above misses it) would make imports/updates land on
|
|
76
|
+
# whichever row the DB returns while both roots export into it. Reject
|
|
77
|
+
# globally; re-linking @origin stays idempotent.
|
|
78
|
+
project_taken = CollavreLinear::ProjectLink
|
|
79
|
+
.where(linear_project_id: linear_project_id)
|
|
80
|
+
.where.not(creative_id: @origin.id)
|
|
81
|
+
.exists?
|
|
82
|
+
|
|
83
|
+
if overlapping || origin_conflict || project_taken
|
|
84
|
+
overlapping_link = true
|
|
85
|
+
else
|
|
86
|
+
link = @origin.linear_project_links.find_or_initialize_by(
|
|
87
|
+
account: account,
|
|
88
|
+
linear_project_id: linear_project_id
|
|
89
|
+
)
|
|
90
|
+
link.team_id = team_id
|
|
91
|
+
link.save!
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rescue ActiveRecord::RecordNotUnique
|
|
95
|
+
# Race-safe backstop for the project_taken check above: the check is
|
|
96
|
+
# check-then-save, so two DISJOINT-tree requests (which do not share the
|
|
97
|
+
# root lock) can both pass it; the linear_project_id unique index rejects
|
|
98
|
+
# the loser's insert. Surface the same conflict instead of a 500.
|
|
99
|
+
overlapping_link = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if overlapping_link
|
|
103
|
+
render json: { error: I18n.t("collavre_linear.errors.overlapping_link") },
|
|
104
|
+
status: :unprocessable_entity
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Existing descendants never fire after-commit callbacks during linking,
|
|
109
|
+
# so enqueue an outbound export for the whole subtree — otherwise a
|
|
110
|
+
# populated tree would only create the root Linear issue. Idempotent:
|
|
111
|
+
# the exporter skips unchanged content via its content hash.
|
|
112
|
+
enqueue_subtree_sync
|
|
113
|
+
|
|
114
|
+
# Inbound sync needs a webhook Linear can't auto-provision (app actors
|
|
115
|
+
# lack the admin scope), so the admin sets it up by hand and pastes the
|
|
116
|
+
# secret Linear generates via update_secret. The modal shows that guide.
|
|
117
|
+
render json: { success: true, project_link: serialize_link(link) }
|
|
118
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
119
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# DELETE /linear/creatives/:creative_id/integration
|
|
123
|
+
#
|
|
124
|
+
# Unlinks the Creative from its Linear project. The webhook is set up by
|
|
125
|
+
# hand in Linear (we never learn its id), so there is nothing to
|
|
126
|
+
# deregister here — the admin removes it in Linear's settings if desired.
|
|
127
|
+
def destroy
|
|
128
|
+
account = Current.user.linear_account
|
|
129
|
+
unless account
|
|
130
|
+
render json: { error: I18n.t("collavre_linear.errors.not_connected") },
|
|
131
|
+
status: :unprocessable_entity
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
link = @origin.linear_project_links.find_by(account: account)
|
|
136
|
+
unless link
|
|
137
|
+
render json: { error: I18n.t("collavre_linear.errors.not_found") },
|
|
138
|
+
status: :not_found
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
link.destroy!
|
|
143
|
+
|
|
144
|
+
render json: { success: true }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# POST /linear/creatives/:creative_id/integration/resync
|
|
148
|
+
#
|
|
149
|
+
# Re-enqueues a full outbound export for the whole subtree. Mirrors the
|
|
150
|
+
# create path: enqueuing only the root would never recover stale/missing
|
|
151
|
+
# descendant issues. The ParentNotExportedError + retry_on deferral in
|
|
152
|
+
# OutboundSyncJob protects against parent-before-child races, so enqueuing
|
|
153
|
+
# the full subtree is safe.
|
|
154
|
+
def resync
|
|
155
|
+
account = Current.user.linear_account
|
|
156
|
+
unless account
|
|
157
|
+
render json: { error: I18n.t("collavre_linear.errors.not_connected") },
|
|
158
|
+
status: :unprocessable_entity
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless @origin.linear_project_links.where(account: account).exists?
|
|
163
|
+
render json: { error: I18n.t("collavre_linear.errors.not_found") },
|
|
164
|
+
status: :not_found
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Resync is the human resolution path the conflict notice points to, so it
|
|
169
|
+
# must break the :conflict freeze — CreativeExporter#update_issue! (and the
|
|
170
|
+
# inbound applier) HALT on conflicted links, so without this the enqueued
|
|
171
|
+
# jobs would report success while making no API call and leaving the
|
|
172
|
+
# conflict stuck. Reopen conflicted links as :dirty so the export re-runs
|
|
173
|
+
# and pushes the local (kept) content Linear never received.
|
|
174
|
+
clear_conflicted_links
|
|
175
|
+
|
|
176
|
+
enqueue_subtree_sync
|
|
177
|
+
|
|
178
|
+
render json: { success: true, message: I18n.t("collavre_linear.integration.resync_started") }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# POST /linear/creatives/:creative_id/integration/secret
|
|
182
|
+
#
|
|
183
|
+
# Store the signing secret the admin copied from Linear's webhook settings.
|
|
184
|
+
# Linear owns the secret (it generates one per webhook and won't let us pick
|
|
185
|
+
# it), so the admin pastes it here and we propagate it to the team's sibling
|
|
186
|
+
# links — the one value all of the team's deliveries verify against.
|
|
187
|
+
def update_secret
|
|
188
|
+
account = Current.user.linear_account
|
|
189
|
+
unless account
|
|
190
|
+
render json: { error: I18n.t("collavre_linear.errors.not_connected") },
|
|
191
|
+
status: :unprocessable_entity
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
link = @origin.linear_project_links.find_by(account: account)
|
|
196
|
+
unless link
|
|
197
|
+
render json: { error: I18n.t("collavre_linear.errors.not_found") },
|
|
198
|
+
status: :not_found
|
|
199
|
+
return
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
secret = params[:webhook_secret].to_s.strip
|
|
203
|
+
if secret.blank?
|
|
204
|
+
render json: { error: I18n.t("collavre_linear.errors.missing_secret") },
|
|
205
|
+
status: :unprocessable_entity
|
|
206
|
+
return
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
link.update_webhook_secret!(secret)
|
|
210
|
+
|
|
211
|
+
render json: { success: true }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# GET /linear/creatives/:creative_id/integration/options
|
|
215
|
+
#
|
|
216
|
+
# Teams and projects the connected account can see, so the link modal can
|
|
217
|
+
# offer dropdowns instead of asking the user to type raw Linear IDs.
|
|
218
|
+
def options
|
|
219
|
+
account = Current.user.linear_account
|
|
220
|
+
unless account
|
|
221
|
+
render json: { error: I18n.t("collavre_linear.errors.not_connected") },
|
|
222
|
+
status: :unprocessable_entity
|
|
223
|
+
return
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
client = CollavreLinear::Client.new(account)
|
|
227
|
+
render json: { teams: client.list_teams, projects: client.list_projects }
|
|
228
|
+
rescue CollavreLinear::Client::Error => e
|
|
229
|
+
# Surface the Linear-side failure (expired token, revoked scope) instead
|
|
230
|
+
# of leaving the dropdowns silently empty.
|
|
231
|
+
render json: { error: e.message }, status: :bad_gateway
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# Enqueue an outbound export for every Creative in the @origin subtree.
|
|
237
|
+
# Shared by create (initial link) and resync so both cover descendants.
|
|
238
|
+
def enqueue_subtree_sync
|
|
239
|
+
@origin.self_and_descendants.ids.each do |creative_id|
|
|
240
|
+
CollavreLinear::OutboundSyncJob.perform_later(creative_id)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Reopen conflicted issue links in the subtree so an explicit resync can
|
|
245
|
+
# actually push. update_all bypasses the exporter's per-link lock, but a
|
|
246
|
+
# concurrent inbound re-marking one conflict just means that link no-ops
|
|
247
|
+
# again (the user resyncs once more) — no data loss, and it unsticks the
|
|
248
|
+
# common case the resync button promises to fix.
|
|
249
|
+
def clear_conflicted_links
|
|
250
|
+
CollavreLinear::IssueLink
|
|
251
|
+
.where(creative_id: @origin.self_and_descendants.ids)
|
|
252
|
+
.where(sync_state: CollavreLinear::IssueLink.sync_states[:conflict])
|
|
253
|
+
.update_all(sync_state: CollavreLinear::IssueLink.sync_states[:dirty])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def integration_forbidden_message
|
|
257
|
+
I18n.t("collavre_linear.errors.forbidden")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def serialize_link(link)
|
|
261
|
+
{
|
|
262
|
+
id: link.id,
|
|
263
|
+
team_id: link.team_id,
|
|
264
|
+
linear_project_id: link.linear_project_id,
|
|
265
|
+
sync_state: link.sync_state,
|
|
266
|
+
webhook_id: link.webhook_id
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreLinear
|
|
4
|
+
# Receives inbound webhooks from Linear.
|
|
5
|
+
#
|
|
6
|
+
# Security pipeline (all BEFORE enqueueing any work):
|
|
7
|
+
# 1. Verify `Linear-Signature` = HMAC-SHA256(webhook_secret, raw_body) with
|
|
8
|
+
# a constant-time compare. Bad/missing signature -> 401.
|
|
9
|
+
# 2. Verify `webhookTimestamp` (ms epoch) is within +/- 60s of now to reject
|
|
10
|
+
# replayed/stale deliveries. Out-of-window -> 401.
|
|
11
|
+
# 3. Drop our own events (EchoGuard) to avoid sync loops. Echo -> 200 ack,
|
|
12
|
+
# but nothing enqueued.
|
|
13
|
+
#
|
|
14
|
+
# Machine-to-machine endpoint authenticated by the HMAC signature below, not a
|
|
15
|
+
# user session. Inherits ActionController::API, which carries no CSRF
|
|
16
|
+
# middleware — so there is nothing to skip, and no session cookie to forge.
|
|
17
|
+
class WebhooksController < ActionController::API
|
|
18
|
+
TIMESTAMP_WINDOW_MS = 60_000
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
raw_body = request.raw_post.presence || request.body.read
|
|
22
|
+
payload = JSON.parse(raw_body)
|
|
23
|
+
|
|
24
|
+
link = find_project_link(payload)
|
|
25
|
+
# Resolve the secret at the TEAM level, not off the matched row's own
|
|
26
|
+
# column. find_project_link can return a specific row (project/issue/comment
|
|
27
|
+
# branches) that committed blank in the paste race even though a sibling
|
|
28
|
+
# holds the team's secret — verifying against the team's non-blank secret
|
|
29
|
+
# keeps those deliveries from a spurious 401.
|
|
30
|
+
secret = link && CollavreLinear::ProjectLink.team_webhook_secret(link.team_id)
|
|
31
|
+
|
|
32
|
+
return head :unauthorized unless valid_signature?(raw_body, secret)
|
|
33
|
+
return head :unauthorized unless fresh_timestamp?(payload)
|
|
34
|
+
# Signature only proves the sender holds the signing link's per-TEAM secret.
|
|
35
|
+
# InboundApplier routes by the payload's own id/projectId, so a payload
|
|
36
|
+
# signed with team A's secret but naming team B's project/issue would let a
|
|
37
|
+
# user who can see A's (UI-visible) secret forge writes into B. Require every
|
|
38
|
+
# other identifier to resolve to the SAME team before enqueueing.
|
|
39
|
+
return head :unauthorized unless payload_identifiers_consistent?(payload, link)
|
|
40
|
+
|
|
41
|
+
account = resolve_account(link)
|
|
42
|
+
if account && CollavreLinear::EchoGuard.our_event?(account, payload)
|
|
43
|
+
# Our own actor bounced back — ack so Linear stops retrying, but do not
|
|
44
|
+
# re-apply our own change.
|
|
45
|
+
return head :ok
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Enqueue on the sequential linear_inbound queue (single-thread worker) so
|
|
49
|
+
# verified events apply strictly in receipt order — a comment that arrives
|
|
50
|
+
# after its issue's create must also be APPLIED after it.
|
|
51
|
+
CollavreLinear::InboundApplyJob.perform_later(payload)
|
|
52
|
+
head :ok
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
head :bad_request
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Match the payload's team/project to a ProjectLink so we can use its
|
|
60
|
+
# per-link webhook secret and account. Returns nil when nothing matches —
|
|
61
|
+
# the secret then resolves to nil and the delivery is rejected (401). There
|
|
62
|
+
# is no ENV/admin fallback: the secret lives only on the ProjectLink.
|
|
63
|
+
def find_project_link(payload)
|
|
64
|
+
team_id = extract_team_id(payload)
|
|
65
|
+
project_id = extract_project_id(payload)
|
|
66
|
+
|
|
67
|
+
if team_id.present?
|
|
68
|
+
# A team shares one webhook secret, but a sibling created concurrently
|
|
69
|
+
# with the admin's paste can commit blank and would 401 the delivery if
|
|
70
|
+
# the verifier picked it. Prefer any sibling that already holds the pasted
|
|
71
|
+
# secret; fall back to any team row so routing still resolves before a
|
|
72
|
+
# secret is ever pasted (that case 401s on the nil secret, as intended).
|
|
73
|
+
siblings = CollavreLinear::ProjectLink.where(team_id: team_id).to_a
|
|
74
|
+
link = siblings.find { |s| s.webhook_secret.present? } || siblings.first
|
|
75
|
+
return link if link
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if project_id.present?
|
|
79
|
+
link = CollavreLinear::ProjectLink.find_by(linear_project_id: project_id)
|
|
80
|
+
return link if link
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Comment deliveries carry no team/project fields — they reference the
|
|
84
|
+
# parent issue instead. Resolve via the linked issue so verification uses
|
|
85
|
+
# that project's per-link secret; without this the delivery matches no
|
|
86
|
+
# link and is rejected (401).
|
|
87
|
+
if (issue_id = extract_issue_id(payload)).present?
|
|
88
|
+
issue_link = CollavreLinear::IssueLink.find_by(linear_issue_id: issue_id)
|
|
89
|
+
return issue_link.project_link if issue_link
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The webhook secret is shared per TEAM (siblings adopt it, rotation rolls the
|
|
96
|
+
# whole team), so the team is the trust boundary. Every identifier the payload
|
|
97
|
+
# carries that resolves to a ProjectLink must belong to the signing link's
|
|
98
|
+
# team; a mismatch means a cross-team forgery signed with the wrong secret.
|
|
99
|
+
def payload_identifiers_consistent?(payload, link)
|
|
100
|
+
return false unless link
|
|
101
|
+
|
|
102
|
+
project_id = extract_project_id(payload)
|
|
103
|
+
if project_id.present?
|
|
104
|
+
other = CollavreLinear::ProjectLink.find_by(linear_project_id: project_id)
|
|
105
|
+
return false if other && other.team_id != link.team_id
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
issue_ids_in_payload(payload).each do |issue_id|
|
|
109
|
+
issue_link = CollavreLinear::IssueLink.find_by(linear_issue_id: issue_id)
|
|
110
|
+
return false if issue_link && issue_link.project_link.team_id != link.team_id
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
comment_ids_in_payload(payload).each do |comment_id|
|
|
114
|
+
comment_link = CollavreLinear::CommentLink.find_by(linear_comment_id: comment_id)
|
|
115
|
+
return false if comment_link &&
|
|
116
|
+
comment_link.issue_link.project_link.team_id != link.team_id
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Every issue identifier InboundApplier may route by: the comment's parent
|
|
123
|
+
# issue (data.issue.id / data.issueId), the Issue event's own id (data.id —
|
|
124
|
+
# what update_issue!/remove_issue!/create_issue! key on), and a create/
|
|
125
|
+
# reparent target (data.parentId). The comment-shaped extract_issue_id alone
|
|
126
|
+
# missed data.id, letting an Issue payload signed with team A's secret but
|
|
127
|
+
# naming team B's issue slip past the cross-team guard.
|
|
128
|
+
def issue_ids_in_payload(payload)
|
|
129
|
+
ids = [ extract_issue_id(payload), payload.dig("data", "parentId") ]
|
|
130
|
+
ids << payload.dig("data", "id") if payload["type"] == "Issue"
|
|
131
|
+
ids.compact_blank
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Comment events route update/remove by the Comment's own data.id (a
|
|
135
|
+
# linear_comment_id), which none of the issue-shaped extractors surface.
|
|
136
|
+
# Without checking it, a payload signed with team A's secret but naming team
|
|
137
|
+
# B's CommentLink slips past the guard and apply_comment! edits/deletes B's
|
|
138
|
+
# mirror. Only a Comment payload's data.id is a comment id (Issue payloads key
|
|
139
|
+
# it as an issue, already covered above).
|
|
140
|
+
def comment_ids_in_payload(payload)
|
|
141
|
+
return [] unless payload["type"] == "Comment"
|
|
142
|
+
|
|
143
|
+
[ payload.dig("data", "id") ].compact_blank
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_issue_id(payload)
|
|
147
|
+
payload.dig("data", "issue", "id") || payload.dig("data", "issueId")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_team_id(payload)
|
|
151
|
+
payload.dig("data", "teamId") ||
|
|
152
|
+
payload.dig("data", "team", "id") ||
|
|
153
|
+
payload["teamId"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def extract_project_id(payload)
|
|
157
|
+
payload.dig("data", "projectId") ||
|
|
158
|
+
payload.dig("data", "project", "id")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolve_account(link)
|
|
162
|
+
link&.account || CollavreLinear::Account.first
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def valid_signature?(raw_body, secret)
|
|
166
|
+
return false if secret.blank?
|
|
167
|
+
|
|
168
|
+
signature = request.headers["Linear-Signature"] ||
|
|
169
|
+
request.get_header("HTTP_LINEAR_SIGNATURE")
|
|
170
|
+
return false if signature.blank?
|
|
171
|
+
|
|
172
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
|
|
173
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def fresh_timestamp?(payload)
|
|
177
|
+
ts = payload["webhookTimestamp"]
|
|
178
|
+
return false if ts.blank?
|
|
179
|
+
|
|
180
|
+
ts_ms = Integer(ts)
|
|
181
|
+
now_ms = (Time.now.to_f * 1000).to_i
|
|
182
|
+
(now_ms - ts_ms).abs <= TIMESTAMP_WINDOW_MS
|
|
183
|
+
rescue ArgumentError, TypeError
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|