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.
- checksums.yaml +4 -4
- data/app/controllers/collavre_github/creatives/integrations_controller.rb +61 -12
- data/app/controllers/collavre_github/webhooks_controller.rb +25 -4
- data/app/javascript/collavre_github.js +706 -0
- data/app/jobs/collavre_github/initial_markdown_sync_job.rb +19 -0
- data/app/jobs/collavre_github/markdown_sync_job.rb +16 -0
- data/app/models/collavre_github/repository_link.rb +7 -0
- data/app/services/collavre_github/client.rb +50 -0
- data/app/services/collavre_github/markdown_sync/content_processor.rb +113 -0
- data/app/services/collavre_github/markdown_sync/incremental_sync_service.rb +231 -0
- data/app/services/collavre_github/markdown_sync/initial_import_service.rb +190 -0
- data/app/services/collavre_github/tools/concerns/github_client_finder.rb +17 -0
- data/app/services/collavre_github/tools/github_pr_commits_service.rb +7 -10
- data/app/services/collavre_github/tools/github_pr_details_service.rb +21 -24
- data/app/services/collavre_github/tools/github_pr_diff_service.rb +13 -16
- data/app/services/collavre_github/webhook_provisioner.rb +18 -3
- data/app/views/collavre_github/auth/setup.html.erb +48 -3
- data/app/views/collavre_github/integrations/_modal.html.erb +18 -3
- data/config/locales/en.yml +9 -0
- data/config/locales/ko.yml +9 -0
- data/config/routes.rb +3 -1
- data/db/migrate/20260412000000_add_markdown_sync_to_repository_links.rb +10 -0
- data/db/migrate/20260416000000_add_markdown_root_creative_index.rb +5 -0
- data/lib/collavre_github/version.rb +1 -1
- data/lib/tasks/mock_import.rake +98 -0
- 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
|
+
""
|
|
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
|