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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/app/controllers/collavre_linear/application_controller.rb +21 -0
  3. data/app/controllers/collavre_linear/auth_controller.rb +135 -0
  4. data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
  5. data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
  6. data/app/javascript/collavre_linear.js +199 -0
  7. data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
  8. data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
  9. data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
  10. data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
  11. data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
  12. data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
  13. data/app/models/collavre_linear/account.rb +29 -0
  14. data/app/models/collavre_linear/application_record.rb +7 -0
  15. data/app/models/collavre_linear/comment_link.rb +12 -0
  16. data/app/models/collavre_linear/issue_link.rb +20 -0
  17. data/app/models/collavre_linear/project_link.rb +76 -0
  18. data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
  19. data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
  20. data/app/services/collavre_linear/client.rb +425 -0
  21. data/app/services/collavre_linear/comment_formatter.rb +34 -0
  22. data/app/services/collavre_linear/comment_syncability.rb +33 -0
  23. data/app/services/collavre_linear/creative_exporter.rb +352 -0
  24. data/app/services/collavre_linear/echo_guard.rb +36 -0
  25. data/app/services/collavre_linear/field_mapper.rb +119 -0
  26. data/app/services/collavre_linear/inbound_applier.rb +599 -0
  27. data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
  28. data/app/views/collavre_linear/auth/setup.html.erb +63 -0
  29. data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
  30. data/config/initializers/integration_settings.rb +8 -0
  31. data/config/locales/en.yml +49 -0
  32. data/config/locales/ko.yml +49 -0
  33. data/config/routes.rb +22 -0
  34. data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
  35. data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
  36. data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
  37. data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
  38. data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
  39. data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
  40. data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
  41. data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
  42. data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
  43. data/lib/collavre_linear/engine.rb +122 -0
  44. data/lib/collavre_linear/version.rb +3 -0
  45. data/lib/collavre_linear.rb +5 -0
  46. metadata +115 -0
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module CollavreLinear
8
+ # Handles Linear OAuth 2.0 flows:
9
+ # - authorize_url : build the URL that sends the user to Linear's OAuth screen
10
+ # - exchange : trade an authorization code for tokens (authorization_code grant)
11
+ # - refresh : silently refresh tokens when they are expiring soon (refresh_token grant)
12
+ #
13
+ # Secret resolution order: DB Resolver > ENV > Rails.application.credentials
14
+ # (consistent with CollavreLinear::Client and other engine services in this codebase)
15
+ class OAuthTokenService
16
+ LINEAR_AUTH_ENDPOINT = "https://linear.app/oauth/authorize"
17
+ LINEAR_TOKEN_ENDPOINT = "https://api.linear.app/oauth/token"
18
+ # NOTE: `admin` is intentionally NOT requested. Linear rejects it for app
19
+ # actors ("App users can't request admin scopes"), which is how we authorize
20
+ # (actor: "app"). `admin` is only needed to auto-create webhooks via the API,
21
+ # so inbound sync is wired up MANUALLY instead — the integration modal shows a
22
+ # one-time setup guide (URL + events) after linking, and the admin pastes back
23
+ # the signing secret Linear generates for the webhook.
24
+ OAUTH_SCOPES = "read,write,issues:create,comments:create"
25
+
26
+ class Error < StandardError; end
27
+
28
+ class << self
29
+ # Names of any OAuth secrets that are unset/blank. When non-empty we must
30
+ # NOT start the flow: a blank redirect_uri produces an authorize URL like
31
+ # `...&redirect_uri&scope=...` that Linear accepts but can never redirect
32
+ # back from, so the callback never fires and the account is never created
33
+ # (Linear just shows the app as "already installed"). client_secret is
34
+ # checked too: it isn't in the authorize URL, but a blank one would let the
35
+ # user complete Linear authorization only to fail at token exchange in the
36
+ # callback — surface the misconfiguration before leaving the app instead.
37
+ def missing_config
38
+ { LINEAR_CLIENT_ID: client_id,
39
+ LINEAR_CLIENT_SECRET: client_secret,
40
+ LINEAR_OAUTH_REDIRECT_URI: redirect_uri }.select { |_, v| v.blank? }.keys
41
+ end
42
+
43
+ # Build the URL that redirects the user to Linear for authorization.
44
+ #
45
+ # @param state [String] CSRF token / opaque value passed through the OAuth flow
46
+ # @param creative_id [Integer] stored in session before redirect; not sent to Linear
47
+ # @return [String] full URL including query params
48
+ def authorize_url(state:, creative_id:)
49
+ params = {
50
+ response_type: "code",
51
+ client_id: client_id,
52
+ redirect_uri: redirect_uri,
53
+ scope: OAUTH_SCOPES,
54
+ actor: "app",
55
+ state: state
56
+ }
57
+ "#{LINEAR_AUTH_ENDPOINT}?#{URI.encode_www_form(params)}"
58
+ end
59
+
60
+ # Exchange an authorization code for access + refresh tokens.
61
+ #
62
+ # @param code [String] authorization code from Linear callback
63
+ # @return [Hash] with symbolized keys :access_token, :refresh_token, :expires_in
64
+ def exchange(code)
65
+ params = {
66
+ grant_type: "authorization_code",
67
+ code: code,
68
+ redirect_uri: redirect_uri,
69
+ client_id: client_id,
70
+ client_secret: client_secret
71
+ }
72
+ post_token!(params)
73
+ end
74
+
75
+ # Refresh tokens if the account is expiring soon.
76
+ # Persists new token values to the account and returns it.
77
+ # No-ops (returns account unchanged) if not expiring soon.
78
+ #
79
+ # @param account [CollavreLinear::Account]
80
+ # @return [CollavreLinear::Account]
81
+ def refresh(account)
82
+ return account unless account.token_expiring_soon?
83
+
84
+ params = {
85
+ grant_type: "refresh_token",
86
+ refresh_token: account.refresh_token,
87
+ client_id: client_id,
88
+ client_secret: client_secret
89
+ }
90
+
91
+ tokens = post_token!(params)
92
+
93
+ attrs = {
94
+ access_token: tokens[:access_token],
95
+ token_expires_at: Time.current + tokens[:expires_in].to_i.seconds
96
+ }
97
+ attrs[:refresh_token] = tokens[:refresh_token] if tokens[:refresh_token].present?
98
+ account.update!(attrs)
99
+ account
100
+ end
101
+
102
+ private
103
+
104
+ # Post form-encoded body to the Linear token endpoint.
105
+ # @return [Hash] symbolized response body
106
+ def post_token!(params)
107
+ uri = URI.parse(LINEAR_TOKEN_ENDPOINT)
108
+ http = Net::HTTP.new(uri.host, uri.port)
109
+ http.use_ssl = true
110
+ http.open_timeout = 10
111
+ http.read_timeout = 15
112
+
113
+ request = Net::HTTP::Post.new(uri.path)
114
+ request["Content-Type"] = "application/x-www-form-urlencoded"
115
+ request["Accept"] = "application/json"
116
+ request.body = URI.encode_www_form(params)
117
+
118
+ response = http.request(request)
119
+
120
+ parsed = begin
121
+ JSON.parse(response.body)
122
+ rescue JSON::ParserError
123
+ raise Error, "Linear returned non-JSON token response (HTTP #{response.code})"
124
+ end
125
+
126
+ unless response.is_a?(Net::HTTPSuccess)
127
+ error_detail = parsed["error"] || parsed["error_description"] || "(no error field)"
128
+ raise Error, "Linear token request failed (HTTP #{response.code}): #{error_detail}"
129
+ end
130
+
131
+ parsed.transform_keys(&:to_sym)
132
+ end
133
+
134
+ # Secret resolution: DB Resolver > ENV > Rails credentials
135
+ def client_id
136
+ resolve_secret(:linear_client_id) ||
137
+ Rails.application.credentials.dig(:linear, :client_id)
138
+ end
139
+
140
+ def client_secret
141
+ resolve_secret(:linear_client_secret) ||
142
+ Rails.application.credentials.dig(:linear, :client_secret)
143
+ end
144
+
145
+ def redirect_uri
146
+ resolve_secret(:linear_oauth_redirect_uri) ||
147
+ Rails.application.credentials.dig(:linear, :oauth_redirect_uri)
148
+ end
149
+
150
+ def resolve_secret(key)
151
+ return unless defined?(Collavre::IntegrationSettings::Resolver)
152
+
153
+ Collavre::IntegrationSettings::Resolver.get(key)
154
+ rescue Collavre::IntegrationSettings::Resolver::UnknownKeyError
155
+ ENV[key.to_s.upcase]
156
+ rescue ActiveRecord::StatementInvalid,
157
+ ActiveRecord::NoDatabaseError,
158
+ ActiveRecord::ConnectionNotEstablished
159
+ ENV[key.to_s.upcase]
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,63 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="csrf-token" content="<%= form_authenticity_token %>">
7
+ <title><%= t('collavre_linear.integration.label', default: 'Linear') %> <%= t('collavre_linear.integration.setup', default: 'Setup') %></title>
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
12
+ background: #f5f5f5;
13
+ color: #333;
14
+ padding: 2em;
15
+ line-height: 1.5;
16
+ }
17
+ .wizard-container {
18
+ max-width: 520px;
19
+ margin: 0 auto;
20
+ background: #fff;
21
+ border-radius: 12px;
22
+ box-shadow: 0 2px 12px rgba(0,0,0,0.1);
23
+ padding: 2em;
24
+ }
25
+ h2 { margin-bottom: 0.5em; font-size: 1.3em; }
26
+ .success-msg { color: #16a34a; font-weight: 600; margin: 0.5em 0; text-align: center; }
27
+ .btn {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ padding: 0.6em 1.2em;
32
+ border-radius: 6px;
33
+ border: 1px solid #ccc;
34
+ background: #fff;
35
+ cursor: pointer;
36
+ font-size: 0.95em;
37
+ }
38
+ .btn-primary { background: #5b40cc; color: #fff; border-color: #5b40cc; }
39
+ .close-btn { margin-top: 1em; width: 100%; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div class="wizard-container" id="linear-wizard"
44
+ data-creative-id="<%= @creative_id %>">
45
+ <h2><%= t('collavre_linear.integration.label', default: 'Linear') %></h2>
46
+ <p class="success-msg"><%= t('collavre_linear.auth.connected', default: 'Linear connected successfully.') %></p>
47
+ <button type="button" id="close-btn" class="btn btn-primary close-btn">
48
+ <%= t('app.close', default: 'Close') %>
49
+ </button>
50
+ </div>
51
+
52
+ <script>
53
+ document.getElementById('close-btn').addEventListener('click', function() {
54
+ try {
55
+ if (window.opener) {
56
+ window.opener.postMessage({ type: 'linearConnected' }, window.location.origin);
57
+ }
58
+ } catch (e) {}
59
+ window.close();
60
+ });
61
+ </script>
62
+ </body>
63
+ </html>
@@ -0,0 +1,200 @@
1
+ <%#
2
+ Linear integration modal partial.
3
+
4
+ Locals (optional – callers may pass creative):
5
+ creative: the Collavre::Creative being configured
6
+ connected: boolean – whether Current.user has a CollavreLinear::Account
7
+
8
+ Rendered by callers with:
9
+ render "collavre_linear/integrations/modal", creative: @creative
10
+ (an example with literal ERB tags is omitted here: a nested "%"+">" would
11
+ terminate this comment early and leak the trailing marker into the page).
12
+ %>
13
+
14
+ <% connected = local_assigns.fetch(:connected, Current.user&.linear_account.present?) %>
15
+ <% creative = local_assigns[:creative] %>
16
+ <% creative_id = creative&.id %>
17
+ <% project_link = connected && creative &&
18
+ creative.effective_origin&.linear_project_links
19
+ &.find_by(account: Current.user&.linear_account) %>
20
+
21
+ <div id="linear-integration-modal"
22
+ data-creative-id="<%= creative_id %>"
23
+ data-error-generic="<%= t('collavre_linear.integration.request_failed') %>"
24
+ style="display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:10000;
25
+ align-items:center;justify-content:center;background:rgba(0,0,0,0.4);">
26
+ <div class="popup-box" style="min-width:340px;max-width:90vw;">
27
+ <button type="button" id="close-linear-modal" class="popup-close-btn"
28
+ onclick="document.getElementById('linear-integration-modal').style.display='none'">&times;</button>
29
+
30
+ <h2><%= t("collavre_linear.integration.label") %></h2>
31
+ <p style="margin-bottom:1em;color:var(--text-muted);">
32
+ <%= t("collavre_linear.integration.description") %>
33
+ </p>
34
+
35
+ <% if connected %>
36
+ <% if project_link %>
37
+ <%# --- Already linked --- %>
38
+ <p style="color:var(--color-success,#16a34a);margin-bottom:0.75em;">
39
+ <%= t("collavre_linear.integration.linked",
40
+ project: project_link.linear_project_id,
41
+ team: project_link.team_id) %>
42
+ </p>
43
+
44
+ <%# --- Manual webhook setup guide ---
45
+ Linear GENERATES the signing secret when the webhook is created and
46
+ won't let us force it to a value we pick, and app actors can't request
47
+ the `admin` scope to auto-provision — so the admin creates the webhook
48
+ by hand, then pastes Linear's generated secret back into the form below.
49
+ Shown until that secret is stored (blank => inbound not yet wired). %>
50
+ <% secret_set = project_link.webhook_secret.present? %>
51
+ <%# Use defined semantic tokens (not phantom --color-*-secondary names,
52
+ whose light hex fallbacks forced a near-white box + invisible text
53
+ in dark mode). Explicit --text-primary guards inherited contrast. %>
54
+ <details class="linear-webhook-guide" <%= "open" unless secret_set %>
55
+ style="margin-bottom:1em;padding:0.75em;border:1px solid var(--border-color);border-radius:6px;background:var(--surface-secondary);color:var(--text-primary);">
56
+ <summary style="font-weight:600;cursor:pointer;"><%= t("collavre_linear.integration.webhook_guide_title") %></summary>
57
+ <p style="margin:0.5em 0;color:var(--text-muted);font-size:0.9em;">
58
+ <%= t("collavre_linear.integration.webhook_guide_intro") %>
59
+ </p>
60
+ <ol style="margin:0 0 0 1.2em;font-size:0.9em;line-height:1.6;">
61
+ <li>
62
+ <%= t("collavre_linear.integration.webhook_guide_open_settings_html",
63
+ link: link_to("linear.app/settings/api", "https://linear.app/settings/api",
64
+ target: "_blank", rel: "noopener")) %>
65
+ </li>
66
+ <li>
67
+ <%= t("collavre_linear.integration.webhook_guide_url_label") %>
68
+ <br>
69
+ <code style="user-select:all;word-break:break-all;background:var(--color-code-bg);color:var(--color-code-text);padding:0.1em 0.3em;border-radius:3px;"><%= "#{request.base_url}/linear/webhook" %></code>
70
+ </li>
71
+ <li>
72
+ <%= t("collavre_linear.integration.webhook_guide_events_label") %>
73
+ <strong>Issue, Project, Comment</strong>
74
+ </li>
75
+ <li>
76
+ <%= t("collavre_linear.integration.webhook_guide_secret_label") %>
77
+ <%# Paste Linear's generated secret; the controller stores it and
78
+ propagates it to every sibling link of the team. We never render
79
+ the stored secret back — it's Linear's to show, not ours. %>
80
+ <%= form_with url: main_app.respond_to?(:linear_creative_integration_secret_path) ?
81
+ main_app.linear_creative_integration_secret_path(creative_id: creative_id) :
82
+ "/linear/creatives/#{creative_id}/integration/secret",
83
+ method: :post, local: false,
84
+ html: { style: "margin-top:0.4em;display:flex;gap:0.4em;align-items:center;flex-wrap:wrap;" } do |f| %>
85
+ <%= f.password_field :webhook_secret, autocomplete: "off",
86
+ placeholder: t("collavre_linear.integration.webhook_secret_placeholder"),
87
+ "aria-label": t("collavre_linear.integration.webhook_secret_input_label"),
88
+ style: "flex:1;min-width:180px;padding:0.35em 0.5em;border:1px solid var(--color-border);border-radius:4px;" %>
89
+ <%= f.submit t("collavre_linear.integration.webhook_secret_save_button"),
90
+ class: "btn btn-primary btn-sm" %>
91
+ <% end %>
92
+ <p style="margin:0.4em 0 0;font-size:0.85em;color:<%= secret_set ? "var(--color-success,#16a34a)" : "var(--text-muted)" %>;">
93
+ <%= secret_set ? t("collavre_linear.integration.webhook_secret_saved") :
94
+ t("collavre_linear.integration.webhook_secret_unset") %>
95
+ </p>
96
+ </li>
97
+ </ol>
98
+ </details>
99
+
100
+ <%# Resync form %>
101
+ <%= form_with url: main_app.respond_to?(:linear_creative_integration_resync_path) ?
102
+ main_app.linear_creative_integration_resync_path(creative_id: creative_id) :
103
+ "/linear/creatives/#{creative_id}/integration/resync",
104
+ method: :post, local: false,
105
+ html: { style: "display:inline;margin-right:0.5em;" } do |f| %>
106
+ <%= f.submit t("collavre_linear.integration.resync_button"),
107
+ class: "btn btn-secondary btn-sm" %>
108
+ <% end %>
109
+
110
+ <%# Unlink form %>
111
+ <%= form_with url: main_app.respond_to?(:linear_creative_integration_path) ?
112
+ main_app.linear_creative_integration_path(creative_id: creative_id) :
113
+ "/linear/creatives/#{creative_id}/integration",
114
+ method: :delete, local: false,
115
+ data: { confirm: t("collavre_linear.integration.unlink_confirm") } do |f| %>
116
+ <%= f.submit t("collavre_linear.integration.unlink_button"),
117
+ class: "btn btn-danger btn-sm" %>
118
+ <% end %>
119
+
120
+ <% else %>
121
+ <%# --- Connected but not yet linked to a project ---
122
+ Dropdowns are populated by JS from data-options-url so users pick a
123
+ project/team by name instead of typing raw Linear IDs. Selecting a
124
+ project scopes the team dropdown to that project's owning team(s). %>
125
+ <p style="margin-bottom:0.75em;">
126
+ <%= t("collavre_linear.integration.link_prompt") %>
127
+ </p>
128
+
129
+ <%= form_with url: main_app.respond_to?(:linear_creative_integration_path) ?
130
+ main_app.linear_creative_integration_path(creative_id: creative_id) :
131
+ "/linear/creatives/#{creative_id}/integration",
132
+ method: :post, local: false,
133
+ html: { id: "linear-link-form",
134
+ data: { options_url: "/linear/creatives/#{creative_id}/integration/options",
135
+ options_failed: t("collavre_linear.integration.options_failed"),
136
+ no_projects: t("collavre_linear.integration.no_projects") } } do |f| %>
137
+ <p class="linear-options-status"
138
+ style="margin-bottom:0.75em;color:var(--text-muted);font-size:0.9em;">
139
+ <%= t("collavre_linear.integration.options_loading") %>
140
+ </p>
141
+
142
+ <div style="margin-bottom:0.75em;">
143
+ <%= f.label :linear_project_id, t("collavre_linear.integration.project_id_label"),
144
+ style: "display:block;margin-bottom:0.25em;font-weight:600;" %>
145
+ <%= f.select :linear_project_id,
146
+ [],
147
+ { include_blank: t("collavre_linear.integration.project_select_placeholder") },
148
+ required: true, disabled: true,
149
+ style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
150
+ </div>
151
+
152
+ <div style="margin-bottom:1em;">
153
+ <%= f.label :team_id, t("collavre_linear.integration.team_id_label"),
154
+ style: "display:block;margin-bottom:0.25em;font-weight:600;" %>
155
+ <%= f.select :team_id,
156
+ [],
157
+ { include_blank: t("collavre_linear.integration.team_select_placeholder") },
158
+ required: true, disabled: true,
159
+ style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
160
+ </div>
161
+
162
+ <%= f.submit t("collavre_linear.integration.link_button"),
163
+ class: "btn btn-primary", disabled: true %>
164
+ <% end %>
165
+ <% end %>
166
+
167
+ <% else %>
168
+ <%# --- Not connected — show OAuth connect button --- %>
169
+ <p style="margin-bottom:0.75em;">
170
+ <%= t("collavre_linear.integration.connect_prompt") %>
171
+ </p>
172
+
173
+ <%#
174
+ Hidden form + separate button, mirroring collavre_github. The JS opener
175
+ calls the native form.submit() (not a submit-button click), which Turbo
176
+ does NOT intercept — so the popup does a real top-level navigation and
177
+ follows the 302 to Linear's authorize URL. A submit-button click lets
178
+ Turbo turn it into a fetch(), which auto-follows the cross-origin
179
+ redirect and is blocked by CORS.
180
+ %>
181
+ <form id="linear-connect-form"
182
+ action="/linear/auth/store_creative"
183
+ method="post"
184
+ target="linear-auth-window"
185
+ style="display:none;">
186
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
187
+ <input type="hidden" name="creative_id" value="<%= creative_id %>">
188
+ </form>
189
+ <%# Wrap in a block element: .popup-box is flex-column, so a bare button
190
+ child stretches to full width. The block wrapper lets the button keep
191
+ its natural (auto) width, matching the other modal buttons. %>
192
+ <div>
193
+ <button type="button" id="linear-connect-btn" class="btn btn-primary"
194
+ data-window-width="620" data-window-height="720">
195
+ <%= t("collavre_linear.integration.connect_button") %>
196
+ </button>
197
+ </div>
198
+ <% end %>
199
+ </div>
200
+ </div>
@@ -0,0 +1,8 @@
1
+ # Register collavre_linear integration setting keys with the central registry.
2
+ if defined?(Collavre::IntegrationSettings::Registry)
3
+ registry = Collavre::IntegrationSettings::Registry.instance
4
+ registry.register(:linear_api_endpoint, category: "linear", sensitive: false, requires_restart: false)
5
+ registry.register(:linear_client_id, category: "linear", sensitive: false, requires_restart: true)
6
+ registry.register(:linear_client_secret, category: "linear", sensitive: true, requires_restart: true)
7
+ registry.register(:linear_oauth_redirect_uri, category: "linear", sensitive: false, requires_restart: false)
8
+ end
@@ -0,0 +1,49 @@
1
+ en:
2
+ collavre_linear:
3
+ auth:
4
+ connected: "Linear connected successfully."
5
+ invalid_state: "Invalid OAuth state. Please try connecting again."
6
+ login_first: "Please log in first."
7
+ already_linked_other: "This Linear account is already connected to a different Collavre user."
8
+ oauth_config_missing: "Linear OAuth is not configured (%{keys}). Set these before connecting."
9
+ integration:
10
+ label: "Linear"
11
+ description: "Two-way sync of projects, issues and comments"
12
+ setup: "Setup"
13
+ connect_prompt: "Connect your Linear account to start syncing."
14
+ connect_button: "Connect Linear"
15
+ link_prompt: "Select a Linear project and team to link this creative."
16
+ team_id_label: "Team"
17
+ project_id_label: "Project"
18
+ project_select_placeholder: "Select a project…"
19
+ team_select_placeholder: "Select a team…"
20
+ options_loading: "Loading projects and teams…"
21
+ options_failed: "Couldn't load your Linear projects and teams. Please try again."
22
+ no_projects: "No projects found in your Linear workspace."
23
+ link_button: "Link Project"
24
+ linked: "Linked to project %{project} (team %{team})"
25
+ unlink_button: "Unlink"
26
+ unlink_confirm: "Remove Linear integration from this creative?"
27
+ resync_button: "Re-sync now"
28
+ resync_started: "Re-sync started. Issues will update shortly."
29
+ missing_creative: "creative_id is required"
30
+ webhook_guide_title: "Enable inbound sync (one-time webhook setup)"
31
+ webhook_guide_intro: "Outbound sync (Collavre → Linear) works now. To also receive changes from Linear, a workspace admin creates one webhook:"
32
+ webhook_guide_open_settings_html: "Open %{link} → Webhooks → New webhook."
33
+ webhook_guide_url_label: "Set the URL to:"
34
+ webhook_guide_events_label: "Subscribe to these events:"
35
+ webhook_guide_secret_label: "Create the webhook, then copy the signing secret Linear generated and paste it below:"
36
+ webhook_secret_input_label: "Linear signing secret"
37
+ webhook_secret_placeholder: "lin_wh_…"
38
+ webhook_secret_save_button: "Save secret"
39
+ webhook_secret_saved: "✓ Signing secret saved — inbound sync is active. Paste again to update it if you roll it in Linear."
40
+ webhook_secret_unset: "Inbound sync stays inactive until you paste the signing secret from Linear."
41
+ request_failed: "Linear request failed. Please try again."
42
+ conflict_notice: "⚠️ Linear sync conflict: this item was edited both locally and in Linear. The remote change was NOT applied to avoid overwriting local edits. Resolve manually and re-sync."
43
+ errors:
44
+ not_connected: "Linear account not connected."
45
+ missing_params: "team_id and linear_project_id are required."
46
+ not_found: "Linear project link not found."
47
+ forbidden: "Access denied."
48
+ overlapping_link: "A parent or child of this item is already linked to Linear. Unlink it first, or link a non-overlapping item."
49
+ missing_secret: "Paste the signing secret Linear generated for the webhook."
@@ -0,0 +1,49 @@
1
+ ko:
2
+ collavre_linear:
3
+ auth:
4
+ connected: "Linear 연결 성공."
5
+ invalid_state: "OAuth 상태가 유효하지 않습니다. 다시 연결해 주세요."
6
+ login_first: "먼저 로그인해 주세요."
7
+ already_linked_other: "이 Linear 계정은 이미 다른 Collavre 사용자에 연결되어 있습니다."
8
+ oauth_config_missing: "Linear OAuth가 설정되지 않았습니다 (%{keys}). 연결 전에 설정해 주세요."
9
+ integration:
10
+ label: "Linear"
11
+ description: "프로젝트, 이슈, 댓글 양방향 동기화"
12
+ setup: "설정"
13
+ connect_prompt: "Linear 계정을 연결하여 동기화를 시작하세요."
14
+ connect_button: "Linear 연결"
15
+ link_prompt: "이 크리에이티브에 연결할 Linear 프로젝트와 팀을 선택하세요."
16
+ team_id_label: "팀"
17
+ project_id_label: "프로젝트"
18
+ project_select_placeholder: "프로젝트 선택…"
19
+ team_select_placeholder: "팀 선택…"
20
+ options_loading: "프로젝트와 팀을 불러오는 중…"
21
+ options_failed: "Linear 프로젝트와 팀을 불러오지 못했습니다. 다시 시도해 주세요."
22
+ no_projects: "Linear 워크스페이스에 프로젝트가 없습니다."
23
+ link_button: "프로젝트 연결"
24
+ linked: "프로젝트 %{project} 연결됨 (팀 %{team})"
25
+ unlink_button: "연결 해제"
26
+ unlink_confirm: "이 크리에이티브에서 Linear 연동을 제거하시겠습니까?"
27
+ resync_button: "지금 재동기화"
28
+ resync_started: "재동기화 시작됨. 이슈가 곧 업데이트됩니다."
29
+ missing_creative: "creative_id가 필요합니다"
30
+ webhook_guide_title: "인바운드 동기화 활성화 (1회성 웹훅 설정)"
31
+ webhook_guide_intro: "아웃바운드 동기화(Collavre → Linear)는 이미 동작합니다. Linear의 변경 사항도 받으려면 워크스페이스 관리자가 웹훅 하나를 생성해야 합니다:"
32
+ webhook_guide_open_settings_html: "%{link} → Webhooks → New webhook 을 엽니다."
33
+ webhook_guide_url_label: "URL 을 다음으로 설정합니다:"
34
+ webhook_guide_events_label: "다음 이벤트를 구독합니다:"
35
+ webhook_guide_secret_label: "웹훅을 생성한 뒤, Linear가 생성한 서명 시크릿(signing secret)을 복사해 아래에 붙여넣습니다:"
36
+ webhook_secret_input_label: "Linear 서명 시크릿"
37
+ webhook_secret_placeholder: "lin_wh_…"
38
+ webhook_secret_save_button: "시크릿 저장"
39
+ webhook_secret_saved: "✓ 서명 시크릿이 저장되었습니다 — 인바운드 동기화가 활성화되었습니다. Linear에서 시크릿을 갱신하면 다시 붙여넣어 업데이트하세요."
40
+ webhook_secret_unset: "Linear의 서명 시크릿을 붙여넣기 전까지 인바운드 동기화는 비활성 상태입니다."
41
+ request_failed: "Linear 요청에 실패했습니다. 다시 시도해 주세요."
42
+ conflict_notice: "⚠️ Linear 동기화 충돌: 이 항목이 로컬과 Linear 양쪽에서 수정되었습니다. 로컬 변경을 덮어쓰지 않기 위해 원격 변경은 적용되지 않았습니다. 수동으로 해결한 후 다시 동기화해 주세요."
43
+ errors:
44
+ not_connected: "Linear 계정이 연결되지 않았습니다."
45
+ missing_params: "team_id와 linear_project_id가 필요합니다."
46
+ not_found: "Linear 프로젝트 링크를 찾을 수 없습니다."
47
+ forbidden: "접근이 거부되었습니다."
48
+ overlapping_link: "이 항목의 상위 또는 하위 항목이 이미 Linear에 연결되어 있습니다. 먼저 연결을 해제하거나 겹치지 않는 항목을 연결하세요."
49
+ missing_secret: "Linear가 웹훅에 대해 생성한 서명 시크릿을 붙여넣으세요."
data/config/routes.rb ADDED
@@ -0,0 +1,22 @@
1
+ CollavreLinear::Engine.routes.draw do
2
+ # OAuth callback from Linear
3
+ get "auth/callback", to: "auth#callback", as: :auth_callback
4
+ # Setup wizard after OAuth succeeds
5
+ get "auth/setup", to: "auth#setup", as: :setup_auth
6
+ # Store creative_id in session before redirecting to Linear
7
+ post "auth/store_creative", to: "auth#store_creative", as: :store_creative_auth
8
+
9
+ # Inbound webhook from Linear (HMAC-signed, machine-to-machine, no user session)
10
+ post "webhook", to: "webhooks#create", as: :webhook
11
+
12
+ # Creative integration endpoints (link / unlink / resync)
13
+ resources :creatives, only: [] do
14
+ resource :integration, module: :creatives, only: [ :create, :destroy ] do
15
+ post :resync, on: :member
16
+ # Store the signing secret the admin pasted from Linear's webhook settings.
17
+ post :secret, on: :member, action: :update_secret
18
+ # Teams/projects for the link picker (populated as dropdowns in the modal).
19
+ get :options, on: :member
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ class CreateLinearAccounts < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :linear_accounts do |t|
4
+ t.references :user, null: false, foreign_key: true, index: { unique: true }
5
+ t.string :linear_uid, null: false
6
+ t.string :app_actor_id
7
+ # OAuth tokens are stored via ActiveRecord encryption; the encrypted
8
+ # envelope of a real Linear access/refresh token exceeds the 255-byte
9
+ # `string` limit in PostgreSQL, so use `text` to avoid save failures.
10
+ t.text :access_token, null: false
11
+ t.text :refresh_token
12
+ t.datetime :token_expires_at
13
+ t.string :workspace_id
14
+ t.string :workspace_name
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :linear_accounts, :linear_uid, unique: true
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ class CreateCollavreLinearProjectLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :linear_project_links do |t|
4
+ t.references :creative, null: false, foreign_key: { to_table: :creatives }
5
+ t.references :account, null: false, foreign_key: { to_table: :linear_accounts }
6
+ t.string :linear_project_id, null: false
7
+ t.string :team_id, null: false
8
+ # Encrypted at rest (ActiveRecord encryption); the envelope can exceed the
9
+ # 255-byte `string` limit in PostgreSQL, so store it in a `text` column.
10
+ t.text :webhook_secret, null: false
11
+ t.integer :sync_state, null: false, default: 0
12
+ t.datetime :last_synced_at
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :linear_project_links, [ :creative_id, :linear_project_id ], unique: true, name: "index_linear_project_links_on_creative_and_project"
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ class CreateCollavreLinearIssueLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :linear_issue_links do |t|
4
+ # One IssueLink per Creative — enforce with a single unique index. Pass
5
+ # index: { unique: true } to t.references rather than a separate
6
+ # `add_index :creative_id, unique: true`: the latter collides with the
7
+ # non-unique index t.references creates by default (same generated name),
8
+ # which raises "index already exists" on a fresh migrate.
9
+ t.references :creative, null: false, foreign_key: { to_table: :creatives }, index: { unique: true }
10
+ t.references :project_link, null: false, foreign_key: { to_table: :linear_project_links }
11
+ t.string :linear_issue_id, null: false
12
+ t.string :parent_issue_id
13
+ t.integer :local_version, null: false, default: 0
14
+ t.datetime :remote_updated_at
15
+ t.string :content_hash
16
+ t.integer :sync_state, null: false, default: 0
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :linear_issue_links, :linear_issue_id, unique: true
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ class CreateCollavreLinearCommentLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :linear_comment_links do |t|
4
+ t.integer :comment_id, null: false
5
+ t.string :linear_comment_id, null: false
6
+ t.references :issue_link, null: false, foreign_key: { to_table: :linear_issue_links }
7
+ # Linear-side `updatedAt` of the comment version we last synced. Lets the
8
+ # inbound applier ignore our own (possibly stale) echoes by timestamp
9
+ # instead of comparing against the mutable local body.
10
+ t.datetime :remote_updated_at
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :linear_comment_links, :comment_id, unique: true
15
+ add_index :linear_comment_links, :linear_comment_id, unique: true
16
+ end
17
+ end
@@ -0,0 +1,6 @@
1
+ class AddLastOutboundAtToLinearLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :linear_project_links, :last_outbound_at, :datetime
4
+ add_column :linear_issue_links, :last_outbound_at, :datetime
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ class AddWebhookIdToLinearProjectLinks < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :linear_project_links, :webhook_id, :string
4
+ end
5
+ end