collavre_github 0.4.1 → 0.5.1

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 (26) 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/javascript/collavre_github.js +706 -0
  5. data/app/jobs/collavre_github/initial_markdown_sync_job.rb +19 -0
  6. data/app/jobs/collavre_github/markdown_sync_job.rb +16 -0
  7. data/app/models/collavre_github/repository_link.rb +7 -0
  8. data/app/services/collavre_github/client.rb +50 -0
  9. data/app/services/collavre_github/markdown_sync/content_processor.rb +113 -0
  10. data/app/services/collavre_github/markdown_sync/incremental_sync_service.rb +231 -0
  11. data/app/services/collavre_github/markdown_sync/initial_import_service.rb +190 -0
  12. data/app/services/collavre_github/tools/concerns/github_client_finder.rb +17 -0
  13. data/app/services/collavre_github/tools/github_pr_commits_service.rb +7 -10
  14. data/app/services/collavre_github/tools/github_pr_details_service.rb +21 -24
  15. data/app/services/collavre_github/tools/github_pr_diff_service.rb +13 -16
  16. data/app/services/collavre_github/webhook_provisioner.rb +18 -3
  17. data/app/views/collavre_github/auth/setup.html.erb +48 -3
  18. data/app/views/collavre_github/integrations/_modal.html.erb +18 -3
  19. data/config/locales/en.yml +9 -0
  20. data/config/locales/ko.yml +9 -0
  21. data/config/routes.rb +3 -1
  22. data/db/migrate/20260412000000_add_markdown_sync_to_repository_links.rb +10 -0
  23. data/db/migrate/20260416000000_add_markdown_root_creative_index.rb +5 -0
  24. data/lib/collavre_github/version.rb +1 -1
  25. data/lib/tasks/mock_import.rake +98 -0
  26. metadata +10 -1
@@ -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
@@ -0,0 +1,190 @@
1
+ module CollavreGithub
2
+ module MarkdownSync
3
+ class InitialImportService
4
+ MAX_FILES = 200
5
+
6
+ def initialize(repository_link:, user:)
7
+ @link = repository_link
8
+ @user = user
9
+ @client = CollavreGithub::Client.new(repository_link.github_account)
10
+ @repo = repository_link.repository_full_name
11
+ end
12
+
13
+ def call
14
+ branch = resolve_branch
15
+ tree_entries = @client.tree(@repo, branch)
16
+ md_entries = tree_entries.select { |e| e.type == "blob" && e.path.end_with?(".md") }
17
+
18
+ if md_entries.size > MAX_FILES
19
+ Rails.logger.warn("[MarkdownSync] #{@repo}: #{md_entries.size} .md files found, limiting to #{MAX_FILES}")
20
+ md_entries = md_entries.first(MAX_FILES)
21
+ end
22
+
23
+ # Archive existing root tree before reimport (pause/resume flow)
24
+ root = @link.markdown_root_creative
25
+ if root && root.archived_at.nil?
26
+ root.archive!
27
+ end
28
+
29
+ parent_creative = @link.creative
30
+ root_creative = create_root_creative(parent_creative)
31
+ @link.update!(markdown_root_creative_id: root_creative.id, last_synced_at: Time.current)
32
+
33
+ dir_map = { "" => root_creative }
34
+ created = [ root_creative ]
35
+ file_creatives = []
36
+
37
+ if md_entries.empty?
38
+ Collavre::Creative::RealtimeBroadcastable.broadcast_batch_created(created) if created.size > 1
39
+ return created
40
+ end
41
+
42
+ md_entries.sort_by(&:path).each do |entry|
43
+ parts = entry.path.split("/")
44
+ filename = parts.pop
45
+
46
+ parent = ensure_directories(parts, dir_map, root_creative, created)
47
+
48
+ content = @client.file_content(@repo, entry.path, ref: branch)
49
+ next if content.blank?
50
+
51
+ creative = Collavre::Creative.new(
52
+ description: filename,
53
+ parent: parent,
54
+ user: @user,
55
+ data: {
56
+ "source" => {
57
+ "type" => "github_markdown",
58
+ "repo" => @repo,
59
+ "path" => entry.path,
60
+ "sha" => entry.sha,
61
+ "markdown" => content,
62
+ "repository_link_id" => @link.id
63
+ }
64
+ }
65
+ )
66
+ creative.skip_github_validation = true
67
+ creative.save!
68
+ file_creatives << creative
69
+ created << creative
70
+ end
71
+
72
+ # Second pass: resolve relative links and images now that all creatives exist
73
+ path_map = build_path_map(created)
74
+ processor = ContentProcessor.new(client: @client, repo: @repo, branch: branch, path_to_creative_map: path_map)
75
+
76
+ file_creatives.each do |creative|
77
+ raw_md = creative.data.dig("source", "markdown")
78
+ next if raw_md.blank?
79
+
80
+ processed, blobs = processor.process(raw_md, creative.data.dig("source", "path"))
81
+ comment = create_content_comment(creative, processed)
82
+ attach_blobs(comment, blobs) if blobs.any?
83
+ end
84
+
85
+ resequence_directories_first(created)
86
+ Collavre::Creative::RealtimeBroadcastable.broadcast_batch_created(created) if created.size > 1
87
+ created
88
+ end
89
+
90
+ private
91
+
92
+ def resolve_branch
93
+ if @link.sync_branch.present?
94
+ @link.sync_branch
95
+ else
96
+ branch = @client.default_branch(@repo)
97
+ @link.update_column(:sync_branch, branch)
98
+ branch
99
+ end
100
+ end
101
+
102
+ def create_root_creative(parent)
103
+ repo_name = @repo.split("/").last
104
+ creative = Collavre::Creative.new(
105
+ description: repo_name,
106
+ parent: parent,
107
+ user: @user,
108
+ data: {
109
+ "source" => {
110
+ "type" => "github_markdown",
111
+ "repo" => @repo,
112
+ "path" => "",
113
+ "repository_link_id" => @link.id
114
+ }
115
+ }
116
+ )
117
+ creative.skip_github_validation = true
118
+ creative.save!
119
+ creative
120
+ end
121
+
122
+ def build_path_map(creatives)
123
+ creatives.each_with_object({}) do |c, map|
124
+ path = c.data&.dig("source", "path")
125
+ map[path] = c if path.present?
126
+ end
127
+ end
128
+
129
+ def create_content_comment(creative, markdown_content)
130
+ topic = creative.content_topic(fallback_user: @user)
131
+ creative.comments.create!(
132
+ content: markdown_content,
133
+ topic: topic,
134
+ user: @user,
135
+ skip_dispatch: true
136
+ )
137
+ end
138
+
139
+ def attach_blobs(comment, blobs)
140
+ blobs.each { |blob| comment.images.attach(blob) }
141
+ end
142
+
143
+ def resequence_directories_first(creatives)
144
+ Collavre::Creative.transaction do
145
+ creatives.group_by(&:parent_id).each do |_parent_id, siblings|
146
+ sorted = siblings.sort_by do |c|
147
+ path = c.data&.dig("source", "path") || ""
148
+ is_dir = path.end_with?("/") || path == ""
149
+ [ is_dir ? 0 : 1, c.description.to_s.downcase ]
150
+ end
151
+ sorted.each_with_index do |c, idx|
152
+ c.update_column(:sequence, idx)
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def ensure_directories(parts, dir_map, root, created)
159
+ current_path = ""
160
+ parent = root
161
+
162
+ parts.each do |part|
163
+ current_path = current_path.empty? ? part : "#{current_path}/#{part}"
164
+ unless dir_map[current_path]
165
+ dir_creative = Collavre::Creative.new(
166
+ description: part,
167
+ parent: parent,
168
+ user: @user,
169
+ data: {
170
+ "source" => {
171
+ "type" => "github_markdown",
172
+ "repo" => @repo,
173
+ "path" => "#{current_path}/",
174
+ "repository_link_id" => @link.id
175
+ }
176
+ }
177
+ )
178
+ dir_creative.skip_github_validation = true
179
+ dir_creative.save!
180
+ dir_map[current_path] = dir_creative
181
+ created << dir_creative
182
+ end
183
+ parent = dir_map[current_path]
184
+ end
185
+
186
+ parent
187
+ end
188
+ end
189
+ end
190
+ end
@@ -30,6 +30,23 @@ module CollavreGithub
30
30
 
31
31
  CollavreGithub::Client.new(link.github_account)
32
32
  end
33
+
34
+ # Find client or return error hash. Yields the client to the block.
35
+ # Wraps the block with a StandardError rescue that returns { error: ... }.
36
+ #
37
+ # @param creative_id [Integer]
38
+ # @param repo [String]
39
+ # @param error_context [String] human-readable label for error messages
40
+ # @yield [client] the GitHub client
41
+ # @return [Hash] the block's result or an error hash
42
+ def with_github_client(creative_id:, repo:, error_context:, &block)
43
+ client = find_github_client(creative_id, repo)
44
+ return { error: "GitHub account not found for this creative and repository" } unless client
45
+
46
+ yield client
47
+ rescue StandardError => e
48
+ { error: "Failed to #{error_context}: #{e.message}" }
49
+ end
33
50
  end
34
51
  end
35
52
  end