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.



75
76
77
# File 'app/services/collavre_linear/creative_exporter.rb', line 75

def initialize(creative)
  @creative = creative
end

Class Method Details

.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
# 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
    )
  )
  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.



70
71
72
73
# File 'app/services/collavre_linear/creative_exporter.rb', line 70

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).



60
61
62
63
64
65
# File 'app/services/collavre_linear/creative_exporter.rb', line 60

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

  parent.linear_issue_links.first&.linear_issue_id
end

Instance Method Details

#sync!Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'app/services/collavre_linear/creative_exporter.rb', line 79

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()
  attrs     = FieldMapper.creative_to_issue_attrs(adapt(@creative))
  parent_id = parent_linear_issue_id
  hash      = compute_content_hash(attrs, parent_id)

  issue_link = @creative.linear_issue_links.first

  # 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