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.
- 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/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 +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8f3ffe59f1a7e85a1a42fd49cd5fa3ee1ad40667996a69e405fef0bb8415318
|
|
4
|
+
data.tar.gz: 5a9c05bf70a341ca56253bfa2d6a2849acc335038e42c1b9919747be2d564f7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
26
|
-
creative =
|
|
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
|
+
""
|
|
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
|