collavre_linear 0.1.0 → 0.2.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 +4 -4
- data/app/controllers/collavre_linear/creatives/integrations_controller.rb +10 -1
- data/app/controllers/collavre_linear/webhooks_controller.rb +1 -1
- data/app/javascript/collavre_linear.js +29 -1
- data/app/jobs/collavre_linear/inbound_apply_job.rb +20 -0
- data/app/observers/collavre_linear/creative_sync_observer.rb +6 -2
- data/app/services/collavre_linear/client.rb +83 -14
- data/app/services/collavre_linear/creative_exporter.rb +125 -4
- data/app/services/collavre_linear/inbound_applier.rb +46 -0
- data/app/views/collavre_linear/integrations/_modal.html.erb +17 -0
- data/config/locales/en.yml +3 -0
- data/config/locales/ko.yml +3 -0
- data/db/migrate/20260704000000_add_done_state_id_to_linear_project_links.rb +9 -0
- data/lib/collavre_linear/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f91e7ebe8a6419a4b13d66c1d3cd919520bbef1c617981747f0008fafef63377
|
|
4
|
+
data.tar.gz: a960ab7bd872ba996974b5f02c4741e4d6285f5f699be6258fc1979ccf42dd38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b30d822da909ec236b3af65b015b846550c9128c1989169a1014cbfecf5d8a595c833732b26d4a9eaa7175da3042e05672a4ea0dca81ac92ad1524d9ec64c7f4
|
|
7
|
+
data.tar.gz: 4e6e4b2cf5ad7dbd39cffb15bbebc959f80ae8db047c019fd013f8625a2bd4f4fcb24ec57f1817f515432beaf740304697ea16ddf63e3d82c7d2236e577fe9e9
|
|
@@ -88,6 +88,10 @@ module CollavreLinear
|
|
|
88
88
|
linear_project_id: linear_project_id
|
|
89
89
|
)
|
|
90
90
|
link.team_id = team_id
|
|
91
|
+
# The Linear workflow-state UUID the admin picked as "done" (combobox,
|
|
92
|
+
# default Completed). Blank when the team has no completed state or the
|
|
93
|
+
# picker was cleared — completion mapping then no-ops both directions.
|
|
94
|
+
link.done_state_id = params[:done_state_id].to_s.presence
|
|
91
95
|
link.save!
|
|
92
96
|
end
|
|
93
97
|
end
|
|
@@ -224,7 +228,11 @@ module CollavreLinear
|
|
|
224
228
|
end
|
|
225
229
|
|
|
226
230
|
client = CollavreLinear::Client.new(account)
|
|
227
|
-
render json: {
|
|
231
|
+
render json: {
|
|
232
|
+
teams: client.list_teams,
|
|
233
|
+
projects: client.list_projects,
|
|
234
|
+
states: client.list_workflow_states
|
|
235
|
+
}
|
|
228
236
|
rescue CollavreLinear::Client::Error => e
|
|
229
237
|
# Surface the Linear-side failure (expired token, revoked scope) instead
|
|
230
238
|
# of leaving the dropdowns silently empty.
|
|
@@ -262,6 +270,7 @@ module CollavreLinear
|
|
|
262
270
|
id: link.id,
|
|
263
271
|
team_id: link.team_id,
|
|
264
272
|
linear_project_id: link.linear_project_id,
|
|
273
|
+
done_state_id: link.done_state_id,
|
|
265
274
|
sync_state: link.sync_state,
|
|
266
275
|
webhook_id: link.webhook_id
|
|
267
276
|
}
|
|
@@ -178,7 +178,7 @@ module CollavreLinear
|
|
|
178
178
|
return false if ts.blank?
|
|
179
179
|
|
|
180
180
|
ts_ms = Integer(ts)
|
|
181
|
-
now_ms = (Time.
|
|
181
|
+
now_ms = (Time.current.to_f * 1000).to_i
|
|
182
182
|
(now_ms - ts_ms).abs <= TIMESTAMP_WINDOW_MS
|
|
183
183
|
rescue ArgumentError, TypeError
|
|
184
184
|
false
|
|
@@ -73,6 +73,7 @@ if (!linearIntegrationInitialized) {
|
|
|
73
73
|
const status = form.querySelector('.linear-options-status');
|
|
74
74
|
const projectSelect = form.querySelector('select[name="linear_project_id"]');
|
|
75
75
|
const teamSelect = form.querySelector('select[name="team_id"]');
|
|
76
|
+
const stateSelect = form.querySelector('select[name="done_state_id"]');
|
|
76
77
|
const submitBtn = form.querySelector('input[type="submit"],button[type="submit"]');
|
|
77
78
|
if (!projectSelect || !teamSelect) return;
|
|
78
79
|
|
|
@@ -88,6 +89,7 @@ if (!linearIntegrationInitialized) {
|
|
|
88
89
|
.then(function (data) {
|
|
89
90
|
const teams = data.teams || [];
|
|
90
91
|
const projects = data.projects || [];
|
|
92
|
+
const states = data.states || [];
|
|
91
93
|
const teamName = {};
|
|
92
94
|
teams.forEach(function (t) { teamName[t.id] = t.name; });
|
|
93
95
|
|
|
@@ -119,15 +121,41 @@ if (!linearIntegrationInitialized) {
|
|
|
119
121
|
// Auto-select when the project has exactly one team.
|
|
120
122
|
teamSelect.value = ids.length === 1 ? ids[0] : '';
|
|
121
123
|
teamSelect.disabled = !projectSelect.value;
|
|
124
|
+
syncStates();
|
|
122
125
|
refreshSubmit();
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Rebuild the done-state dropdown for the selected team and default to
|
|
129
|
+
// that team's Completed state. Optional field — no completed state (or no
|
|
130
|
+
// team chosen) leaves it blank and completion mapping stays off.
|
|
131
|
+
function syncStates() {
|
|
132
|
+
if (!stateSelect) return;
|
|
133
|
+
const teamId = teamSelect.value;
|
|
134
|
+
while (stateSelect.options.length > 1) stateSelect.remove(1);
|
|
135
|
+
const teamStates = states
|
|
136
|
+
.filter(function (s) { return s.team_id === teamId; })
|
|
137
|
+
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
|
|
138
|
+
teamStates.forEach(function (s) {
|
|
139
|
+
const opt = document.createElement('option');
|
|
140
|
+
opt.value = s.id;
|
|
141
|
+
opt.textContent = s.name;
|
|
142
|
+
opt.dataset.type = s.type || '';
|
|
143
|
+
stateSelect.appendChild(opt);
|
|
144
|
+
});
|
|
145
|
+
const completed = teamStates.filter(function (s) { return s.type === 'completed'; });
|
|
146
|
+
const named = completed.find(function (s) {
|
|
147
|
+
return (s.name || '').toLowerCase() === 'completed';
|
|
148
|
+
});
|
|
149
|
+
stateSelect.value = named ? named.id : (completed[0] ? completed[0].id : '');
|
|
150
|
+
stateSelect.disabled = !teamId;
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
function refreshSubmit() {
|
|
126
154
|
if (submitBtn) submitBtn.disabled = !(projectSelect.value && teamSelect.value);
|
|
127
155
|
}
|
|
128
156
|
|
|
129
157
|
projectSelect.addEventListener('change', syncTeams);
|
|
130
|
-
teamSelect.addEventListener('change', refreshSubmit);
|
|
158
|
+
teamSelect.addEventListener('change', function () { syncStates(); refreshSubmit(); });
|
|
131
159
|
|
|
132
160
|
projectSelect.disabled = false;
|
|
133
161
|
if (status) status.style.display = 'none';
|
|
@@ -22,6 +22,26 @@ module CollavreLinear
|
|
|
22
22
|
# sequential FIFO queue that preserves enqueue (receipt) order.
|
|
23
23
|
queue_as :linear_inbound
|
|
24
24
|
|
|
25
|
+
# Auto-recover from transient DB contention. WebhooksController acks Linear
|
|
26
|
+
# with 200 the instant it enqueues this job (ack-before-apply), so Linear
|
|
27
|
+
# never re-delivers a payload once accepted. Without a retry, a transient
|
|
28
|
+
# ActiveRecord::Deadlocked / LockWaitTimeout — which InboundApplier CAN hit,
|
|
29
|
+
# since it takes `with_lock`/`lock!` on the same Creative the OUTBOUND worker
|
|
30
|
+
# locks (see inbound_applier.rb's lock-order note) — would park the event as
|
|
31
|
+
# a failed execution and the local mirror would silently diverge from Linear
|
|
32
|
+
# with no resend to fix it. Retrying is safe: a deadlock/lock-timeout rolls
|
|
33
|
+
# the whole transaction back (no partial apply), and InboundApplier is
|
|
34
|
+
# idempotent (create no-ops on an existing link, unique indexes guard
|
|
35
|
+
# duplicates, disposition checks refuse to clobber newer local edits), so a
|
|
36
|
+
# re-run cannot double-apply. Scope is deliberately narrow — only transient
|
|
37
|
+
# contention retries; a real bug still raises and parks (loud, operator-
|
|
38
|
+
# visible) rather than looping. The wait is kept SHORT (much shorter than the
|
|
39
|
+
# outbound jobs' polynomially_longer) so a rescheduled retry disturbs this
|
|
40
|
+
# FIFO queue's receipt order as little as possible; the applier's own
|
|
41
|
+
# out-of-order tolerance covers the small residual reordering.
|
|
42
|
+
retry_on ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
43
|
+
wait: 2.seconds, attempts: 5
|
|
44
|
+
|
|
25
45
|
def perform(payload)
|
|
26
46
|
unless CollavreLinear.const_defined?(:InboundApplier)
|
|
27
47
|
Rails.logger.info(
|
|
@@ -67,8 +67,12 @@ module CollavreLinear
|
|
|
67
67
|
|
|
68
68
|
# Columns whose change can alter the exported Linear issue (mirrors what
|
|
69
69
|
# CreativeExporter hashes: description -> title/description, sequence ->
|
|
70
|
-
# priority, data -> state/labels) plus parent_id (re-parenting -> parentId)
|
|
71
|
-
|
|
70
|
+
# priority, data -> state/labels) plus parent_id (re-parenting -> parentId)
|
|
71
|
+
# and progress (a leaf reaching 100% -> the project's "done" state, via
|
|
72
|
+
# CreativeExporter.apply_completion!). A non-leaf's rolled-up progress change
|
|
73
|
+
# enqueues a sync too, but apply_completion! no-ops for non-leaves so the
|
|
74
|
+
# content_hash is unchanged and the exporter makes no API call.
|
|
75
|
+
LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id progress].freeze
|
|
72
76
|
|
|
73
77
|
# Record — in after_save, where saved_changes is reliable — whether this
|
|
74
78
|
# save touched a Linear-relevant column. A create always counts (its seeded
|
|
@@ -191,11 +191,59 @@ module CollavreLinear
|
|
|
191
191
|
}
|
|
192
192
|
GQL
|
|
193
193
|
|
|
194
|
+
# List workflow states with their owning team id and type so the link modal
|
|
195
|
+
# can offer a "done state" combobox scoped to the chosen team. `type` is
|
|
196
|
+
# Linear's state category (triage/backlog/unstarted/started/completed/
|
|
197
|
+
# canceled) — the picker defaults to the team's "completed" state.
|
|
198
|
+
WORKFLOW_STATES = <<~GQL.freeze
|
|
199
|
+
query WorkflowStates {
|
|
200
|
+
workflowStates(first: 250) {
|
|
201
|
+
nodes {
|
|
202
|
+
id
|
|
203
|
+
name
|
|
204
|
+
type
|
|
205
|
+
position
|
|
206
|
+
team {
|
|
207
|
+
id
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
GQL
|
|
213
|
+
|
|
214
|
+
# Fetch a single issue's current workflow state (for seeding the pre-done
|
|
215
|
+
# snapshot when a leaf is completed with no locally-known Linear state).
|
|
216
|
+
ISSUE_QUERY = <<~GQL.freeze
|
|
217
|
+
query Issue($id: String!) {
|
|
218
|
+
issue(id: $id) {
|
|
219
|
+
id
|
|
220
|
+
state {
|
|
221
|
+
id
|
|
222
|
+
name
|
|
223
|
+
type
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
GQL
|
|
228
|
+
|
|
194
229
|
def initialize(account)
|
|
195
230
|
@account = account
|
|
196
231
|
@endpoint = resolve_endpoint
|
|
197
232
|
end
|
|
198
233
|
|
|
234
|
+
# Fetch a single Linear issue's current workflow state.
|
|
235
|
+
# @param id [String] Linear issue UUID
|
|
236
|
+
# @return [Hash, nil] {"id" =>, "name" =>, "type" =>} (string keys, matching
|
|
237
|
+
# the shape stored under data["linear"]["state"]), or nil when the issue or
|
|
238
|
+
# its state is absent.
|
|
239
|
+
def fetch_issue_state(id)
|
|
240
|
+
data = post!(ISSUE_QUERY, { id: id })
|
|
241
|
+
state = data.dig("issue", "state")
|
|
242
|
+
return nil if state.nil?
|
|
243
|
+
|
|
244
|
+
state.slice("id", "name", "type")
|
|
245
|
+
end
|
|
246
|
+
|
|
199
247
|
# Create a Linear issue.
|
|
200
248
|
# @return [Hash] with :id and :identifier
|
|
201
249
|
def create_issue(team_id:, title:, description: nil, parent_id: nil,
|
|
@@ -315,6 +363,24 @@ module CollavreLinear
|
|
|
315
363
|
end
|
|
316
364
|
end
|
|
317
365
|
|
|
366
|
+
# List workflow states for the "done state" picker, each with its owning
|
|
367
|
+
# team id and category type so the UI can scope states to the selected team
|
|
368
|
+
# and default to the completed one.
|
|
369
|
+
# @return [Array<Hash>] each with :id, :name, :type, :position, :team_id
|
|
370
|
+
def list_workflow_states
|
|
371
|
+
data = post!(WORKFLOW_STATES, {})
|
|
372
|
+
nodes = data.dig("workflowStates", "nodes") || []
|
|
373
|
+
nodes.map do |n|
|
|
374
|
+
{
|
|
375
|
+
id: n["id"],
|
|
376
|
+
name: n["name"],
|
|
377
|
+
type: n["type"],
|
|
378
|
+
position: n["position"],
|
|
379
|
+
team_id: n.dig("team", "id")
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
318
384
|
# Register a webhook with Linear.
|
|
319
385
|
# WebhookCreateInput fields: url, secret, teamId, resourceTypes
|
|
320
386
|
# @return [Hash] with :id
|
|
@@ -332,22 +398,19 @@ module CollavreLinear
|
|
|
332
398
|
# Execute a GraphQL operation and return the `data` hash.
|
|
333
399
|
# Raises Client::Error if the response contains a top-level `errors` key.
|
|
334
400
|
def post!(query, variables)
|
|
335
|
-
|
|
336
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
337
|
-
http.use_ssl = uri.scheme == "https"
|
|
338
|
-
http.open_timeout = 10
|
|
339
|
-
http.read_timeout = 30
|
|
340
|
-
|
|
341
|
-
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
|
342
|
-
request["Content-Type"] = "application/json"
|
|
343
|
-
request["Authorization"] = "Bearer #{fresh_access_token}"
|
|
344
|
-
request.body = { query: query, variables: variables }.to_json
|
|
401
|
+
token = fresh_access_token
|
|
345
402
|
|
|
346
403
|
response =
|
|
347
404
|
begin
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
http_client.post(
|
|
406
|
+
endpoint,
|
|
407
|
+
body: { query: query, variables: variables }.to_json,
|
|
408
|
+
headers: {
|
|
409
|
+
"Content-Type" => "application/json",
|
|
410
|
+
"Authorization" => "Bearer #{token}"
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
rescue Collavre::HttpClient::ConnectionError => e
|
|
351
414
|
# Transport-layer failures (connection refused/reset, DNS, TLS,
|
|
352
415
|
# open/read timeouts) raise before any GraphQL response exists, so they
|
|
353
416
|
# bypass the parsed-`errors` path below. Wrap them in Error so the
|
|
@@ -368,13 +431,19 @@ module CollavreLinear
|
|
|
368
431
|
raise Error, "Linear GraphQL error(s): #{messages}"
|
|
369
432
|
end
|
|
370
433
|
|
|
371
|
-
unless response.
|
|
434
|
+
unless response.success?
|
|
372
435
|
raise Error, "Linear HTTP error: #{response.code} #{response.message}"
|
|
373
436
|
end
|
|
374
437
|
|
|
375
438
|
parsed["data"]
|
|
376
439
|
end
|
|
377
440
|
|
|
441
|
+
# Shared Net::HTTP wrapper preserving Linear's original 10s connect / 30s
|
|
442
|
+
# read timeouts.
|
|
443
|
+
def http_client
|
|
444
|
+
@http_client ||= Collavre::HttpClient.new(open_timeout: 10, read_timeout: 30)
|
|
445
|
+
end
|
|
446
|
+
|
|
378
447
|
# Return a non-expired access token, refreshing via the refresh_token grant
|
|
379
448
|
# when the current token is expiring soon.
|
|
380
449
|
#
|
|
@@ -52,9 +52,70 @@ module CollavreLinear
|
|
|
52
52
|
data: creative.data
|
|
53
53
|
)
|
|
54
54
|
)
|
|
55
|
+
# Fold the completion→state override into the hash so inbound and outbound
|
|
56
|
+
# agree: after an inbound apply advances content_hash via this method, a
|
|
57
|
+
# later outbound sync of a completed leaf must see the SAME state_id it
|
|
58
|
+
# would push. Resolve the governing ProjectLink the same way sync! does.
|
|
59
|
+
apply_completion!(attrs, creative, resolve_project_link_for(creative))
|
|
55
60
|
hash_attrs(attrs, parent_linear_issue_id_for(creative))
|
|
56
61
|
end
|
|
57
62
|
|
|
63
|
+
# Walk self-and-ancestors (nearest first) for the governing ProjectLink.
|
|
64
|
+
# Class-level twin of the instance #resolve_project_link so content_hash_for
|
|
65
|
+
# can apply the same completion override.
|
|
66
|
+
def self.resolve_project_link_for(creative)
|
|
67
|
+
creative.self_and_ancestors.each do |ancestor|
|
|
68
|
+
link = CollavreLinear::ProjectLink.find_by(creative_id: ancestor.id)
|
|
69
|
+
return link if link
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Completion mapping (Collavre → Linear): a LEAF creative at 100% progress
|
|
75
|
+
# exports with its project's configured "done" workflow state. Mutates and
|
|
76
|
+
# returns `attrs`.
|
|
77
|
+
#
|
|
78
|
+
# Guards, each a deliberate no-op:
|
|
79
|
+
# * no project_link / no done_state_id — completion mapping not configured.
|
|
80
|
+
# * creative has active children — only leaves carry independent
|
|
81
|
+
# progress (a parent's progress is a rollup average), so only leaves drive
|
|
82
|
+
# the done state; a parent issue's state follows from its children.
|
|
83
|
+
# * progress < 1.0 — only 100% maps to done. We do NOT
|
|
84
|
+
# blindly clobber the state here; instead, when our record shows the issue
|
|
85
|
+
# still sitting in the done state, we RESTORE the state it held before it
|
|
86
|
+
# was completed (the "un-done" case — see below). Otherwise the last-known
|
|
87
|
+
# Linear state (from data["linear"]) echoes through unchanged.
|
|
88
|
+
#
|
|
89
|
+
# Un-done (Collavre 100% → below): the pre-done state is snapshotted into
|
|
90
|
+
# data["linear"]["state_before_done"] by #capture_state_before_done! at the
|
|
91
|
+
# moment the leaf is pushed to done. On a later drop below 100% we push that
|
|
92
|
+
# snapshot back — but only while data["linear"]["state"] still equals the done
|
|
93
|
+
# state (i.e. our push landed and nobody has since moved the issue). That
|
|
94
|
+
# done-state guard keeps us from fighting a human who moved the issue
|
|
95
|
+
# elsewhere in Linear, and bounds the loop: once the restore echoes back,
|
|
96
|
+
# data["linear"]["state"] is no longer done so this never re-fires.
|
|
97
|
+
#
|
|
98
|
+
# This method stays PURE (no I/O, no mutation) so CreativeExporter.content_hash_for
|
|
99
|
+
# and #sync! compute the SAME state_id — the capture that needs the network is
|
|
100
|
+
# done separately in the sync! path.
|
|
101
|
+
def self.apply_completion!(attrs, creative, project_link)
|
|
102
|
+
done_state_id = project_link&.done_state_id
|
|
103
|
+
return attrs if done_state_id.blank?
|
|
104
|
+
return attrs unless creative.children.active.empty?
|
|
105
|
+
|
|
106
|
+
if creative.progress.to_f >= 1.0
|
|
107
|
+
attrs[:state_id] = done_state_id
|
|
108
|
+
else
|
|
109
|
+
linear = (creative.data || {})["linear"] || {}
|
|
110
|
+
before = linear["state_before_done"]
|
|
111
|
+
current = linear["state"]
|
|
112
|
+
if before.present? && current.is_a?(Hash) && current["id"] == done_state_id
|
|
113
|
+
attrs[:state_id] = before["id"]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
attrs
|
|
117
|
+
end
|
|
118
|
+
|
|
58
119
|
# Resolve the parent Creative's linked linear_issue_id (or nil when the
|
|
59
120
|
# parent is absent or not itself linked to a Linear issue).
|
|
60
121
|
def self.parent_linear_issue_id_for(creative)
|
|
@@ -84,14 +145,23 @@ module CollavreLinear
|
|
|
84
145
|
# (Its children export as the project's top-level issues; see class docs.)
|
|
85
146
|
return if project_link.creative_id == @creative.id
|
|
86
147
|
|
|
87
|
-
account
|
|
88
|
-
client
|
|
148
|
+
account = project_link.account
|
|
149
|
+
client = Client.new(account)
|
|
150
|
+
issue_link = @creative.linear_issue_links.first
|
|
151
|
+
|
|
152
|
+
# Before a completed leaf's state is overridden to "done", remember the state
|
|
153
|
+
# it is leaving so a later drop below 100% can restore it (outbound un-done).
|
|
154
|
+
# This may query Linear once, so it lives here in the I/O path — NOT in the
|
|
155
|
+
# pure apply_completion!/content_hash_for.
|
|
156
|
+
capture_state_before_done!(client, project_link, issue_link)
|
|
157
|
+
|
|
89
158
|
attrs = FieldMapper.creative_to_issue_attrs(adapt(@creative))
|
|
159
|
+
# Completed leaf → push the project's "done" state; a leaf dropped below 100%
|
|
160
|
+
# → restore its pre-done state (mirrors content_hash_for).
|
|
161
|
+
self.class.apply_completion!(attrs, @creative, project_link)
|
|
90
162
|
parent_id = parent_linear_issue_id
|
|
91
163
|
hash = compute_content_hash(attrs, parent_id)
|
|
92
164
|
|
|
93
|
-
issue_link = @creative.linear_issue_links.first
|
|
94
|
-
|
|
95
165
|
# Cross-project move: the creative was reparented under a DIFFERENT linked
|
|
96
166
|
# root than the one its existing issue belongs to. resolve_project_link now
|
|
97
167
|
# returns the new project, so updating would push the OLD project's issue
|
|
@@ -125,6 +195,57 @@ module CollavreLinear
|
|
|
125
195
|
|
|
126
196
|
private
|
|
127
197
|
|
|
198
|
+
# Snapshot the pre-done Linear workflow state onto
|
|
199
|
+
# data["linear"]["state_before_done"] so a later drop below 100% can restore
|
|
200
|
+
# the issue to it (see apply_completion!'s un-done branch).
|
|
201
|
+
#
|
|
202
|
+
# Acts only for a completed leaf (progress >= 1.0) whose project has a done
|
|
203
|
+
# state configured. Sources the snapshot from the last-known non-done state in
|
|
204
|
+
# data["linear"]["state"]; when none is known (e.g. the leaf reached 100%
|
|
205
|
+
# purely in Collavre before any inbound state sync), queries Linear ONCE to
|
|
206
|
+
# seed it — but only if no snapshot exists yet, so this never re-queries.
|
|
207
|
+
#
|
|
208
|
+
# No-op for: the create path (no Linear issue yet to query), non-leaves,
|
|
209
|
+
# completion mapping off, an already-done known state, and when the snapshot
|
|
210
|
+
# is unchanged. Persists via update_column to avoid the sync observer (this is
|
|
211
|
+
# bookkeeping, not a mapped-field change) and never touches state_id itself.
|
|
212
|
+
def capture_state_before_done!(client, project_link, issue_link)
|
|
213
|
+
done_state_id = project_link.done_state_id
|
|
214
|
+
return if done_state_id.blank?
|
|
215
|
+
return unless @creative.progress.to_f >= 1.0
|
|
216
|
+
return unless @creative.children.active.empty?
|
|
217
|
+
|
|
218
|
+
linear = (@creative.data || {})["linear"] || {}
|
|
219
|
+
current = linear["state"]
|
|
220
|
+
|
|
221
|
+
candidate =
|
|
222
|
+
if current.is_a?(Hash) && current["id"].present? && current["id"] != done_state_id
|
|
223
|
+
current
|
|
224
|
+
elsif linear["state_before_done"].blank? && issue_link&.linear_issue_id.present?
|
|
225
|
+
fetched = client.fetch_issue_state(issue_link.linear_issue_id)
|
|
226
|
+
fetched if fetched.present? && fetched["id"] != done_state_id
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return if candidate.blank?
|
|
230
|
+
return if candidate == linear["state_before_done"]
|
|
231
|
+
|
|
232
|
+
new_data = (@creative.data || {}).deep_dup
|
|
233
|
+
new_data["linear"] ||= {}
|
|
234
|
+
new_data["linear"]["state_before_done"] = candidate
|
|
235
|
+
# Record our OWN outbound completion into the local state mirror: we are
|
|
236
|
+
# about to push this issue to the done state, but Linear's echo of that push
|
|
237
|
+
# is EchoGuard-suppressed and never arrives, and nothing else writes
|
|
238
|
+
# data["linear"]["state"] outbound. Without this, apply_completion!'s un-done
|
|
239
|
+
# guard (data["linear"]["state"] == done) can NEVER be satisfied on a
|
|
240
|
+
# Collavre-driven completion, so a later drop below 100% would push no state
|
|
241
|
+
# and leave the issue stuck in done. This is the truth — the issue IS now in
|
|
242
|
+
# done because we put it there — and a genuine human move in Linear still
|
|
243
|
+
# overwrites it via the inbound applier, correctly halting the restore.
|
|
244
|
+
new_data["linear"]["state"] = { "id" => done_state_id }
|
|
245
|
+
@creative.data = new_data
|
|
246
|
+
@creative.update_column(:data, new_data)
|
|
247
|
+
end
|
|
248
|
+
|
|
128
249
|
# True when this creative's parent is itself part of the exported subtree
|
|
129
250
|
# (it resolves the governing ProjectLink) but has not yet been exported to
|
|
130
251
|
# Linear (no linear_issue_id). The parent MUST land first so this child can
|
|
@@ -97,6 +97,10 @@ module CollavreLinear
|
|
|
97
97
|
creative.skip_linear_sync = true
|
|
98
98
|
merge_linear_data!(creative, attrs[:data_linear])
|
|
99
99
|
creative.sequence = attrs[:sequence]
|
|
100
|
+
# Completion mapping (Linear → Collavre): a freshly-imported issue that is
|
|
101
|
+
# already in the project's "done" state marks the (new, always-leaf)
|
|
102
|
+
# creative 100%.
|
|
103
|
+
reconcile_leaf_progress!(creative, project_link)
|
|
100
104
|
creative.save!
|
|
101
105
|
|
|
102
106
|
IssueLink.create!(
|
|
@@ -208,6 +212,17 @@ module CollavreLinear
|
|
|
208
212
|
end
|
|
209
213
|
merge_linear_data!(creative, attrs[:data_linear])
|
|
210
214
|
|
|
215
|
+
# Completion mapping (Linear → Collavre): only reconcile when the state
|
|
216
|
+
# field is actually part of this update (or updatedFrom is absent). A
|
|
217
|
+
# title-only edit must not touch the leaf's progress.
|
|
218
|
+
# Linear keys the state change in updatedFrom by its scalar FK column
|
|
219
|
+
# `stateId` (mirrors `parentId`/`projectId`/`priority` elsewhere), NOT a
|
|
220
|
+
# nested `state` object — matching "state" here never fired on real
|
|
221
|
+
# webhooks, silently disabling done→100% on the normal update path.
|
|
222
|
+
if changed.nil? || changed.include?("stateId")
|
|
223
|
+
reconcile_leaf_progress!(creative, link.project_link)
|
|
224
|
+
end
|
|
225
|
+
|
|
211
226
|
# Reparent: when the payload's Linear parent differs from what the link
|
|
212
227
|
# last recorded, move the Creative under the corresponding linked parent
|
|
213
228
|
# and track the new parent_issue_id. Without this the tree stays under the
|
|
@@ -548,6 +563,37 @@ module CollavreLinear
|
|
|
548
563
|
attrs[:title].presence || ""
|
|
549
564
|
end
|
|
550
565
|
|
|
566
|
+
# Completion mapping (Linear → Collavre). Reflect whether the inbound issue
|
|
567
|
+
# is in the project's configured "done" workflow state onto the LEAF
|
|
568
|
+
# creative's progress. Sets progress in memory; the caller's save! persists
|
|
569
|
+
# it under skip_linear_sync (no echo back to Linear).
|
|
570
|
+
#
|
|
571
|
+
# Guards (deliberate no-ops):
|
|
572
|
+
# * done_state_id blank — completion mapping not configured.
|
|
573
|
+
# * origin-linked creative — its progress delegates to the origin and
|
|
574
|
+
# a direct write is validation-forbidden; leave it alone.
|
|
575
|
+
# * creative has active children — only leaves carry independent progress
|
|
576
|
+
# (a parent's is a rollup average driven by its children).
|
|
577
|
+
#
|
|
578
|
+
# Transitions:
|
|
579
|
+
# * state == done_state_id → progress 1.0 (100%).
|
|
580
|
+
# * state != done_state_id AND was 1.0 → progress 0.0 (un-complete). A
|
|
581
|
+
# sub-100% local progress is PRESERVED — the done<->100% mapping is
|
|
582
|
+
# binary, so a non-done state must not clobber partial Collavre progress.
|
|
583
|
+
def reconcile_leaf_progress!(creative, project_link)
|
|
584
|
+
done_state_id = project_link&.done_state_id
|
|
585
|
+
return if done_state_id.blank?
|
|
586
|
+
return if creative.respond_to?(:origin_id) && creative.origin_id.present?
|
|
587
|
+
return unless creative.children.active.empty?
|
|
588
|
+
|
|
589
|
+
state_id = @data.dig("state", "id")
|
|
590
|
+
if state_id.present? && state_id == done_state_id
|
|
591
|
+
creative.progress = 1.0 if creative.progress.to_f < 1.0
|
|
592
|
+
elsif creative.progress.to_f >= 1.0
|
|
593
|
+
creative.progress = 0.0
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
551
597
|
def merge_linear_data!(creative, data_linear)
|
|
552
598
|
return if data_linear.blank?
|
|
553
599
|
|
|
@@ -159,6 +159,23 @@
|
|
|
159
159
|
style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
|
|
160
160
|
</div>
|
|
161
161
|
|
|
162
|
+
<%# Done-state combobox: which Linear workflow state marks an issue as
|
|
163
|
+
"완료". JS scopes the options to the chosen team and defaults to that
|
|
164
|
+
team's Completed state. Optional — a team with no completed state
|
|
165
|
+
leaves it blank and completion mapping stays off. %>
|
|
166
|
+
<div style="margin-bottom:1em;">
|
|
167
|
+
<%= f.label :done_state_id, t("collavre_linear.integration.done_state_label"),
|
|
168
|
+
style: "display:block;margin-bottom:0.25em;font-weight:600;" %>
|
|
169
|
+
<%= f.select :done_state_id,
|
|
170
|
+
[],
|
|
171
|
+
{ include_blank: t("collavre_linear.integration.done_state_placeholder") },
|
|
172
|
+
disabled: true,
|
|
173
|
+
style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
|
|
174
|
+
<p style="margin:0.25em 0 0;color:var(--text-muted);font-size:0.85em;">
|
|
175
|
+
<%= t("collavre_linear.integration.done_state_help") %>
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
162
179
|
<%= f.submit t("collavre_linear.integration.link_button"),
|
|
163
180
|
class: "btn btn-primary", disabled: true %>
|
|
164
181
|
<% end %>
|
data/config/locales/en.yml
CHANGED
|
@@ -17,6 +17,9 @@ en:
|
|
|
17
17
|
project_id_label: "Project"
|
|
18
18
|
project_select_placeholder: "Select a project…"
|
|
19
19
|
team_select_placeholder: "Select a team…"
|
|
20
|
+
done_state_label: "Done state"
|
|
21
|
+
done_state_placeholder: "Select a done state…"
|
|
22
|
+
done_state_help: "When a Linear issue reaches this state its matching leaf creative is marked 100% — and a leaf creative reaching 100% moves its Linear issue to this state."
|
|
20
23
|
options_loading: "Loading projects and teams…"
|
|
21
24
|
options_failed: "Couldn't load your Linear projects and teams. Please try again."
|
|
22
25
|
no_projects: "No projects found in your Linear workspace."
|
data/config/locales/ko.yml
CHANGED
|
@@ -17,6 +17,9 @@ ko:
|
|
|
17
17
|
project_id_label: "프로젝트"
|
|
18
18
|
project_select_placeholder: "프로젝트 선택…"
|
|
19
19
|
team_select_placeholder: "팀 선택…"
|
|
20
|
+
done_state_label: "완료 상태"
|
|
21
|
+
done_state_placeholder: "완료 상태 선택…"
|
|
22
|
+
done_state_help: "Linear 이슈가 이 상태가 되면 대응되는 leaf 크리에이티브가 100%로 처리되고, 반대로 leaf 크리에이티브가 100%가 되면 Linear 이슈가 이 상태로 이동합니다."
|
|
20
23
|
options_loading: "프로젝트와 팀을 불러오는 중…"
|
|
21
24
|
options_failed: "Linear 프로젝트와 팀을 불러오지 못했습니다. 다시 시도해 주세요."
|
|
22
25
|
no_projects: "Linear 워크스페이스에 프로젝트가 없습니다."
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddDoneStateIdToLinearProjectLinks < ActiveRecord::Migration[8.0]
|
|
2
|
+
# The Linear workflow-state UUID that represents "done" for this linked
|
|
3
|
+
# project. Chosen by the admin (combobox, default "Completed") at link time.
|
|
4
|
+
# Nullable: links created before this feature — or teams with no completed
|
|
5
|
+
# state — simply have no completion mapping (the sync no-ops both ways).
|
|
6
|
+
def change
|
|
7
|
+
add_column :linear_project_links, :done_state_id, :string
|
|
8
|
+
end
|
|
9
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_linear
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -86,6 +86,7 @@ files:
|
|
|
86
86
|
- db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb
|
|
87
87
|
- db/migrate/20260701000007_enforce_one_project_link_per_creative.rb
|
|
88
88
|
- db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb
|
|
89
|
+
- db/migrate/20260704000000_add_done_state_id_to_linear_project_links.rb
|
|
89
90
|
- lib/collavre_linear.rb
|
|
90
91
|
- lib/collavre_linear/engine.rb
|
|
91
92
|
- lib/collavre_linear/version.rb
|