Class: CollavreLinear::CreativeExporter
- Inherits:
-
Object
- Object
- CollavreLinear::CreativeExporter
- 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
-
.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.
-
.content_hash_for(creative) ⇒ Object
Compute the outbound content hash for a Creative's current state.
-
.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).
-
.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).
-
.resolve_project_link_for(creative) ⇒ Object
Walk self-and-ancestors (nearest first) for the governing ProjectLink.
Instance Method Summary collapse
-
#initialize(creative) ⇒ CreativeExporter
constructor
A new instance of CreativeExporter.
- #sync! ⇒ Object
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 |
.resolve_project_link_for(creative) ⇒ Object
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 account = project_link.account client = Client.new(account) 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 |