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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cc482eb53e316ac259f9565368c50a59cbd1dce01be3b74100c41b239919731
4
- data.tar.gz: cccca601d9c57cd0d45ee603d50b46663d6d3daac3373e93a4b4e3b9b59348b1
3
+ metadata.gz: f91e7ebe8a6419a4b13d66c1d3cd919520bbef1c617981747f0008fafef63377
4
+ data.tar.gz: a960ab7bd872ba996974b5f02c4741e4d6285f5f699be6258fc1979ccf42dd38
5
5
  SHA512:
6
- metadata.gz: e4a88144150cd9d67ff55433fde009579709cb572cdc08a7cd5209c6d187394917a0efdcd8bbd6a73d60e85fc46ae5d21e8ac731b015b0a0973819e73b51cd61
7
- data.tar.gz: 28ee1fc888af6a9a86fd2b18f30883668d273111ad01912a14cc73e0eb598ae412c0efd0733e850013e4da2e81bd4b888b564e74e0e654619df849457d13ec2c
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: { teams: client.list_teams, projects: client.list_projects }
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.now.to_f * 1000).to_i
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
- LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id].freeze
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
- uri = URI.parse(endpoint)
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
- http.request(request)
349
- rescue SocketError, SystemCallError, Timeout::Error, IOError,
350
- OpenSSL::SSL::SSLError => e
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.is_a?(Net::HTTPSuccess)
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 = project_link.account
88
- client = Client.new(account)
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 %>
@@ -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."
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CollavreLinear
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  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.1.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