Class: CollavreLinear::CreativeExporter

Inherits:
Object
  • Object
show all
Defined in:
app/services/collavre_linear/creative_exporter.rb

Overview

Syncs a single Collavre::Creative to Linear as an issue.

Usage:

CollavreLinear::CreativeExporter.new(creative).sync!

Resolves the governing ProjectLink from the creative itself or its nearest ancestor. Creates a new Linear issue and IssueLink on first run; updates the existing issue on subsequent runs. Skips the network call when the content hash is unchanged (dirty-tracking). Raises Client::Error on network failure so the caller (OutboundSyncJob) can retry.

Project-root mapping: the ProjectLink-root Creative maps to the Linear PROJECT itself, NOT an issue. Its direct children become the project's TOP-LEVEL issues (parentId nil); deeper descendants nest as sub-issues. Exporting the root as an issue would consume the project's single top-level slot and flatten every sibling under it, so sync! no-ops for the root. Inbound mirrors this: a top-level Linear issue is imported as a direct child of the root Creative (see InboundApplier#resolve_create_parent).

Defined Under Namespace

Classes: CreativeAdapter, ParentNotExportedError

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(creative) ⇒ CreativeExporter

Returns a new instance of CreativeExporter.



136
137
138
# File 'app/services/collavre_linear/creative_exporter.rb', line 136

def initialize(creative)
  @creative = creative
end

Class Method Details

.apply_completion!(attrs, creative, project_link) ⇒ Object

Completion mapping (Collavre → Linear): a LEAF creative at 100% progress exports with its project's configured "done" workflow state. Mutates and returns attrs.

Guards, each a deliberate no-op:

* no project_link / no done_state_id — completion mapping not configured.
* creative has active children       — only leaves carry independent
progress (a parent's progress is a rollup average), so only leaves drive
the done state; a parent issue's state follows from its children.
* progress < 1.0                     — only 100% maps to done. We do NOT
blindly clobber the state here; instead, when our record shows the issue
still sitting in the done state, we RESTORE the state it held before it
was completed (the "un-done" case — see below). Otherwise the last-known
Linear state (from data["linear"]) echoes through unchanged.

Un-done (Collavre 100% → below): the pre-done state is snapshotted into data["state_before_done"] by #capture_state_before_done! at the moment the leaf is pushed to done. On a later drop below 100% we push that snapshot back — but only while data["state"] still equals the done state (i.e. our push landed and nobody has since moved the issue). That done-state guard keeps us from fighting a human who moved the issue elsewhere in Linear, and bounds the loop: once the restore echoes back, data["state"] is no longer done so this never re-fires.

This method stays PURE (no I/O, no mutation) so CreativeExporter.content_hash_for and #sync! compute the SAME state_id — the capture that needs the network is done separately in the sync! path.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/services/collavre_linear/creative_exporter.rb', line 101

def self.apply_completion!(attrs, creative, project_link)
  done_state_id = project_link&.done_state_id
  return attrs if done_state_id.blank?
  return attrs unless creative.children.active.empty?

  if creative.progress.to_f >= 1.0
    attrs[:state_id] = done_state_id
  else
    linear  = (creative.data || {})["linear"] || {}
    before  = linear["state_before_done"]
    current = linear["state"]
    if before.present? && current.is_a?(Hash) && current["id"] == done_state_id
      attrs[:state_id] = before["id"]
    end
  end
  attrs
end

.content_hash_for(creative) ⇒ Object

Compute the outbound content hash for a Creative's current state. Shared with the inbound applier so it can advance IssueLink#content_hash after applying a remote update, keeping the exporter's dirty-check consistent.

The hash folds in the parent's linear_issue_id so that a pure reparent (content fields unchanged) still changes the hash and drives update_issue!, which pushes the new parentId to Linear. Otherwise Collavre and Linear hierarchies would diverge after a move.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'app/services/collavre_linear/creative_exporter.rb', line 46

def self.content_hash_for(creative)
  attrs = FieldMapper.creative_to_issue_attrs(
    CreativeAdapter.new(
      title:       creative.creative_snippet,
      description: creative.description,
      sequence:    creative.sequence,
      data:        creative.data
    )
  )
  # Fold the completion→state override into the hash so inbound and outbound
  # agree: after an inbound apply advances content_hash via this method, a
  # later outbound sync of a completed leaf must see the SAME state_id it
  # would push. Resolve the governing ProjectLink the same way sync! does.
  apply_completion!(attrs, creative, resolve_project_link_for(creative))
  hash_attrs(attrs, parent_linear_issue_id_for(creative))
end

.hash_attrs(attrs, parent_linear_issue_id) ⇒ Object

SHA-256 of the sorted, serialised mapped attrs plus the parent's linear issue id (stable key order). Single source of truth so inbound + outbound and the class/instance paths all agree.



131
132
133
134
# File 'app/services/collavre_linear/creative_exporter.rb', line 131

def self.hash_attrs(attrs, parent_linear_issue_id)
  payload = attrs.merge(_parent_linear_issue_id: parent_linear_issue_id)
  Digest::SHA256.hexdigest(payload.sort.to_h.to_json)
end

.parent_linear_issue_id_for(creative) ⇒ Object

Resolve the parent Creative's linked linear_issue_id (or nil when the parent is absent or not itself linked to a Linear issue).



121
122
123
124
125
126
# File 'app/services/collavre_linear/creative_exporter.rb', line 121

def self.parent_linear_issue_id_for(creative)
  parent = creative.parent
  return nil unless parent

  parent.linear_issue_links.first&.linear_issue_id
end

Walk self-and-ancestors (nearest first) for the governing ProjectLink. Class-level twin of the instance #resolve_project_link so content_hash_for can apply the same completion override.



66
67
68
69
70
71
72
# File 'app/services/collavre_linear/creative_exporter.rb', line 66

def self.resolve_project_link_for(creative)
  creative.self_and_ancestors.each do |ancestor|
    link = CollavreLinear::ProjectLink.find_by(creative_id: ancestor.id)
    return link if link
  end
  nil
end

Instance Method Details

#sync!Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'app/services/collavre_linear/creative_exporter.rb', line 140

def sync!
  project_link = resolve_project_link
  return unless project_link

  # The ProjectLink-root Creative IS the project, not an issue — skip it.
  # (Its children export as the project's top-level issues; see class docs.)
  return if project_link.creative_id == @creative.id

      = project_link.
  client     = Client.new()
  issue_link = @creative.linear_issue_links.first

  # Before a completed leaf's state is overridden to "done", remember the state
  # it is leaving so a later drop below 100% can restore it (outbound un-done).
  # This may query Linear once, so it lives here in the I/O path — NOT in the
  # pure apply_completion!/content_hash_for.
  capture_state_before_done!(client, project_link, issue_link)

  attrs     = FieldMapper.creative_to_issue_attrs(adapt(@creative))
  # Completed leaf → push the project's "done" state; a leaf dropped below 100%
  # → restore its pre-done state (mirrors content_hash_for).
  self.class.apply_completion!(attrs, @creative, project_link)
  parent_id = parent_linear_issue_id
  hash      = compute_content_hash(attrs, parent_id)

  # Cross-project move: the creative was reparented under a DIFFERENT linked
  # root than the one its existing issue belongs to. resolve_project_link now
  # returns the new project, so updating would push the OLD project's issue
  # with the NEW project's account/client (wrong project) and diverge the tree
  # from Linear. It cannot be auto-applied — re-homing the issue (and its
  # linked sub-issues, which move with it in Linear) into the new project is
  # ambiguous. Halt + surface as conflict (mirrors the inbound cross-project
  # handling); actively migrating/recreating is a product decision. Checked
  # before the parent-export guard so a cross-root move never defers.
  if issue_link && issue_link.project_link_id != project_link.id
    return mark_cross_project_conflict!(issue_link)
  end

  # Ordering guard for BOTH create and update: if this creative's parent
  # belongs to the exported subtree but has not yet produced its Linear issue,
  # defer (the job re-enqueues on ParentNotExportedError once the parent lands).
  #   - create: exporting now would make a top-level issue, flattening the tree.
  #   - update: an already-linked creative moved under a NEWLY-created parent
  #     can run before the parent's create job. Without waiting we'd send no
  #     parentId, persist parent_issue_id for the still-nil parent, and the
  #     later parent export would NOT re-enqueue us — leaving the Linear issue
  #     under its old parent until a manual resync.
  raise ParentNotExportedError if parent_export_pending?

  if issue_link.nil?
    create_issue!(client, project_link, attrs, hash, parent_id)
  else
    update_issue!(client, issue_link, attrs, hash, parent_id)
  end
end