collavre 0.2.5 → 0.3.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/channels/collavre/comments_presence_channel.rb +6 -3
- data/app/controllers/collavre/admin/orchestration_controller.rb +177 -0
- data/app/controllers/collavre/comments_controller.rb +10 -1
- data/app/javascript/controllers/comments/presence_controller.js +5 -1
- data/app/jobs/collavre/ai_agent_job.rb +55 -4
- data/app/jobs/collavre/cron_action_job.rb +53 -0
- data/app/jobs/collavre/stuck_detector_job.rb +34 -0
- data/app/models/collavre/comment.rb +15 -15
- data/app/models/collavre/creative.rb +1 -1
- data/app/models/collavre/orchestrator_policy.rb +124 -0
- data/app/models/collavre/task.rb +3 -0
- data/app/models/collavre/user.rb +1 -1
- data/app/services/collavre/ai_agent_service.rb +32 -1
- data/app/services/collavre/ai_client.rb +4 -3
- data/app/services/collavre/comments/command_processor.rb +4 -1
- data/app/services/collavre/comments/topic_command.rb +101 -0
- data/app/services/collavre/orchestration/agent_context_builder.rb +201 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +142 -0
- data/app/services/collavre/orchestration/arbiter.rb +257 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +294 -0
- data/app/services/collavre/orchestration/matcher.rb +98 -0
- data/app/services/collavre/orchestration/policy_resolver.rb +170 -0
- data/app/services/collavre/orchestration/resource_tracker.rb +135 -0
- data/app/services/collavre/orchestration/scheduler.rb +145 -0
- data/app/services/collavre/orchestration/self_reflection_evaluator.rb +231 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +303 -0
- data/app/services/collavre/system_events/context_builder.rb +34 -7
- data/app/services/collavre/system_events/dispatcher.rb +2 -7
- data/app/services/collavre/tools/cron_cancel_service.rb +49 -0
- data/app/services/collavre/tools/cron_create_service.rb +73 -0
- data/app/services/collavre/tools/cron_list_service.rb +65 -0
- data/app/services/collavre/tools/cron_update_service.rb +82 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/orchestration/show.html.erb +66 -0
- data/app/views/collavre/creatives/index.html.erb +1 -1
- data/config/locales/ai_agent.en.yml +21 -0
- data/config/locales/ai_agent.ko.yml +21 -0
- data/config/locales/comments.en.yml +8 -0
- data/config/locales/comments.ko.yml +8 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20260206005035_create_orchestrator_policies.rb +32 -0
- data/db/migrate/20260206094509_add_retry_count_to_tasks.rb +5 -0
- data/db/migrate/20260206100000_add_topic_id_to_tasks.rb +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +24 -13
- data/app/controllers/collavre/github_auth_controller.rb +0 -25
- data/app/models/collavre/github_account.rb +0 -10
- data/app/models/collavre/github_repository_link.rb +0 -19
- data/app/services/collavre/github/client.rb +0 -112
- data/app/services/collavre/github/pull_request_analyzer.rb +0 -280
- data/app/services/collavre/github/pull_request_processor.rb +0 -181
- data/app/services/collavre/github/webhook_provisioner.rb +0 -130
- data/app/services/collavre/system_events/router.rb +0 -96
- data/app/views/collavre/creatives/_github_integration_modal.html.erb +0 -77
- data/db/migrate/20250925000000_create_github_integrations.rb +0 -26
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +0 -29
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +0 -5
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
module Collavre
|
|
2
|
-
require "json"
|
|
3
|
-
|
|
4
|
-
module Github
|
|
5
|
-
class PullRequestAnalyzer
|
|
6
|
-
Result = Struct.new(:completed, :additional, :raw_response, :prompt, keyword_init: true)
|
|
7
|
-
CompletedTask = Struct.new(:creative_id, :progress, :note, :path, keyword_init: true)
|
|
8
|
-
SuggestedTask = Struct.new(:parent_id, :description, :progress, :note, :path, keyword_init: true)
|
|
9
|
-
|
|
10
|
-
DIFF_MAX_LENGTH = 10_000
|
|
11
|
-
|
|
12
|
-
def initialize(payload:, creative:, paths:, commit_messages: [], diff: nil, client: default_client, logger: Rails.logger)
|
|
13
|
-
@payload = payload
|
|
14
|
-
@creative = creative
|
|
15
|
-
@paths = normalize_paths(paths)
|
|
16
|
-
@commit_messages = Array(commit_messages)
|
|
17
|
-
@diff = diff
|
|
18
|
-
@client = client
|
|
19
|
-
@logger = logger
|
|
20
|
-
@prompt_text = nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call
|
|
24
|
-
response_text = collect_response
|
|
25
|
-
return unless response_text.present?
|
|
26
|
-
|
|
27
|
-
parsed = parse_response(response_text)
|
|
28
|
-
Result.new(
|
|
29
|
-
completed: parsed[:completed],
|
|
30
|
-
additional: parsed[:additional],
|
|
31
|
-
raw_response: response_text,
|
|
32
|
-
prompt: prompt_text
|
|
33
|
-
)
|
|
34
|
-
rescue StandardError => e
|
|
35
|
-
logger.error("Gemini analysis failed: #{e.class} #{e.message}")
|
|
36
|
-
nil
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
private
|
|
40
|
-
|
|
41
|
-
attr_reader :payload, :creative, :paths, :commit_messages, :diff, :client, :logger, :prompt_text
|
|
42
|
-
|
|
43
|
-
def collect_response
|
|
44
|
-
messages = build_messages
|
|
45
|
-
buffer = +""
|
|
46
|
-
client.chat(messages) do |delta|
|
|
47
|
-
buffer << delta.to_s
|
|
48
|
-
end
|
|
49
|
-
buffer
|
|
50
|
-
rescue StandardError => e
|
|
51
|
-
logger.error("Gemini chat failed: #{e.class} #{e.message}")
|
|
52
|
-
nil
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def default_client
|
|
56
|
-
AiClient.new(
|
|
57
|
-
vendor: "google",
|
|
58
|
-
model: "gemini-2.5-flash",
|
|
59
|
-
system_prompt: AiClient::SYSTEM_INSTRUCTIONS
|
|
60
|
-
)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def build_messages
|
|
64
|
-
pr = payload["pull_request"]
|
|
65
|
-
|
|
66
|
-
# Use shared TreeFormatter logic
|
|
67
|
-
tree_lines = Creatives::TreeFormatter.new.format(creative)
|
|
68
|
-
|
|
69
|
-
pr_body = pr["body"].to_s
|
|
70
|
-
commit_lines = formatted_commit_messages
|
|
71
|
-
diff_text = formatted_diff
|
|
72
|
-
language_instructions = preferred_language_instructions
|
|
73
|
-
|
|
74
|
-
prompt_template = creative.github_gemini_prompt_template
|
|
75
|
-
prompt = render_prompt_template(
|
|
76
|
-
prompt_template,
|
|
77
|
-
pr_title: pr["title"].to_s,
|
|
78
|
-
pr_body: pr_body,
|
|
79
|
-
commit_messages: commit_lines,
|
|
80
|
-
diff: diff_text,
|
|
81
|
-
creative_tree: tree_lines,
|
|
82
|
-
language_instructions: language_instructions
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
@prompt_text = prompt
|
|
86
|
-
[ { role: "user", parts: [ { text: prompt } ] } ]
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def normalize_paths(paths)
|
|
90
|
-
Array(paths).map do |entry|
|
|
91
|
-
case entry
|
|
92
|
-
when Hash
|
|
93
|
-
path_value = entry[:path]
|
|
94
|
-
path_value = entry["path"] if path_value.nil?
|
|
95
|
-
|
|
96
|
-
leaf_value =
|
|
97
|
-
if entry.key?(:leaf)
|
|
98
|
-
entry[:leaf]
|
|
99
|
-
elsif entry.key?("leaf")
|
|
100
|
-
entry["leaf"]
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
path: path_value.to_s,
|
|
105
|
-
leaf: !!leaf_value
|
|
106
|
-
}
|
|
107
|
-
else
|
|
108
|
-
{ path: entry.to_s, leaf: false }
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def parse_response(text)
|
|
114
|
-
json_fragment = extract_json(text)
|
|
115
|
-
data = JSON.parse(json_fragment)
|
|
116
|
-
{
|
|
117
|
-
completed: sanitize_completed(data["completed"]),
|
|
118
|
-
additional: sanitize_additional(data["additional"])
|
|
119
|
-
}
|
|
120
|
-
rescue JSON::ParserError => e
|
|
121
|
-
logger.warn("Failed to parse Gemini response as JSON: #{e.message}")
|
|
122
|
-
{ completed: [], additional: [] }
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def extract_json(text)
|
|
126
|
-
start_index = text.index("{")
|
|
127
|
-
end_index = text.rindex("}")
|
|
128
|
-
return text unless start_index && end_index && end_index >= start_index
|
|
129
|
-
|
|
130
|
-
text[start_index..end_index]
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def formatted_commit_messages
|
|
134
|
-
return "No commit messages available." if commit_messages.blank?
|
|
135
|
-
|
|
136
|
-
commit_messages.map.with_index(1) do |message, index|
|
|
137
|
-
"#{index}. #{message.to_s.strip}"
|
|
138
|
-
end.join("\n")
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def formatted_diff
|
|
142
|
-
return "(No diff available)" if diff.blank?
|
|
143
|
-
|
|
144
|
-
diff_text = diff.to_s.strip
|
|
145
|
-
return "(No diff available)" if diff_text.empty?
|
|
146
|
-
|
|
147
|
-
return diff_text if diff_text.length <= DIFF_MAX_LENGTH
|
|
148
|
-
|
|
149
|
-
truncated = diff_text.slice(0, DIFF_MAX_LENGTH)
|
|
150
|
-
"#{truncated}\n...\n[Diff truncated to #{DIFF_MAX_LENGTH} characters]"
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def preferred_language_instructions
|
|
154
|
-
language = preferred_response_language
|
|
155
|
-
"Preferred response language: #{language[:label]} (#{language[:code]}). Write all natural-language output, including new creative descriptions, in #{language[:label]}."
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def preferred_response_language
|
|
159
|
-
locale = creative.user&.locale.presence
|
|
160
|
-
locale ||= I18n.default_locale.to_s if defined?(I18n)
|
|
161
|
-
locale ||= "en"
|
|
162
|
-
|
|
163
|
-
label = if defined?(I18n)
|
|
164
|
-
I18n.t("collavre.users.locales.#{locale}", default: locale)
|
|
165
|
-
else
|
|
166
|
-
locale
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
{ code: locale, label: label }
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def render_prompt_template(template, variables)
|
|
173
|
-
template.to_s.gsub(/\#\{([^}]+)\}/) do
|
|
174
|
-
key = Regexp.last_match(1).strip.to_sym
|
|
175
|
-
value = variables.fetch(key, "")
|
|
176
|
-
value.to_s
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def sanitize_completed(items)
|
|
181
|
-
Array(items).filter_map do |item|
|
|
182
|
-
case item
|
|
183
|
-
when Hash
|
|
184
|
-
creative_id = extract_creative_id(item["creative_id"] || item["id"])
|
|
185
|
-
next unless creative_id
|
|
186
|
-
|
|
187
|
-
CompletedTask.new(
|
|
188
|
-
creative_id: creative_id,
|
|
189
|
-
progress: normalize_progress(item["progress"], default: 1.0),
|
|
190
|
-
note: string_presence(item["note"] || item["summary"]),
|
|
191
|
-
path: string_presence(item["path"] || item["description"])
|
|
192
|
-
)
|
|
193
|
-
when Integer
|
|
194
|
-
CompletedTask.new(creative_id: item, progress: 1.0)
|
|
195
|
-
when String
|
|
196
|
-
creative_id = extract_id_from_string(item)
|
|
197
|
-
next unless creative_id
|
|
198
|
-
|
|
199
|
-
CompletedTask.new(
|
|
200
|
-
creative_id: creative_id,
|
|
201
|
-
progress: 1.0,
|
|
202
|
-
path: string_presence(item)
|
|
203
|
-
)
|
|
204
|
-
else
|
|
205
|
-
nil
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def sanitize_additional(items)
|
|
211
|
-
Array(items).filter_map do |item|
|
|
212
|
-
case item
|
|
213
|
-
when Hash
|
|
214
|
-
parent_id = extract_creative_id(item["parent_id"] || item["parent"])
|
|
215
|
-
description = string_presence(item["description"] || item["title"])
|
|
216
|
-
next unless parent_id && description
|
|
217
|
-
|
|
218
|
-
SuggestedTask.new(
|
|
219
|
-
parent_id: parent_id,
|
|
220
|
-
description: description,
|
|
221
|
-
progress: normalize_progress(item["progress"], default: nil),
|
|
222
|
-
note: string_presence(item["note"] || item["summary"]),
|
|
223
|
-
path: string_presence(item["path"])
|
|
224
|
-
)
|
|
225
|
-
when String
|
|
226
|
-
parent_id = extract_id_from_string(item)
|
|
227
|
-
next unless parent_id
|
|
228
|
-
|
|
229
|
-
description = item.sub(/^.*\]\s*/, "").strip
|
|
230
|
-
description = item if description.blank?
|
|
231
|
-
|
|
232
|
-
SuggestedTask.new(parent_id: parent_id, description: description, progress: nil)
|
|
233
|
-
else
|
|
234
|
-
nil
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def extract_creative_id(value)
|
|
240
|
-
case value
|
|
241
|
-
when Integer
|
|
242
|
-
value.positive? ? value : nil
|
|
243
|
-
when Float
|
|
244
|
-
int_value = value.to_i
|
|
245
|
-
int_value.positive? ? int_value : nil
|
|
246
|
-
when String
|
|
247
|
-
match = value.match(/\d+/)
|
|
248
|
-
match ? match[0].to_i : nil
|
|
249
|
-
else
|
|
250
|
-
nil
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def normalize_progress(value, default: nil)
|
|
255
|
-
return default if value.nil?
|
|
256
|
-
|
|
257
|
-
float_value = Float(value) rescue nil
|
|
258
|
-
return default unless float_value
|
|
259
|
-
|
|
260
|
-
[ [ float_value, 0.0 ].max, 1.0 ].min
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def string_presence(value)
|
|
264
|
-
return if value.nil?
|
|
265
|
-
|
|
266
|
-
str = value.to_s.strip
|
|
267
|
-
str.presence
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def extract_id_from_string(value)
|
|
271
|
-
return unless value.is_a?(String)
|
|
272
|
-
|
|
273
|
-
matches = value.scan(/\[(\d+)\]/).flatten
|
|
274
|
-
return if matches.blank?
|
|
275
|
-
|
|
276
|
-
matches.last.to_i
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
end
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
module Collavre
|
|
2
|
-
require "json"
|
|
3
|
-
|
|
4
|
-
module Github
|
|
5
|
-
class PullRequestProcessor
|
|
6
|
-
HANDLED_ACTIONS = %w[closed].freeze
|
|
7
|
-
|
|
8
|
-
def initialize(payload:, logger: Rails.logger)
|
|
9
|
-
@payload = payload
|
|
10
|
-
@logger = logger
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def call
|
|
14
|
-
action = payload["action"]
|
|
15
|
-
return unless HANDLED_ACTIONS.include?(action)
|
|
16
|
-
|
|
17
|
-
pr = payload["pull_request"]
|
|
18
|
-
return unless pr
|
|
19
|
-
return unless pr["merged"]
|
|
20
|
-
|
|
21
|
-
repo_full_name = payload.dig("repository", "full_name")
|
|
22
|
-
return unless repo_full_name
|
|
23
|
-
|
|
24
|
-
links = GithubRepositoryLink.includes(:creative).where(repository_full_name: repo_full_name)
|
|
25
|
-
return if links.blank?
|
|
26
|
-
|
|
27
|
-
links.each do |link|
|
|
28
|
-
process_link(link)
|
|
29
|
-
end
|
|
30
|
-
rescue StandardError => e
|
|
31
|
-
logger.error("GitHub PR processing failed: #{e.class} #{e.message}")
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
attr_reader :payload, :logger
|
|
37
|
-
|
|
38
|
-
def process_link(link)
|
|
39
|
-
creative = link.creative.effective_origin
|
|
40
|
-
path_exporter = Creatives::PathExporter.new(creative, use_effective_origin: false)
|
|
41
|
-
tree_entries = path_exporter.full_paths_with_ids_and_progress_with_leaf
|
|
42
|
-
return if tree_entries.blank?
|
|
43
|
-
|
|
44
|
-
repo_full_name = payload.dig("repository", "full_name")
|
|
45
|
-
pr = payload["pull_request"]
|
|
46
|
-
client = Github::Client.new(link.github_account)
|
|
47
|
-
commit_messages = client.pull_request_commit_messages(repo_full_name, pr["number"])
|
|
48
|
-
diff = client.pull_request_diff(repo_full_name, pr["number"])
|
|
49
|
-
|
|
50
|
-
analyzer = Github::PullRequestAnalyzer.new(
|
|
51
|
-
payload: payload,
|
|
52
|
-
creative: creative,
|
|
53
|
-
paths: tree_entries,
|
|
54
|
-
commit_messages: commit_messages,
|
|
55
|
-
diff: diff
|
|
56
|
-
)
|
|
57
|
-
result = analyzer.call
|
|
58
|
-
return unless result
|
|
59
|
-
|
|
60
|
-
create_comment(creative, link, path_exporter, result)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def create_comment(creative, link, path_exporter, result)
|
|
64
|
-
pr = payload["pull_request"]
|
|
65
|
-
title = pr["title"]
|
|
66
|
-
number = pr["number"]
|
|
67
|
-
url = pr["html_url"]
|
|
68
|
-
|
|
69
|
-
lines = []
|
|
70
|
-
lines << "### Github PR 분석"
|
|
71
|
-
lines << "- PR: [##{number} #{title}](#{url})"
|
|
72
|
-
lines << ""
|
|
73
|
-
lines << "#### 완료된 Creative"
|
|
74
|
-
if result.completed.any?
|
|
75
|
-
result.completed.each do |task|
|
|
76
|
-
lines << "- #{format_completed_task(task, path_exporter)}"
|
|
77
|
-
end
|
|
78
|
-
else
|
|
79
|
-
lines << "- 없음"
|
|
80
|
-
end
|
|
81
|
-
lines << ""
|
|
82
|
-
lines << "#### 추가로 필요한 Creative"
|
|
83
|
-
if result.additional.any?
|
|
84
|
-
result.additional.each do |suggestion|
|
|
85
|
-
lines << "- #{format_suggestion(suggestion, path_exporter)}"
|
|
86
|
-
end
|
|
87
|
-
else
|
|
88
|
-
lines << "- 없음"
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
actions = build_actions(result)
|
|
92
|
-
if actions.any?
|
|
93
|
-
lines << ""
|
|
94
|
-
lines << "#### 승인 시 자동 적용"
|
|
95
|
-
lines << "- 이 댓글을 승인하면 완료된 Creative의 진행률이 업데이트되고 제안된 Creative가 생성됩니다."
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
attributes = { user: nil, content: lines.join("\n") }
|
|
99
|
-
if actions.any?
|
|
100
|
-
approver = link.github_account.user
|
|
101
|
-
if approver
|
|
102
|
-
attributes[:action] = JSON.pretty_generate({ actions: actions })
|
|
103
|
-
attributes[:approver] = approver
|
|
104
|
-
else
|
|
105
|
-
logger.warn("Skipping action assignment for PR comment because approver is missing")
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
creative.comments.create!(attributes)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def build_actions(result)
|
|
113
|
-
actions = []
|
|
114
|
-
|
|
115
|
-
result.completed.each do |task|
|
|
116
|
-
next unless task.creative_id
|
|
117
|
-
|
|
118
|
-
actions << {
|
|
119
|
-
"action" => "update_creative",
|
|
120
|
-
"creative_id" => task.creative_id,
|
|
121
|
-
"attributes" => {
|
|
122
|
-
"progress" => (task.progress || 1.0).to_f
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
result.additional.each do |suggestion|
|
|
128
|
-
next unless suggestion.parent_id && suggestion.description.present?
|
|
129
|
-
|
|
130
|
-
attributes = { "description" => suggestion.description }
|
|
131
|
-
attributes["progress"] = suggestion.progress.to_f if suggestion.progress
|
|
132
|
-
|
|
133
|
-
actions << {
|
|
134
|
-
"action" => "create_creative",
|
|
135
|
-
"parent_id" => suggestion.parent_id,
|
|
136
|
-
"attributes" => attributes
|
|
137
|
-
}
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
actions
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def format_completed_task(task, path_exporter)
|
|
144
|
-
label = task.path.presence || path_exporter.path_for(task.creative_id) || "Creative ##{task.creative_id}"
|
|
145
|
-
parts = []
|
|
146
|
-
parts << "#{creative_link(task.creative_id)} #{label}"
|
|
147
|
-
if task.progress && task.progress < 1.0
|
|
148
|
-
percentage = (task.progress * 100).round
|
|
149
|
-
parts << "(progress #{percentage}%)"
|
|
150
|
-
end
|
|
151
|
-
parts << task.note if task.note.present?
|
|
152
|
-
parts.join(" ")
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def format_suggestion(suggestion, path_exporter)
|
|
156
|
-
parent_label = path_exporter.path_for(suggestion.parent_id) || "Creative ##{suggestion.parent_id}"
|
|
157
|
-
parts = []
|
|
158
|
-
parts << "#{creative_link(suggestion.parent_id)} #{parent_label}"
|
|
159
|
-
parts << "→ #{suggestion.description}"
|
|
160
|
-
if suggestion.progress
|
|
161
|
-
percentage = (suggestion.progress * 100).round
|
|
162
|
-
parts << "(initial progress #{percentage}%)"
|
|
163
|
-
end
|
|
164
|
-
parts << "- #{suggestion.note}" if suggestion.note.present?
|
|
165
|
-
parts.join(" ")
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def creative_link(id)
|
|
169
|
-
path = url_helpers.creative_path(id)
|
|
170
|
-
"[#%<id>d](%<path>s)" % { id: id, path: path }
|
|
171
|
-
rescue StandardError => e
|
|
172
|
-
logger.warn("Failed to build creative link for ##{id}: #{e.message}")
|
|
173
|
-
"##{id}"
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def url_helpers
|
|
177
|
-
@url_helpers ||= Collavre::Engine.routes.url_helpers
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
end
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
module Collavre
|
|
2
|
-
module Github
|
|
3
|
-
class WebhookProvisioner
|
|
4
|
-
EVENTS = %w[pull_request].freeze
|
|
5
|
-
CONTENT_TYPE = "json".freeze
|
|
6
|
-
|
|
7
|
-
def self.ensure_for_links(account:, links:, webhook_url:)
|
|
8
|
-
new(account: account, webhook_url: webhook_url).ensure_for_links(Array(links))
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def self.remove_for_repositories(account:, repositories:, webhook_url:)
|
|
12
|
-
new(account: account, webhook_url: webhook_url).remove_for_repositories(Array(repositories))
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def initialize(account:, webhook_url:, client: Github::Client.new(account))
|
|
16
|
-
@client = client
|
|
17
|
-
@webhook_url = webhook_url
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def ensure_for_links(links)
|
|
21
|
-
links.each do |link|
|
|
22
|
-
ensure_webhook(link)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def remove_for_repositories(repositories)
|
|
27
|
-
repositories.each do |repository_full_name|
|
|
28
|
-
next if GithubRepositoryLink.where(repository_full_name: repository_full_name).exists?
|
|
29
|
-
|
|
30
|
-
remove_webhook(repository_full_name)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
attr_reader :client, :webhook_url
|
|
37
|
-
|
|
38
|
-
def ensure_webhook(link)
|
|
39
|
-
repository_full_name = link.repository_full_name
|
|
40
|
-
primary_link = primary_link_for(repository_full_name)
|
|
41
|
-
hook = find_existing_hook(repository_full_name)
|
|
42
|
-
|
|
43
|
-
if hook
|
|
44
|
-
if primary_link && primary_link != link
|
|
45
|
-
align_link_secret(link, primary_link.webhook_secret)
|
|
46
|
-
else
|
|
47
|
-
update_webhook(repository_full_name, hook.id, link.webhook_secret)
|
|
48
|
-
end
|
|
49
|
-
else
|
|
50
|
-
secret = link.webhook_secret
|
|
51
|
-
|
|
52
|
-
if primary_link && primary_link != link
|
|
53
|
-
secret = primary_link.webhook_secret
|
|
54
|
-
align_link_secret(link, secret)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
create_webhook(repository_full_name, secret)
|
|
58
|
-
end
|
|
59
|
-
rescue Octokit::Error => e
|
|
60
|
-
Rails.logger.warn(
|
|
61
|
-
"GitHub webhook provisioning failed for #{repository_full_name}: #{e.message}"
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def remove_webhook(repository_full_name)
|
|
66
|
-
hook = find_existing_hook(repository_full_name)
|
|
67
|
-
return unless hook
|
|
68
|
-
|
|
69
|
-
client.delete_repository_webhook(repository_full_name, hook.id)
|
|
70
|
-
rescue Octokit::Error => e
|
|
71
|
-
Rails.logger.warn(
|
|
72
|
-
"GitHub webhook removal failed for #{repository_full_name}: #{e.message}"
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def find_existing_hook(repository_full_name)
|
|
77
|
-
client.repository_hooks(repository_full_name).find do |hook|
|
|
78
|
-
config = normalize_config(hook.config)
|
|
79
|
-
config["url"] == webhook_url
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def create_webhook(repository_full_name, secret)
|
|
84
|
-
client.create_repository_webhook(
|
|
85
|
-
repository_full_name,
|
|
86
|
-
url: webhook_url,
|
|
87
|
-
secret: secret,
|
|
88
|
-
events: EVENTS,
|
|
89
|
-
content_type: CONTENT_TYPE
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def update_webhook(repository_full_name, hook_id, secret)
|
|
94
|
-
client.update_repository_webhook(
|
|
95
|
-
repository_full_name,
|
|
96
|
-
hook_id,
|
|
97
|
-
url: webhook_url,
|
|
98
|
-
secret: secret,
|
|
99
|
-
events: EVENTS,
|
|
100
|
-
content_type: CONTENT_TYPE
|
|
101
|
-
)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def primary_link_for(repository_full_name)
|
|
105
|
-
GithubRepositoryLink
|
|
106
|
-
.where(repository_full_name: repository_full_name)
|
|
107
|
-
.order(:id)
|
|
108
|
-
.first
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def align_link_secret(link, secret)
|
|
112
|
-
return if secret.blank? || link.webhook_secret == secret
|
|
113
|
-
|
|
114
|
-
link.update!(webhook_secret: secret)
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def normalize_config(config)
|
|
118
|
-
hash =
|
|
119
|
-
case config
|
|
120
|
-
when Hash
|
|
121
|
-
config
|
|
122
|
-
else
|
|
123
|
-
config.respond_to?(:to_h) ? config.to_h : {}
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
hash.with_indifferent_access
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
module Collavre
|
|
2
|
-
module SystemEvents
|
|
3
|
-
class Router
|
|
4
|
-
def route(event_name, context)
|
|
5
|
-
# Build the context for Liquid
|
|
6
|
-
liquid_context = Collavre::SystemEvents::ContextBuilder.new(context).build
|
|
7
|
-
liquid_context["event_name"] = event_name
|
|
8
|
-
|
|
9
|
-
# Priority 1: Mention-based routing (exclusive)
|
|
10
|
-
# If any user is mentioned, mention routing takes over exclusively.
|
|
11
|
-
# - Mentioned AI agent → route only to that agent
|
|
12
|
-
# - Mentioned non-AI user → route to nobody (no AI agents)
|
|
13
|
-
mentioned_result = find_mentioned_routing(liquid_context, context)
|
|
14
|
-
return mentioned_result unless mentioned_result.nil?
|
|
15
|
-
|
|
16
|
-
# Priority 2: Liquid expression routing (fallback, only when no mention)
|
|
17
|
-
route_by_liquid_expression(liquid_context, context)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
# Returns Array of agents to route to, or nil if no mention was found.
|
|
23
|
-
# When a mention IS found, this is exclusive:
|
|
24
|
-
# - AI agent mentioned → [agent] (if permitted)
|
|
25
|
-
# - Non-AI user mentioned → [] (no AI agents should receive it)
|
|
26
|
-
def find_mentioned_routing(liquid_context, context)
|
|
27
|
-
mentioned_user_data = liquid_context.dig("chat", "mentioned_user")
|
|
28
|
-
return nil unless mentioned_user_data && mentioned_user_data["id"]
|
|
29
|
-
|
|
30
|
-
mentioned_user = User.find_by(id: mentioned_user_data["id"])
|
|
31
|
-
return nil unless mentioned_user
|
|
32
|
-
|
|
33
|
-
# Mention found — this is exclusive routing.
|
|
34
|
-
# If the mentioned user is not an AI agent, no AI agents should receive the message.
|
|
35
|
-
return [] unless mentioned_user.ai_user?
|
|
36
|
-
|
|
37
|
-
# Permission check for mentioned AI agent
|
|
38
|
-
return [] unless has_creative_permission?(mentioned_user, context)
|
|
39
|
-
|
|
40
|
-
[ mentioned_user ]
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def route_by_liquid_expression(liquid_context, context)
|
|
44
|
-
# Find all AI agents
|
|
45
|
-
agents = User.where.not(llm_vendor: nil)
|
|
46
|
-
|
|
47
|
-
matched_agents = []
|
|
48
|
-
|
|
49
|
-
agents.each do |agent|
|
|
50
|
-
next if agent.routing_expression.blank?
|
|
51
|
-
|
|
52
|
-
# Permission Check
|
|
53
|
-
next unless has_creative_permission?(agent, context)
|
|
54
|
-
|
|
55
|
-
begin
|
|
56
|
-
# Add 'agent' to context so they can refer to themselves
|
|
57
|
-
agent_context = liquid_context.merge("agent" => agent.as_json(only: [ :id, :name, :email ]))
|
|
58
|
-
|
|
59
|
-
# Parse and evaluate the routing expression
|
|
60
|
-
# We wrap the expression in an if block to evaluate truthiness
|
|
61
|
-
expression = agent.routing_expression.strip
|
|
62
|
-
unless expression.start_with?("{%")
|
|
63
|
-
expression = "{% if #{expression} %}true{% endif %}"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
template = Liquid::Template.parse(expression)
|
|
67
|
-
result = template.render(agent_context)
|
|
68
|
-
|
|
69
|
-
# Check if the result evaluates to "true" string or boolean true
|
|
70
|
-
if result.strip == "true"
|
|
71
|
-
matched_agents << agent
|
|
72
|
-
end
|
|
73
|
-
rescue StandardError => e
|
|
74
|
-
Rails.logger.error("Routing error for agent #{agent.id}: #{e.message}")
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
matched_agents
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def has_creative_permission?(agent, context)
|
|
82
|
-
# Searchable agents can receive any routed message
|
|
83
|
-
return true if agent.searchable?
|
|
84
|
-
|
|
85
|
-
# Non-searchable agents must have feedback permission on the creative
|
|
86
|
-
creative_id = context.dig("creative", "id") || context.dig(:creative, :id)
|
|
87
|
-
return false unless creative_id
|
|
88
|
-
|
|
89
|
-
creative = Creative.find_by(id: creative_id)
|
|
90
|
-
return false unless creative
|
|
91
|
-
|
|
92
|
-
creative.has_permission?(agent, :feedback)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|