collavre_github 0.4.1 → 0.5.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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/collavre_github/creatives/integrations_controller.rb +61 -12
  3. data/app/controllers/collavre_github/webhooks_controller.rb +25 -4
  4. data/app/jobs/collavre_github/initial_markdown_sync_job.rb +19 -0
  5. data/app/jobs/collavre_github/markdown_sync_job.rb +16 -0
  6. data/app/models/collavre_github/repository_link.rb +7 -0
  7. data/app/services/collavre_github/client.rb +50 -0
  8. data/app/services/collavre_github/markdown_sync/content_processor.rb +113 -0
  9. data/app/services/collavre_github/markdown_sync/incremental_sync_service.rb +231 -0
  10. data/app/services/collavre_github/markdown_sync/initial_import_service.rb +190 -0
  11. data/app/services/collavre_github/tools/concerns/github_client_finder.rb +17 -0
  12. data/app/services/collavre_github/tools/github_pr_commits_service.rb +7 -10
  13. data/app/services/collavre_github/tools/github_pr_details_service.rb +21 -24
  14. data/app/services/collavre_github/tools/github_pr_diff_service.rb +13 -16
  15. data/app/services/collavre_github/webhook_provisioner.rb +18 -3
  16. data/app/views/collavre_github/auth/setup.html.erb +48 -3
  17. data/app/views/collavre_github/integrations/_modal.html.erb +18 -3
  18. data/config/locales/en.yml +9 -0
  19. data/config/locales/ko.yml +9 -0
  20. data/config/routes.rb +3 -1
  21. data/db/migrate/20260412000000_add_markdown_sync_to_repository_links.rb +10 -0
  22. data/db/migrate/20260416000000_add_markdown_root_creative_index.rb +5 -0
  23. data/lib/collavre_github/version.rb +1 -1
  24. data/lib/tasks/mock_import.rake +98 -0
  25. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c11fdb398224bc3a5dfeeb326b2f5ddd1575c1a371907ce0fcb9d5592e2f9cf
4
- data.tar.gz: '058cfbcf2a41677dd39ec6da17f3b8f31471875db56e158855f587985e26005b'
3
+ metadata.gz: d8f3ffe59f1a7e85a1a42fd49cd5fa3ee1ad40667996a69e405fef0bb8415318
4
+ data.tar.gz: 5a9c05bf70a341ca56253bfa2d6a2849acc335038e42c1b9919747be2d564f7f
5
5
  SHA512:
6
- metadata.gz: c9063bc158175ca780be591a837e7b185eb2e6fc7595422c4f52b17ae91c5f459246521ccaa0eac5d6dbb2de2b6fda1331f0df59cbab16730f7112b06eb780ff
7
- data.tar.gz: 1fbcc7a46e22b45146a812335524dcd3a08ec10b2cc21d6f7588978a4a3960fad36b7fd6f680a29c30b632f44cf3367f230c78cbde588bc0342ade2c69a908c1
6
+ metadata.gz: ee53fe7588353302c64f84decc488e62b1d8f9fb9d65f8332439d0bd8d2f353fc8777ff59724c9c9d82390fd5094ab513f796ab5b1666fd456e382fd0d13099b
7
+ data.tar.gz: aa439ab76e282b9841abcaea1a84ad0e3ccfaf06214cb8380af7e24661ea93848de8197fd8f69b4108b5a4b609ed2cc8caaae69cc5c59e48f833328a6829b2e6
@@ -1,12 +1,13 @@
1
1
  module CollavreGithub
2
2
  module Creatives
3
3
  class IntegrationsController < ApplicationController
4
+ include Collavre::IntegrationSetup
4
5
  include Collavre::IntegrationPermission
5
6
 
6
7
  before_action :set_creative
7
8
  before_action :set_origin
8
9
  before_action :ensure_read_permission
9
- before_action :ensure_admin_permission, only: [ :show, :update ]
10
+ before_action :ensure_admin_permission, only: [ :show, :update, :resync ]
10
11
 
11
12
  def show
12
13
  account = Current.user.github_account
@@ -25,7 +26,14 @@ module CollavreGithub
25
26
  },
26
27
  selected_repositories: links.map(&:repository_full_name),
27
28
  all_repositories: all_repositories,
28
- webhooks: serialize_webhooks(links)
29
+ webhooks: serialize_webhooks(links),
30
+ markdown_sync: links.each_with_object({}) { |l, h|
31
+ h[l.repository_full_name] = {
32
+ enabled: l.markdown_sync_enabled?,
33
+ last_synced_at: l.last_synced_at,
34
+ sync_branch: l.markdown_sync_branch
35
+ }
36
+ }
29
37
  }
30
38
  end
31
39
 
@@ -56,6 +64,24 @@ module CollavreGithub
56
64
  links = linked_repository_links(account).to_a
57
65
  end
58
66
 
67
+ # Handle markdown sync toggle BEFORE webhook provisioning
68
+ # so events_for() sees the updated markdown_sync_enabled state
69
+ markdown_sync = integration_attributes[:markdown_sync]
70
+ if markdown_sync.is_a?(ActionController::Parameters) || markdown_sync.is_a?(Hash)
71
+ links.each do |link|
72
+ repo = link.repository_full_name
73
+ next unless markdown_sync.key?(repo)
74
+
75
+ enabled = ActiveModel::Type::Boolean.new.cast(markdown_sync[repo])
76
+ was_enabled = link.markdown_sync_enabled?
77
+ link.update!(markdown_sync_enabled: enabled)
78
+
79
+ if enabled && !was_enabled
80
+ CollavreGithub::InitialMarkdownSyncJob.perform_later(link.id)
81
+ end
82
+ end
83
+ end
84
+
59
85
  CollavreGithub::WebhookProvisioner.ensure_for_links(
60
86
  account: account,
61
87
  links: links,
@@ -65,12 +91,43 @@ module CollavreGithub
65
91
  render json: {
66
92
  success: true,
67
93
  selected_repositories: links.map(&:repository_full_name),
68
- webhooks: serialize_webhooks(links)
94
+ webhooks: serialize_webhooks(links),
95
+ markdown_sync: links.each_with_object({}) { |l, h|
96
+ h[l.repository_full_name] = {
97
+ enabled: l.markdown_sync_enabled?,
98
+ last_synced_at: l.last_synced_at,
99
+ sync_branch: l.markdown_sync_branch
100
+ }
101
+ }
69
102
  }
70
103
  rescue ActiveRecord::RecordInvalid => e
71
104
  render json: { error: e.message }, status: :unprocessable_entity
72
105
  end
73
106
 
107
+ def resync
108
+ account = Current.user.github_account
109
+ unless account
110
+ render json: { error: I18n.t("collavre_github.errors.not_connected") }, status: :unprocessable_entity
111
+ return
112
+ end
113
+
114
+ repo = params[:repository]
115
+ link = linked_repository_links(account).find_by(repository_full_name: repo)
116
+ unless link&.markdown_sync_enabled?
117
+ render json: { error: I18n.t("collavre_github.markdown_sync.not_enabled") }, status: :unprocessable_entity
118
+ return
119
+ end
120
+
121
+ # Archive existing synced tree and re-import
122
+ if link.markdown_root_creative
123
+ link.markdown_root_creative.archive! if link.markdown_root_creative.respond_to?(:archive!)
124
+ link.update!(markdown_root_creative_id: nil)
125
+ end
126
+
127
+ CollavreGithub::InitialMarkdownSyncJob.perform_later(link.id)
128
+ render json: { success: true, message: I18n.t("collavre_github.markdown_sync.resync_started") }
129
+ end
130
+
74
131
  def destroy
75
132
  unless @creative.has_permission?(Current.user, :write)
76
133
  render json: { error: integration_forbidden_message }, status: :forbidden
@@ -139,14 +196,6 @@ module CollavreGithub
139
196
 
140
197
  private
141
198
 
142
- def set_creative
143
- @creative = Collavre::Creative.find(params[:creative_id])
144
- end
145
-
146
- def set_origin
147
- @origin = @creative.effective_origin
148
- end
149
-
150
199
  def integration_forbidden_message
151
200
  I18n.t("collavre_github.errors.forbidden")
152
201
  end
@@ -158,7 +207,7 @@ module CollavreGithub
158
207
  end
159
208
 
160
209
  def integration_params
161
- params.permit(repositories: [])
210
+ params.permit(repositories: [], markdown_sync: {})
162
211
  end
163
212
 
164
213
  def serialize_webhooks(links)
@@ -13,7 +13,13 @@ module CollavreGithub
13
13
  return head :unauthorized unless valid_signature?(raw_body)
14
14
 
15
15
  payload = payload.presence || {}
16
- create_system_comment(event, payload) if @repository_link&.creative
16
+
17
+ # Process all links for this repo (same repo can be linked to multiple creatives)
18
+ all_links = all_repository_links_for(payload)
19
+ all_links.each do |link|
20
+ create_system_comment_for(link, event, payload) if link.creative
21
+ trigger_markdown_sync_for(link, event, payload) if link.markdown_sync_enabled?
22
+ end
17
23
 
18
24
  head :ok
19
25
  rescue JSON::ParserError
@@ -22,8 +28,8 @@ module CollavreGithub
22
28
 
23
29
  private
24
30
 
25
- def create_system_comment(event, payload)
26
- creative = @repository_link.creative&.effective_origin
31
+ def create_system_comment_for(link, event, payload)
32
+ creative = link.creative&.effective_origin
27
33
  return unless creative
28
34
 
29
35
  content = format_github_event(event, payload)
@@ -33,7 +39,6 @@ module CollavreGithub
33
39
  content: content,
34
40
  private: false
35
41
  )
36
- # Dispatch handled by Comment#after_create_commit (skipped for system messages with user: nil)
37
42
  end
38
43
 
39
44
  def format_github_event(event, payload)
@@ -202,6 +207,22 @@ module CollavreGithub
202
207
  I18n.t("collavre_github.webhooks.#{key}", **options)
203
208
  end
204
209
 
210
+ def trigger_markdown_sync_for(link, event, payload)
211
+ return unless event == "push"
212
+
213
+ CollavreGithub::MarkdownSyncJob.perform_later(
214
+ link.id,
215
+ payload.as_json
216
+ )
217
+ end
218
+
219
+ def all_repository_links_for(payload)
220
+ repo = payload&.dig("repository", "full_name") || payload&.dig(:repository, :full_name)
221
+ return [ @repository_link ].compact if repo.blank?
222
+
223
+ CollavreGithub::RepositoryLink.where(repository_full_name: repo).to_a
224
+ end
225
+
205
226
  def find_repository_link(payload)
206
227
  if payload.blank?
207
228
  Rails.logger.warn("[GitHub Webhook] Payload is blank")
@@ -0,0 +1,19 @@
1
+ module CollavreGithub
2
+ class InitialMarkdownSyncJob < ApplicationJob
3
+ queue_as :default
4
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
5
+
6
+ def perform(repository_link_id)
7
+ link = CollavreGithub::RepositoryLink.find_by(id: repository_link_id)
8
+ return unless link&.markdown_sync_enabled?
9
+
10
+ user = link.creative.user
11
+ return unless user
12
+
13
+ CollavreGithub::MarkdownSync::InitialImportService.new(
14
+ repository_link: link,
15
+ user: user
16
+ ).call
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ module CollavreGithub
2
+ class MarkdownSyncJob < ApplicationJob
3
+ queue_as :default
4
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3
5
+
6
+ def perform(repository_link_id, push_payload)
7
+ link = CollavreGithub::RepositoryLink.find_by(id: repository_link_id)
8
+ return unless link&.markdown_sync_enabled?
9
+
10
+ CollavreGithub::MarkdownSync::IncrementalSyncService.new(
11
+ repository_link: link,
12
+ push_payload: push_payload
13
+ ).call
14
+ end
15
+ end
16
+ end
@@ -4,12 +4,19 @@ module CollavreGithub
4
4
 
5
5
  belongs_to :creative, class_name: "Collavre::Creative"
6
6
  belongs_to :github_account, class_name: "CollavreGithub::Account"
7
+ belongs_to :markdown_root_creative, class_name: "Collavre::Creative", optional: true
7
8
 
8
9
  validates :repository_full_name, presence: true
9
10
  validates :webhook_secret, presence: true
10
11
 
11
12
  before_validation :ensure_webhook_secret
12
13
 
14
+ scope :markdown_sync, -> { where(markdown_sync_enabled: true) }
15
+
16
+ def markdown_sync_branch
17
+ sync_branch.presence || "main"
18
+ end
19
+
13
20
  private
14
21
 
15
22
  def ensure_webhook_secret
@@ -85,6 +85,9 @@ module CollavreGithub
85
85
  active: true
86
86
  }
87
87
  )
88
+ rescue Octokit::Error, Faraday::Error => e
89
+ Rails.logger.warn("GitHub create webhook failed for #{repo_full_name}: #{e.message}")
90
+ nil
88
91
  end
89
92
 
90
93
  def update_repository_webhook(repo_full_name, hook_id, url:, secret:, events:, content_type: "json")
@@ -102,10 +105,57 @@ module CollavreGithub
102
105
  active: true
103
106
  }
104
107
  )
108
+ rescue Octokit::Error, Faraday::Error => e
109
+ Rails.logger.warn("GitHub update webhook failed for #{repo_full_name}: #{e.message}")
110
+ nil
105
111
  end
106
112
 
107
113
  def delete_repository_webhook(repo_full_name, hook_id)
108
114
  client.remove_hook(repo_full_name, hook_id)
115
+ rescue Octokit::Error, Faraday::Error => e
116
+ Rails.logger.warn("GitHub delete webhook failed for #{repo_full_name}: #{e.message}")
117
+ nil
118
+ end
119
+
120
+ # Fetch the default branch name for a repository
121
+ def default_branch(repo_full_name)
122
+ repo = client.repository(repo_full_name)
123
+ repo.default_branch
124
+ rescue Octokit::Error, Faraday::Error => e
125
+ Rails.logger.warn("GitHub default branch fetch failed: #{e.message}")
126
+ "main"
127
+ end
128
+
129
+ # Fetch the full recursive tree for a given branch/sha
130
+ def tree(repo_full_name, branch)
131
+ sha = client.ref(repo_full_name, "heads/#{branch}").object.sha
132
+ result = client.tree(repo_full_name, sha, recursive: true)
133
+ result.tree
134
+ rescue Octokit::Error, Faraday::Error => e
135
+ Rails.logger.warn("GitHub tree fetch failed: #{e.message}")
136
+ []
137
+ end
138
+
139
+ # Fetch file content (decoded) for a given path and ref
140
+ def file_content(repo_full_name, path, ref: nil)
141
+ opts = ref ? { ref: ref } : {}
142
+ content = client.contents(repo_full_name, path: path, **opts)
143
+ if content.encoding == "base64"
144
+ Base64.decode64(content.content).force_encoding("UTF-8")
145
+ else
146
+ content.content
147
+ end
148
+ rescue Octokit::Error, Faraday::Error => e
149
+ Rails.logger.warn("GitHub file content fetch failed for #{path}: #{e.message}")
150
+ nil
151
+ end
152
+
153
+ # Compare two commits and return changed files
154
+ def compare(repo_full_name, base, head)
155
+ client.compare(repo_full_name, base, head)
156
+ rescue Octokit::Error, Faraday::Error => e
157
+ Rails.logger.warn("GitHub compare failed: #{e.message}")
158
+ nil
109
159
  end
110
160
 
111
161
  private
@@ -0,0 +1,113 @@
1
+ module CollavreGithub
2
+ module MarkdownSync
3
+ class ContentProcessor
4
+ IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .svg .webp .ico .bmp .tiff .avif].freeze
5
+
6
+ def initialize(client:, repo:, branch:, path_to_creative_map:)
7
+ @client = client
8
+ @repo = repo
9
+ @branch = branch
10
+ @path_to_creative_map = path_to_creative_map
11
+ @image_cache = {}
12
+ end
13
+
14
+ # Returns [processed_content, array_of_blobs]
15
+ def process(markdown_content, file_path)
16
+ content = markdown_content.dup
17
+ blobs = []
18
+ dir = File.dirname(file_path)
19
+
20
+ content = content.gsub(/(!?)\[([^\]]*)\]\(([^)]+)\)/) do |match|
21
+ prefix = Regexp.last_match(1)
22
+ label = Regexp.last_match(2)
23
+ url = Regexp.last_match(3)
24
+
25
+ next match if absolute_url?(url) || anchor_only?(url)
26
+
27
+ if prefix == "!"
28
+ process_image(match, label, url, dir, blobs)
29
+ else
30
+ process_link(match, label, url, dir)
31
+ end
32
+ end
33
+
34
+ [ content, blobs ]
35
+ end
36
+
37
+ private
38
+
39
+ def process_image(match, alt, url, dir, blobs)
40
+ url_path = strip_fragment_and_query(url)
41
+ resolved = resolve_path(dir, url_path)
42
+
43
+ blob = download_and_upload(resolved)
44
+ return match unless blob
45
+
46
+ blobs << blob
47
+ blob_url = Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
48
+ "![#{alt}](#{blob_url})"
49
+ end
50
+
51
+ def process_link(match, label, url, dir)
52
+ url_path, fragment = url.split("#", 2)
53
+ url_path = strip_query(url_path)
54
+ resolved = resolve_path(dir, url_path)
55
+
56
+ creative = @path_to_creative_map[resolved]
57
+ return match unless creative
58
+
59
+ link = "/creatives/#{creative.id}"
60
+ link += "##{fragment}" if fragment.present?
61
+ "[#{label}](#{link})"
62
+ end
63
+
64
+ def download_and_upload(resolved_path)
65
+ return @image_cache[resolved_path] if @image_cache.key?(resolved_path)
66
+
67
+ raw = @client.file_content(@repo, resolved_path, ref: @branch)
68
+ unless raw.present?
69
+ @image_cache[resolved_path] = nil
70
+ return nil
71
+ end
72
+
73
+ filename = File.basename(resolved_path)
74
+ ext = File.extname(filename).downcase
75
+ content_type = Rack::Mime.mime_type(ext, "application/octet-stream")
76
+
77
+ blob = ActiveStorage::Blob.create_and_upload!(
78
+ io: StringIO.new(raw),
79
+ filename: filename,
80
+ content_type: content_type
81
+ )
82
+ @image_cache[resolved_path] = blob
83
+ blob
84
+ rescue StandardError => e
85
+ Rails.logger.warn("[MarkdownSync] Image download failed for #{resolved_path}: #{e.message}")
86
+ @image_cache[resolved_path] = nil
87
+ nil
88
+ end
89
+
90
+ def resolve_path(dir, relative)
91
+ clean = relative.to_s.strip
92
+ return clean if clean.empty?
93
+ Pathname.new(File.join(dir, clean)).cleanpath.to_s
94
+ end
95
+
96
+ def absolute_url?(url)
97
+ url.match?(%r{\A(https?://|//|mailto:)})
98
+ end
99
+
100
+ def anchor_only?(url)
101
+ url.start_with?("#")
102
+ end
103
+
104
+ def strip_fragment_and_query(url)
105
+ url.split("#").first.split("?").first
106
+ end
107
+
108
+ def strip_query(url)
109
+ url.split("?").first
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,231 @@
1
+ module CollavreGithub
2
+ module MarkdownSync
3
+ class IncrementalSyncService
4
+ def initialize(repository_link:, push_payload:)
5
+ @link = repository_link
6
+ @payload = push_payload
7
+ @client = CollavreGithub::Client.new(repository_link.github_account)
8
+ @repo = repository_link.repository_full_name
9
+ @user = repository_link.creative.user
10
+ end
11
+
12
+ def call
13
+ return unless @link.markdown_sync_enabled?
14
+ return unless target_branch_push?
15
+
16
+ root = @link.markdown_root_creative
17
+ return unless root
18
+
19
+ commits = @payload["commits"] || []
20
+ return if commits.empty?
21
+
22
+ added_paths = []
23
+ modified_paths = []
24
+ removed_paths = []
25
+
26
+ commits.each do |commit|
27
+ added_paths.concat(Array(commit["added"]))
28
+ modified_paths.concat(Array(commit["modified"]))
29
+ removed_paths.concat(Array(commit["removed"]))
30
+ end
31
+
32
+ # Only care about .md files
33
+ added_paths = added_paths.uniq.select { |p| p.end_with?(".md") }
34
+ modified_paths = modified_paths.uniq.select { |p| p.end_with?(".md") }
35
+ removed_paths = removed_paths.uniq.select { |p| p.end_with?(".md") }
36
+
37
+ # Remove from modified if also in added (new file)
38
+ modified_paths -= added_paths
39
+
40
+ return if added_paths.empty? && modified_paths.empty? && removed_paths.empty?
41
+
42
+ branch = @link.markdown_sync_branch
43
+
44
+ # Pre-fetch tree once for SHA lookups (avoid per-file API calls)
45
+ @tree_cache = @client.tree(@repo, branch).each_with_object({}) do |entry, h|
46
+ h[entry.path] = entry.sha
47
+ end
48
+
49
+ @synced_creatives = load_synced_creatives
50
+ processor = ContentProcessor.new(
51
+ client: @client, repo: @repo, branch: branch,
52
+ path_to_creative_map: @synced_creatives
53
+ )
54
+
55
+ created = []
56
+
57
+ removed_paths.each do |path|
58
+ creative = @synced_creatives[path]
59
+ next unless creative
60
+ creative.archive! if creative.respond_to?(:archive!)
61
+ @synced_creatives.delete(path)
62
+ end
63
+
64
+ modified_paths.each do |path|
65
+ creative = @synced_creatives[path]
66
+ next unless creative
67
+
68
+ content = @client.file_content(@repo, path, ref: branch)
69
+ next if content.blank?
70
+
71
+ source = creative.data["source"].merge(
72
+ "markdown" => content,
73
+ "sha" => @tree_cache[path]
74
+ )
75
+ source.delete("rendered_html")
76
+ creative.skip_github_validation = true
77
+ creative.update!(data: creative.data.merge("source" => source))
78
+
79
+ processed, blobs = processor.process(content, path)
80
+ update_content_comment(creative, processed, blobs)
81
+ end
82
+
83
+ added_paths.each do |path|
84
+ next if @synced_creatives[path] # idempotency: skip if already synced (redelivery/retry)
85
+
86
+ parts = path.split("/")
87
+ parts.pop
88
+
89
+ parent, new_dirs = ensure_parent_directories(parts, root)
90
+ created.concat(new_dirs)
91
+ content = @client.file_content(@repo, path, ref: branch)
92
+ next if content.blank?
93
+
94
+ filename = path.split("/").last
95
+ creative = Collavre::Creative.new(
96
+ description: filename,
97
+ parent: parent,
98
+ user: @user,
99
+ data: {
100
+ "source" => {
101
+ "type" => "github_markdown",
102
+ "repo" => @repo,
103
+ "path" => path,
104
+ "sha" => @tree_cache[path],
105
+ "markdown" => content,
106
+ "repository_link_id" => @link.id
107
+ }
108
+ }
109
+ )
110
+ creative.skip_github_validation = true
111
+ creative.save!
112
+ @synced_creatives[path] = creative
113
+
114
+ processed, blobs = processor.process(content, path)
115
+ comment = create_content_comment(creative, processed)
116
+ attach_blobs(comment, blobs) if blobs.any?
117
+ created << creative
118
+ end
119
+
120
+ resequence_affected_parents(created)
121
+ @link.update!(last_synced_at: Time.current)
122
+ Collavre::Creative::RealtimeBroadcastable.broadcast_batch_created(created) if created.any?
123
+ end
124
+
125
+ private
126
+
127
+ def target_branch_push?
128
+ ref = @payload["ref"]
129
+ branch = ref&.sub("refs/heads/", "")
130
+ branch == @link.markdown_sync_branch
131
+ end
132
+
133
+ # Load all synced creatives for this repository link, indexed by path
134
+ def load_synced_creatives
135
+ scope = Collavre::Creative.where(archived_at: nil)
136
+
137
+ if scope.connection.adapter_name == "PostgreSQL"
138
+ scope = scope.where("(data->'source'->>'repository_link_id')::integer = ?", @link.id)
139
+ else
140
+ scope = scope.where("json_extract(data, '$.source.repository_link_id') = ?", @link.id)
141
+ end
142
+
143
+ scope.each_with_object({}) { |c, h| h[c.data.dig("source", "path")] = c }
144
+ end
145
+
146
+ def create_content_comment(creative, markdown_content)
147
+ topic = creative.content_topic(fallback_user: @user)
148
+ creative.comments.create!(
149
+ content: markdown_content,
150
+ topic: topic,
151
+ user: @user,
152
+ skip_dispatch: true
153
+ )
154
+ end
155
+
156
+ def update_content_comment(creative, markdown_content, blobs = [])
157
+ topic = creative.content_topic(fallback_user: @user)
158
+ comment = creative.comments.where(topic: topic).order(:created_at).first
159
+ if comment
160
+ comment.images.purge if comment.images.attached?
161
+ comment.update!(content: markdown_content)
162
+ attach_blobs(comment, blobs) if blobs.any?
163
+ else
164
+ comment = create_content_comment(creative, markdown_content)
165
+ attach_blobs(comment, blobs) if blobs.any?
166
+ end
167
+ end
168
+
169
+ def attach_blobs(comment, blobs)
170
+ blobs.each { |blob| comment.images.attach(blob) }
171
+ end
172
+
173
+ def resequence_affected_parents(new_creatives)
174
+ parent_ids = new_creatives.map(&:parent_id).compact.uniq
175
+ Collavre::Creative.transaction do
176
+ parent_ids.each do |pid|
177
+ siblings = Collavre::Creative.where(parent_id: pid, archived_at: nil)
178
+ .select(:id, :data, :description, :sequence)
179
+ sorted = siblings.sort_by do |c|
180
+ path = c.data&.dig("source", "path") || ""
181
+ is_dir = path.end_with?("/") || path == ""
182
+ [ is_dir ? 0 : 1, c.description.to_s.downcase ]
183
+ end
184
+ sorted.each_with_index do |c, idx|
185
+ c.update_column(:sequence, idx) if c.sequence != idx
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ # Returns [parent_creative, newly_created_directories]
192
+ def ensure_parent_directories(parts, root)
193
+ parent = root
194
+ current_path = ""
195
+ new_dirs = []
196
+
197
+ parts.each do |part|
198
+ current_path = current_path.empty? ? part : "#{current_path}/#{part}"
199
+ dir_path = "#{current_path}/"
200
+
201
+ existing = @synced_creatives[dir_path]
202
+
203
+ if existing
204
+ parent = existing
205
+ else
206
+ dir_creative = Collavre::Creative.new(
207
+ description: part,
208
+ parent: parent,
209
+ user: @user,
210
+ data: {
211
+ "source" => {
212
+ "type" => "github_markdown",
213
+ "repo" => @repo,
214
+ "path" => dir_path,
215
+ "repository_link_id" => @link.id
216
+ }
217
+ }
218
+ )
219
+ dir_creative.skip_github_validation = true
220
+ dir_creative.save!
221
+ @synced_creatives[dir_path] = dir_creative
222
+ new_dirs << dir_creative
223
+ parent = dir_creative
224
+ end
225
+ end
226
+
227
+ [ parent, new_dirs ]
228
+ end
229
+ end
230
+ end
231
+ end