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
|
@@ -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'">×</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
|