ace-review 0.49.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 +7 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- metadata +351 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "time"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "ace/support/fs"
|
|
9
|
+
require "ace/b36ts"
|
|
10
|
+
require "ace/bundle/atoms/bundle_normalizer"
|
|
11
|
+
|
|
12
|
+
module Ace
|
|
13
|
+
module Review
|
|
14
|
+
module Organisms
|
|
15
|
+
# Main orchestrator for code review workflow
|
|
16
|
+
class ReviewManager
|
|
17
|
+
attr_reader :preset_manager, :prompt_resolver, :prompt_composer,
|
|
18
|
+
:subject_extractor, :context_extractor
|
|
19
|
+
|
|
20
|
+
def initialize(project_root: nil)
|
|
21
|
+
@project_root = project_root
|
|
22
|
+
@preset_manager = Ace::Review::Molecules::PresetManager.new(project_root: project_root)
|
|
23
|
+
@prompt_resolver = Ace::Review::Molecules::NavPromptResolver.new
|
|
24
|
+
@prompt_composer = Ace::Review::Molecules::PromptComposer.new(resolver: @prompt_resolver)
|
|
25
|
+
@subject_extractor = Ace::Review::Molecules::SubjectExtractor.new
|
|
26
|
+
@context_extractor = Ace::Review::Molecules::ContextExtractor.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execute a code review with the given options
|
|
30
|
+
# @param options [ReviewOptions] review options object
|
|
31
|
+
# @return [Hash] review results
|
|
32
|
+
def execute_review(options)
|
|
33
|
+
# Convert to ReviewOptions if needed
|
|
34
|
+
options = ensure_review_options(options)
|
|
35
|
+
|
|
36
|
+
# Step 1: Prepare configuration
|
|
37
|
+
config_result = prepare_review_config(options)
|
|
38
|
+
return config_result unless config_result[:success]
|
|
39
|
+
|
|
40
|
+
# Step 2: Create session directory early (needed for ace-bundle)
|
|
41
|
+
cache_dir = create_cache_directory
|
|
42
|
+
session_dir = create_session_directory(options, cache_dir)
|
|
43
|
+
|
|
44
|
+
# Step 3: Extract content
|
|
45
|
+
content_result = extract_review_content(config_result[:config], options)
|
|
46
|
+
return content_result unless content_result[:success]
|
|
47
|
+
|
|
48
|
+
# Step 4: Compose prompts via ace-bundle
|
|
49
|
+
prompt_result = compose_review_prompt(
|
|
50
|
+
config_result[:config],
|
|
51
|
+
content_result[:context],
|
|
52
|
+
content_result[:subject],
|
|
53
|
+
session_dir,
|
|
54
|
+
options, # Pass options to check for PR mode
|
|
55
|
+
content_result[:typed_subject_config] # Pass typed subject config directly
|
|
56
|
+
)
|
|
57
|
+
return prompt_result unless prompt_result[:success]
|
|
58
|
+
|
|
59
|
+
# Step 5: Prepare review data structure
|
|
60
|
+
review_data = build_review_data(
|
|
61
|
+
options,
|
|
62
|
+
config_result[:config],
|
|
63
|
+
content_result,
|
|
64
|
+
prompt_result, # Pass the entire prompt_result to handle both formats
|
|
65
|
+
cache_dir
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Step 6: Save session files
|
|
69
|
+
save_session_files(session_dir, review_data)
|
|
70
|
+
|
|
71
|
+
# Step 7: Execute or just prepare
|
|
72
|
+
if options.auto_execute
|
|
73
|
+
execute_with_llm(review_data, session_dir, options)
|
|
74
|
+
else
|
|
75
|
+
{
|
|
76
|
+
success: true,
|
|
77
|
+
session_dir: session_dir,
|
|
78
|
+
system_prompt_file: File.join(session_dir, "system.prompt.md"),
|
|
79
|
+
user_prompt_file: File.join(session_dir, "user.prompt.md"),
|
|
80
|
+
message: "Review session prepared in #{session_dir}"
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# List available presets
|
|
86
|
+
def list_presets
|
|
87
|
+
@preset_manager.available_presets
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# List available prompt modules
|
|
91
|
+
def list_prompts
|
|
92
|
+
prompts = @prompt_resolver.list_available
|
|
93
|
+
prompts.is_a?(Hash) ? prompts.keys : []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Ensure we have a ReviewOptions object
|
|
99
|
+
def ensure_review_options(options)
|
|
100
|
+
return options if options.is_a?(Models::ReviewOptions)
|
|
101
|
+
Models::ReviewOptions.new(options.is_a?(Hash) ? options : {})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Step 1: Prepare and validate configuration
|
|
105
|
+
def prepare_review_config(options)
|
|
106
|
+
preset_name = options.preset
|
|
107
|
+
|
|
108
|
+
unless preset_name
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
error: "No preset specified. Use --preset NAME or set defaults.preset in .ace/review/config.yml"
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
unless @preset_manager.preset_exists?(preset_name)
|
|
116
|
+
available = @preset_manager.available_presets.join(", ")
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
error: "Preset '#{preset_name}' not found. Available: #{available}"
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolve preset with options
|
|
124
|
+
config = @preset_manager.resolve_preset(preset_name, options.to_h)
|
|
125
|
+
|
|
126
|
+
# Check for composition failure (circular deps, missing refs return nil)
|
|
127
|
+
unless config
|
|
128
|
+
return {
|
|
129
|
+
success: false,
|
|
130
|
+
error: "Failed to load preset '#{preset_name}'. Check for circular dependencies or missing preset references."
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Merge options with config
|
|
135
|
+
options.merge_config(config)
|
|
136
|
+
|
|
137
|
+
{success: true, config: config}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Step 2: Extract subject and context
|
|
141
|
+
def extract_review_content(config, options)
|
|
142
|
+
# Handle PR mode
|
|
143
|
+
if options.pr_review?
|
|
144
|
+
return extract_pr_content(options.pr, config, options)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Extract subject (what to review)
|
|
148
|
+
subject_config = options.subject || config[:subject]
|
|
149
|
+
|
|
150
|
+
# Handle array of subjects - merge configs without extraction
|
|
151
|
+
# This allows multiple --subject flags to be combined into a single ace-bundle config
|
|
152
|
+
if subject_config.is_a?(Array)
|
|
153
|
+
merged_config = @subject_extractor.merge_typed_subject_configs(subject_config)
|
|
154
|
+
if merged_config
|
|
155
|
+
cache_dir = options.session_dir || create_cache_directory
|
|
156
|
+
context_config = options.context || config[:context]
|
|
157
|
+
context = extract_context(context_config, cache_dir)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
typed_subject_config: merged_config, # Pass merged config, not content
|
|
162
|
+
subject: nil, # No pre-extracted content
|
|
163
|
+
context: context,
|
|
164
|
+
cache_dir: cache_dir
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Check for typed subject - pass config directly to ace-bundle (no extraction)
|
|
170
|
+
# This avoids extracting content only to save it and re-read it
|
|
171
|
+
if subject_config.is_a?(String)
|
|
172
|
+
typed_config = @subject_extractor.parse_typed_subject_config(subject_config)
|
|
173
|
+
if typed_config
|
|
174
|
+
# Create cache directory for context.md if not provided
|
|
175
|
+
cache_dir = options.session_dir || create_cache_directory
|
|
176
|
+
|
|
177
|
+
# Extract context (background info)
|
|
178
|
+
context_config = options.context || config[:context]
|
|
179
|
+
context = extract_context(context_config, cache_dir)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
success: true,
|
|
183
|
+
typed_subject_config: typed_config, # Pass config, not content
|
|
184
|
+
subject: nil, # No pre-extracted content
|
|
185
|
+
context: context,
|
|
186
|
+
cache_dir: cache_dir
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fall back to legacy flow for non-typed subjects
|
|
192
|
+
subject = extract_subject(subject_config)
|
|
193
|
+
|
|
194
|
+
if subject.nil? || subject.empty?
|
|
195
|
+
return {success: false, error: "No code to review"}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Extract context (background info)
|
|
199
|
+
context_config = options.context || config[:context]
|
|
200
|
+
|
|
201
|
+
# Create cache directory for context.md if not provided
|
|
202
|
+
cache_dir = options.session_dir || create_cache_directory
|
|
203
|
+
|
|
204
|
+
context = extract_context(context_config, cache_dir)
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
success: true,
|
|
208
|
+
subject: subject,
|
|
209
|
+
context: context,
|
|
210
|
+
cache_dir: cache_dir
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Extract PR content (diff and metadata)
|
|
215
|
+
def extract_pr_content(pr_identifier, config, options)
|
|
216
|
+
# Fetch PR diff and metadata
|
|
217
|
+
fetch_options = options.gh_timeout ? {timeout: options.gh_timeout} : {}
|
|
218
|
+
result = Ace::Review::Molecules::GhPrFetcher.fetch_pr(pr_identifier, fetch_options)
|
|
219
|
+
|
|
220
|
+
unless result[:success]
|
|
221
|
+
return {success: false, error: result[:error]}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Store PR metadata in options for later use
|
|
225
|
+
options.pr_metadata = result[:metadata]
|
|
226
|
+
|
|
227
|
+
# Fetch PR comments if enabled
|
|
228
|
+
if options.include_pr_comments?
|
|
229
|
+
comments_result = Ace::Review::Molecules::GhPrCommentFetcher.fetch(pr_identifier, fetch_options)
|
|
230
|
+
if comments_result[:success]
|
|
231
|
+
if Ace::Review::Molecules::GhPrCommentFetcher.has_comments?(comments_result)
|
|
232
|
+
options.pr_comment_data = comments_result
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
# Log warning but continue with review (comments are optional enhancement)
|
|
236
|
+
warn "Warning: Failed to fetch PR comments: #{comments_result[:error]}. " \
|
|
237
|
+
"Review will proceed without developer feedback."
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Create cache directory
|
|
242
|
+
cache_dir = options.session_dir || create_cache_directory
|
|
243
|
+
|
|
244
|
+
# Extract context (background info) and enrich with task behavioral spec when available.
|
|
245
|
+
context_config = options.context || config[:context]
|
|
246
|
+
spec_aware_context = build_pr_context_with_task_spec(
|
|
247
|
+
context_config: context_config,
|
|
248
|
+
pr_metadata: result[:metadata]
|
|
249
|
+
)
|
|
250
|
+
context = extract_context(spec_aware_context, cache_dir)
|
|
251
|
+
|
|
252
|
+
# Add PR metadata to context
|
|
253
|
+
pr_info = format_pr_metadata(result[:metadata])
|
|
254
|
+
|
|
255
|
+
{
|
|
256
|
+
success: true,
|
|
257
|
+
subject: result[:diff],
|
|
258
|
+
context: context.empty? ? pr_info : "#{context}\n\n#{pr_info}",
|
|
259
|
+
cache_dir: cache_dir,
|
|
260
|
+
pr_metadata: result[:metadata]
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Add task behavioral spec file to PR context when task can be detected.
|
|
265
|
+
def build_pr_context_with_task_spec(context_config:, pr_metadata:)
|
|
266
|
+
spec_path = Molecules::PrTaskSpecResolver.resolve_spec_path(pr_metadata)
|
|
267
|
+
return context_config unless spec_path
|
|
268
|
+
|
|
269
|
+
case context_config
|
|
270
|
+
when nil, false, "none"
|
|
271
|
+
{"files" => [spec_path]}
|
|
272
|
+
when String
|
|
273
|
+
{"presets" => [context_config], "files" => [spec_path]}
|
|
274
|
+
when Hash
|
|
275
|
+
deep_merge_context(context_config, {"files" => [spec_path]})
|
|
276
|
+
else
|
|
277
|
+
{"files" => [spec_path]}
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Format PR metadata for context
|
|
282
|
+
def format_pr_metadata(metadata)
|
|
283
|
+
info = "## Pull Request Information\n\n"
|
|
284
|
+
info += "- **Title**: #{metadata["title"]}\n"
|
|
285
|
+
info += "- **Number**: ##{metadata["number"]}\n"
|
|
286
|
+
info += "- **Author**: #{metadata["author"]["login"]}\n" if metadata["author"]
|
|
287
|
+
info += "- **State**: #{metadata["state"]}\n"
|
|
288
|
+
info += "- **Draft**: #{metadata["isDraft"] ? "Yes" : "No"}\n"
|
|
289
|
+
info += "- **Base**: #{metadata["baseRefName"]}\n"
|
|
290
|
+
info += "- **Head**: #{metadata["headRefName"]}\n"
|
|
291
|
+
info += "- **URL**: #{metadata["url"]}\n"
|
|
292
|
+
info
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Step 3: Generate system and user prompts via ace-bundle
|
|
296
|
+
def compose_review_prompt(config, context, subject, session_dir, options = nil, typed_subject_config = nil)
|
|
297
|
+
# Extract prompt composition and context config
|
|
298
|
+
config[:system_prompt] || config["system_prompt"] || {}
|
|
299
|
+
context_config = config[:context] || config["context"] || "project"
|
|
300
|
+
|
|
301
|
+
# Step 3a: Create system.context.md with instructions configuration
|
|
302
|
+
instructions_config = config["instructions"] || config[:instructions]
|
|
303
|
+
unless instructions_config
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: "No instructions found in config. All presets must use instructions format."
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
system_context_path = create_context_file(session_dir, instructions_config, context_config, "system.context.md")
|
|
310
|
+
|
|
311
|
+
# Step 3b: Create user.context.md with subject configuration
|
|
312
|
+
subject_config = resolve_subject_config(
|
|
313
|
+
config: config,
|
|
314
|
+
subject: subject,
|
|
315
|
+
session_dir: session_dir,
|
|
316
|
+
options: options,
|
|
317
|
+
typed_subject_config: typed_subject_config
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
unless subject_config
|
|
321
|
+
return {
|
|
322
|
+
success: false,
|
|
323
|
+
error: "No subject found in config. All presets must use subject format."
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
user_context_path = create_context_file(session_dir, subject_config, nil, "user.context.md")
|
|
327
|
+
|
|
328
|
+
# Step 3c: Generate system.prompt.md via ace-bundle
|
|
329
|
+
system_prompt_path = File.join(session_dir, "system.prompt.md")
|
|
330
|
+
begin
|
|
331
|
+
execute_ace_context(system_context_path, system_prompt_path)
|
|
332
|
+
rescue Errors::MissingDependencyError, Errors::BundleProcessingError => e
|
|
333
|
+
return {success: false, error: "Failed to generate system prompt: #{e.message}"}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Step 3d: Generate user.prompt.md via ace-bundle
|
|
337
|
+
user_prompt_path = File.join(session_dir, "user.prompt.md")
|
|
338
|
+
begin
|
|
339
|
+
execute_ace_context(user_context_path, user_prompt_path)
|
|
340
|
+
rescue Errors::MissingDependencyError, Errors::BundleProcessingError => e
|
|
341
|
+
return {success: false, error: "Failed to generate user prompt: #{e.message}"}
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Load the generated prompts
|
|
345
|
+
system_prompt = File.read(system_prompt_path) if File.exist?(system_prompt_path)
|
|
346
|
+
user_prompt = File.read(user_prompt_path) if File.exist?(user_prompt_path)
|
|
347
|
+
|
|
348
|
+
if system_prompt.nil? || system_prompt.empty?
|
|
349
|
+
return {success: false, error: "Failed to generate system prompt"}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
{
|
|
353
|
+
success: true,
|
|
354
|
+
system_prompt: system_prompt,
|
|
355
|
+
user_prompt: user_prompt || "Please review the provided code.",
|
|
356
|
+
system_prompt_path: system_prompt_path,
|
|
357
|
+
user_prompt_path: user_prompt_path
|
|
358
|
+
}
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Detect whether preset uses instructions format or legacy system_prompt format
|
|
362
|
+
def uses_instructions_format?(resolved_config)
|
|
363
|
+
instructions = resolved_config["instructions"] || resolved_config[:instructions]
|
|
364
|
+
instructions && instructions.is_a?(Hash)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Unified context file processor - pass configuration directly to ace-bundle
|
|
368
|
+
def create_context_file(session_dir, context_config, additional_context, output_filename)
|
|
369
|
+
# Build complete ace-bundle configuration
|
|
370
|
+
ace_context_config = {}
|
|
371
|
+
|
|
372
|
+
# Normalize and merge context_config if provided
|
|
373
|
+
if context_config
|
|
374
|
+
normalized_config = Ace::Bundle::Atoms::BundleNormalizer.normalize_config(context_config)
|
|
375
|
+
ace_context_config = deep_merge_context(ace_context_config, normalized_config)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Add additional context as "bundle" key for ace-bundle, but avoid duplicates
|
|
379
|
+
if additional_context && additional_context != "none" && !additional_context.empty?
|
|
380
|
+
ace_context_config["bundle"] ||= {}
|
|
381
|
+
if additional_context.is_a?(String)
|
|
382
|
+
# Check if this preset is already included in the sections to avoid duplication
|
|
383
|
+
existing_presets = extract_presets_from_sections(ace_context_config)
|
|
384
|
+
unless existing_presets.include?(additional_context)
|
|
385
|
+
ace_context_config["bundle"]["presets"] ||= []
|
|
386
|
+
ace_context_config["bundle"]["presets"] << additional_context
|
|
387
|
+
end
|
|
388
|
+
elsif additional_context.is_a?(Hash)
|
|
389
|
+
additional_normalized = Ace::Bundle::Atoms::BundleNormalizer.normalize_config(additional_context)
|
|
390
|
+
ace_context_config = deep_merge_context(ace_context_config, additional_normalized)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Create context.md content with full configuration as frontmatter
|
|
395
|
+
context_content = "#{YAML.dump(ace_context_config).strip}\n---\n\n"
|
|
396
|
+
|
|
397
|
+
# Write to file
|
|
398
|
+
context_path = File.join(session_dir, output_filename)
|
|
399
|
+
File.write(context_path, context_content)
|
|
400
|
+
|
|
401
|
+
context_path
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Deep merge two context configurations (top-level wrapper)
|
|
405
|
+
#
|
|
406
|
+
# Merges overlay into base with smart type handling. This is a simplified
|
|
407
|
+
# version that delegates to deep_merge_hash for all hash values.
|
|
408
|
+
#
|
|
409
|
+
# @param base [Hash] Base configuration (lower priority)
|
|
410
|
+
# @param overlay [Hash] Overlay configuration (higher priority)
|
|
411
|
+
# @return [Hash] Merged configuration
|
|
412
|
+
#
|
|
413
|
+
# @example Simple merge
|
|
414
|
+
# deep_merge_context({a: 1}, {b: 2})
|
|
415
|
+
# #=> {a: 1, b: 2}
|
|
416
|
+
#
|
|
417
|
+
# @example Nested hash merge
|
|
418
|
+
# deep_merge_context({a: {b: 1}}, {a: {c: 2}})
|
|
419
|
+
# #=> {a: {b: 1, c: 2}}
|
|
420
|
+
#
|
|
421
|
+
# @api private
|
|
422
|
+
def deep_merge_context(base, overlay)
|
|
423
|
+
# Use centralized DeepMerger with :union strategy for array deduplication
|
|
424
|
+
Ace::Support::Config::Atoms::DeepMerger.merge(base, overlay, array_strategy: :union)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Extract preset names from sections to avoid duplication
|
|
428
|
+
def extract_presets_from_sections(config)
|
|
429
|
+
presets = []
|
|
430
|
+
return presets unless config.is_a?(Hash)
|
|
431
|
+
|
|
432
|
+
# Check if config has bundle with sections
|
|
433
|
+
bundle = config["bundle"] || config[:bundle]
|
|
434
|
+
return presets unless bundle.is_a?(Hash)
|
|
435
|
+
|
|
436
|
+
sections = bundle["sections"] || bundle[:sections]
|
|
437
|
+
return presets unless sections.is_a?(Hash)
|
|
438
|
+
|
|
439
|
+
# Extract presets from all sections
|
|
440
|
+
sections.each do |section_name, section_config|
|
|
441
|
+
if section_config.is_a?(Hash)
|
|
442
|
+
section_presets = section_config["presets"] || section_config[:presets]
|
|
443
|
+
if section_presets.is_a?(Array)
|
|
444
|
+
presets.concat(section_presets)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
presets.uniq
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Execute ace-bundle to generate prompts using Ruby API
|
|
453
|
+
# @param input_file [String] Path to context configuration file
|
|
454
|
+
# @param output_file [String] Path to write rendered context
|
|
455
|
+
# @raise [Errors::MissingDependencyError] If ace-bundle gem not available
|
|
456
|
+
# @raise [Errors::BundleProcessingError] If context processing fails
|
|
457
|
+
# @return [true] On success
|
|
458
|
+
def execute_ace_context(input_file, output_file)
|
|
459
|
+
# Ensure ace-bundle is available
|
|
460
|
+
begin
|
|
461
|
+
require "ace/bundle"
|
|
462
|
+
rescue LoadError
|
|
463
|
+
raise Errors::MissingDependencyError.new(
|
|
464
|
+
"ace-bundle",
|
|
465
|
+
"gem install ace-bundle"
|
|
466
|
+
)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Check if Ace::Bundle is actually defined (might fail silently)
|
|
470
|
+
unless defined?(Ace::Bundle)
|
|
471
|
+
raise Errors::MissingDependencyError.new(
|
|
472
|
+
"ace-bundle",
|
|
473
|
+
"gem install ace-bundle"
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
begin
|
|
478
|
+
# Load context using ace-bundle Ruby API
|
|
479
|
+
context_result = Ace::Bundle.load_file(input_file)
|
|
480
|
+
|
|
481
|
+
# Check for fatal error in metadata
|
|
482
|
+
if context_result.metadata[:error]
|
|
483
|
+
error_message = context_result.metadata[:error]
|
|
484
|
+
raise Errors::BundleProcessingError.new(
|
|
485
|
+
"Failed to process context file: #{error_message}",
|
|
486
|
+
{input_file: input_file, error: error_message}
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Surface non-fatal errors (e.g., PR fetch failures) as warnings
|
|
491
|
+
# These are stored in metadata[:errors] array by ace-bundle
|
|
492
|
+
if context_result.metadata[:errors]&.any?
|
|
493
|
+
context_result.metadata[:errors].each do |error_msg|
|
|
494
|
+
warn "[ace-review] Warning: #{error_msg}"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Write the rendered content to output file
|
|
499
|
+
File.write(output_file, context_result.content)
|
|
500
|
+
true
|
|
501
|
+
rescue Errors::BundleProcessingError
|
|
502
|
+
# Re-raise our own errors
|
|
503
|
+
raise
|
|
504
|
+
rescue => e
|
|
505
|
+
raise Errors::BundleProcessingError.new(
|
|
506
|
+
"ace-bundle processing failed: #{e.message}",
|
|
507
|
+
{input_file: input_file, error: e.message, backtrace: e.backtrace.first(5)}
|
|
508
|
+
)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Build the complete review data structure
|
|
513
|
+
def build_review_data(options, config, content, prompt_result, cache_dir)
|
|
514
|
+
# v0.13.0 architecture: only supports system/user prompt format
|
|
515
|
+
effective_models = options.effective_models(config[:models])
|
|
516
|
+
|
|
517
|
+
review_data = {
|
|
518
|
+
preset: options.preset,
|
|
519
|
+
config: config,
|
|
520
|
+
subject: content[:subject],
|
|
521
|
+
context: content[:context],
|
|
522
|
+
model: effective_models.first,
|
|
523
|
+
models: effective_models,
|
|
524
|
+
cache_dir: cache_dir,
|
|
525
|
+
system_prompt: prompt_result[:system_prompt],
|
|
526
|
+
user_prompt: prompt_result[:user_prompt],
|
|
527
|
+
system_prompt_path: prompt_result[:system_prompt_path],
|
|
528
|
+
user_prompt_path: prompt_result[:user_prompt_path]
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
# Include PR comment data if available
|
|
532
|
+
review_data[:pr_comment_data] = options.pr_comment_data if options.pr_comment_data
|
|
533
|
+
|
|
534
|
+
review_data
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Resolve subject configuration from multiple sources
|
|
538
|
+
# Priority: typed subject config > --pr flag > preset subject config
|
|
539
|
+
# @param config [Hash] Preset configuration
|
|
540
|
+
# @param subject [String, nil] Pre-extracted subject content (for --pr flag only)
|
|
541
|
+
# @param session_dir [String] Session directory for saving intermediate files
|
|
542
|
+
# @param options [ReviewOptions, nil] Review options
|
|
543
|
+
# @param typed_subject_config [Hash, nil] Parsed typed subject (pr:, files:, diff:, task:)
|
|
544
|
+
# @return [Hash, nil] Resolved subject configuration for ace-bundle
|
|
545
|
+
def resolve_subject_config(config:, subject:, session_dir:, options:, typed_subject_config:)
|
|
546
|
+
# Handle typed subject config (pr:, files:, diff:, task:) - pass directly to ace-bundle
|
|
547
|
+
# This is the primary path - ace-bundle handles all content extraction
|
|
548
|
+
if typed_subject_config
|
|
549
|
+
return typed_subject_config
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Handle --pr flag (full PR mode with GhPrFetcher)
|
|
553
|
+
if subject && !subject.empty? && options&.pr_review?
|
|
554
|
+
pr_diff_path = File.join(session_dir, "pr-diff.patch")
|
|
555
|
+
File.write(pr_diff_path, subject)
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
"bundle" => {
|
|
559
|
+
"sections" => {
|
|
560
|
+
"pr_changes" => {
|
|
561
|
+
"title" => "Pull Request Changes",
|
|
562
|
+
"description" => "Code changes from GitHub Pull Request",
|
|
563
|
+
"files" => [pr_diff_path]
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Fallback to preset subject config
|
|
571
|
+
config["subject"] || config[:subject]
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def extract_subject(subject_config)
|
|
575
|
+
return "" unless subject_config
|
|
576
|
+
@subject_extractor.extract(subject_config)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def extract_context(context_config, cache_dir = nil)
|
|
580
|
+
@context_extractor.extract(context_config, cache_dir)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def execute_with_llm(review_data, session_dir, options = nil)
|
|
584
|
+
models = review_data[:models]
|
|
585
|
+
|
|
586
|
+
# Detect single vs multi-model execution
|
|
587
|
+
if models.size == 1
|
|
588
|
+
# Single model execution (existing path)
|
|
589
|
+
execute_single_model(review_data, session_dir, options, models.first)
|
|
590
|
+
else
|
|
591
|
+
# Multi-model execution (new path)
|
|
592
|
+
execute_multi_model(review_data, session_dir, options, models)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Execute single model review (existing behavior)
|
|
597
|
+
def execute_single_model(review_data, session_dir, options, model)
|
|
598
|
+
executor = Ace::Review::Molecules::LlmExecutor.new
|
|
599
|
+
|
|
600
|
+
# v0.13.0 architecture: only supports system/user prompt format
|
|
601
|
+
result = executor.execute(
|
|
602
|
+
system_prompt: review_data[:system_prompt],
|
|
603
|
+
user_prompt: review_data[:user_prompt],
|
|
604
|
+
model: model,
|
|
605
|
+
session_dir: session_dir
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if result[:success]
|
|
609
|
+
# Save Ruby API metadata if available
|
|
610
|
+
save_ruby_api_metadata(session_dir, result) if result[:metadata]
|
|
611
|
+
|
|
612
|
+
# Copy final review to release folder
|
|
613
|
+
release_path = copy_to_release(session_dir, review_data)
|
|
614
|
+
|
|
615
|
+
# Handle PR comment posting if requested
|
|
616
|
+
comment_result = handle_pr_comment_posting(options, result[:output_file], review_data)
|
|
617
|
+
|
|
618
|
+
# Build response with comment info if applicable
|
|
619
|
+
response = build_success_response(result, release_path, comment_result)
|
|
620
|
+
|
|
621
|
+
# Extract feedback after successful single model review (if enabled)
|
|
622
|
+
feedback_result = maybe_extract_single_model_feedback(
|
|
623
|
+
result, session_dir, review_data, options, model
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# Add feedback info to response if extraction succeeded
|
|
627
|
+
if feedback_result && feedback_result[:success]
|
|
628
|
+
response[:feedback_count] = feedback_result[:items_count]
|
|
629
|
+
response[:feedback_paths] = feedback_result[:paths]
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
response
|
|
633
|
+
else
|
|
634
|
+
# Enhanced error information from Ruby API
|
|
635
|
+
error_result = result.dup
|
|
636
|
+
if result[:error_type]
|
|
637
|
+
error_result[:enhanced_error] = "#{result[:error_type]}: #{result[:error]}"
|
|
638
|
+
end
|
|
639
|
+
error_result
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Execute multi-model review (new capability)
|
|
644
|
+
def execute_multi_model(review_data, session_dir, options, models)
|
|
645
|
+
require_relative "../molecules/multi_model_executor"
|
|
646
|
+
executor = Ace::Review::Molecules::MultiModelExecutor.new
|
|
647
|
+
|
|
648
|
+
# Execute all models concurrently
|
|
649
|
+
result = executor.execute(
|
|
650
|
+
models: models,
|
|
651
|
+
system_prompt: review_data[:system_prompt],
|
|
652
|
+
user_prompt: review_data[:user_prompt],
|
|
653
|
+
session_dir: session_dir
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
if result[:success]
|
|
657
|
+
# Save metadata for all models
|
|
658
|
+
save_multi_model_metadata(session_dir, result)
|
|
659
|
+
|
|
660
|
+
# For multi-model, we don't copy to a single release location
|
|
661
|
+
# Each model has its own output file already in session_dir
|
|
662
|
+
|
|
663
|
+
# Extract feedback (always runs if we have results)
|
|
664
|
+
feedback_result = nil
|
|
665
|
+
if should_extract_feedback?(result, options)
|
|
666
|
+
feedback_result = extract_feedback(result, session_dir, review_data, options)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Build multi-model success response
|
|
670
|
+
build_multi_model_response(result, session_dir, feedback_result)
|
|
671
|
+
else
|
|
672
|
+
{
|
|
673
|
+
success: false,
|
|
674
|
+
error: "All models failed to execute"
|
|
675
|
+
}
|
|
676
|
+
end
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Post review comment to PR
|
|
680
|
+
def post_pr_comment(options, review_file, review_data)
|
|
681
|
+
return {success: false, error: "No review file to post"} unless File.exist?(review_file)
|
|
682
|
+
|
|
683
|
+
# Read review content
|
|
684
|
+
review_content = File.read(review_file)
|
|
685
|
+
|
|
686
|
+
# Prepare metadata for comment
|
|
687
|
+
metadata = {
|
|
688
|
+
preset: review_data[:preset],
|
|
689
|
+
model: review_data[:model],
|
|
690
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
# Post comment
|
|
694
|
+
Ace::Review::Molecules::GhCommentPoster.post_comment(
|
|
695
|
+
options.pr,
|
|
696
|
+
review_content,
|
|
697
|
+
metadata: metadata,
|
|
698
|
+
dry_run: options.dry_run
|
|
699
|
+
)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def save_session_files(session_dir, review_data)
|
|
703
|
+
# v0.13.0+ architecture: system and user prompts are already saved as .prompt.md files
|
|
704
|
+
# Subject and context are handled directly via ace-bundle workflow, no need for separate files
|
|
705
|
+
|
|
706
|
+
# Save metadata (committable - no .tmp extension)
|
|
707
|
+
metadata = create_metadata(review_data)
|
|
708
|
+
File.write(File.join(session_dir, "metadata.yml"), YAML.dump(metadata))
|
|
709
|
+
|
|
710
|
+
# Save PR comments as developer feedback report if available
|
|
711
|
+
if review_data[:pr_comment_data]
|
|
712
|
+
feedback_report = Ace::Review::Atoms::PrCommentFormatter.format(review_data[:pr_comment_data])
|
|
713
|
+
if feedback_report && !feedback_report.empty?
|
|
714
|
+
feedback_file = File.join(session_dir, "review-dev-feedback.md")
|
|
715
|
+
File.write(feedback_file, feedback_report)
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def save_review_output(response, review_data, session_dir)
|
|
721
|
+
# Save review to session directory as review.md
|
|
722
|
+
output_file = File.join(session_dir, "review.md")
|
|
723
|
+
|
|
724
|
+
# Add metadata header to response
|
|
725
|
+
full_content = add_review_metadata(response, review_data)
|
|
726
|
+
|
|
727
|
+
File.write(output_file, full_content)
|
|
728
|
+
|
|
729
|
+
{
|
|
730
|
+
success: true,
|
|
731
|
+
output_file: output_file,
|
|
732
|
+
message: "Review saved to #{output_file}"
|
|
733
|
+
}
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def create_session_directory(options, cache_dir)
|
|
737
|
+
if options.session_dir
|
|
738
|
+
FileUtils.mkdir_p(options.session_dir)
|
|
739
|
+
return options.session_dir
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
# Use cache directory (cache-first approach)
|
|
743
|
+
compact_id = Ace::B36ts.encode(Time.now)
|
|
744
|
+
|
|
745
|
+
# All reviews use the same naming pattern
|
|
746
|
+
session_dir = File.join(cache_dir, "review-#{compact_id}")
|
|
747
|
+
|
|
748
|
+
FileUtils.mkdir_p(session_dir)
|
|
749
|
+
session_dir
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def create_cache_directory
|
|
753
|
+
# Create cache directory in .ace-local/review/sessions/ relative to project root
|
|
754
|
+
# Use @project_root if set (e.g., in tests), otherwise use ProjectRootFinder
|
|
755
|
+
root = @project_root || Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
756
|
+
base_cache_path = File.join(root, ".ace-local", "review", "sessions")
|
|
757
|
+
FileUtils.mkdir_p(base_cache_path)
|
|
758
|
+
base_cache_path
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def copy_to_release(session_dir, review_data)
|
|
762
|
+
# Copy final review reports to release folder
|
|
763
|
+
release_base_path = @preset_manager.review_base_path
|
|
764
|
+
FileUtils.mkdir_p(release_base_path)
|
|
765
|
+
|
|
766
|
+
# Create output filename
|
|
767
|
+
model_slug = Ace::Review::Atoms::SlugGenerator.generate(review_data[:model])
|
|
768
|
+
compact_id = Ace::B36ts.encode(Time.now)
|
|
769
|
+
release_filename = "review-report-#{model_slug}-#{compact_id}.md"
|
|
770
|
+
release_path = File.join(release_base_path, release_filename)
|
|
771
|
+
|
|
772
|
+
# Copy review file if it exists
|
|
773
|
+
review_file = File.join(session_dir, "review.md")
|
|
774
|
+
if File.exist?(review_file)
|
|
775
|
+
FileUtils.cp(review_file, release_path)
|
|
776
|
+
return release_path
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
nil
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def create_metadata(review_data)
|
|
783
|
+
{
|
|
784
|
+
"timestamp" => Time.now.iso8601,
|
|
785
|
+
"preset" => review_data[:preset],
|
|
786
|
+
"model" => review_data[:model],
|
|
787
|
+
"has_context" => !review_data[:context].to_s.empty?,
|
|
788
|
+
"subject_size" => review_data[:subject]&.length || 0,
|
|
789
|
+
"system_prompt_size" => review_data[:system_prompt]&.length || 0,
|
|
790
|
+
"user_prompt_size" => review_data[:user_prompt]&.length || 0
|
|
791
|
+
}
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def save_ruby_api_metadata(session_dir, result)
|
|
795
|
+
# Save rich metadata from Ruby API
|
|
796
|
+
metadata_file = File.join(session_dir, "llm_metadata.yml")
|
|
797
|
+
metadata_content = {
|
|
798
|
+
"timestamp" => Time.now.iso8601,
|
|
799
|
+
"usage" => result[:usage],
|
|
800
|
+
"model_info" => result[:model_info],
|
|
801
|
+
"provider_info" => result[:provider_info],
|
|
802
|
+
"raw_metadata" => result[:metadata]
|
|
803
|
+
}
|
|
804
|
+
File.write(metadata_file, YAML.dump(metadata_content))
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# Save metadata for multi-model execution
|
|
808
|
+
def save_multi_model_metadata(session_dir, result)
|
|
809
|
+
metadata_file = File.join(session_dir, "metadata.yml")
|
|
810
|
+
models_metadata = result[:results].map do |model, model_result|
|
|
811
|
+
{
|
|
812
|
+
"name" => model,
|
|
813
|
+
"status" => model_result[:success] ? "success" : "failed",
|
|
814
|
+
"duration" => model_result[:duration],
|
|
815
|
+
"output_file" => model_result[:output_file] ? File.basename(model_result[:output_file]) : nil,
|
|
816
|
+
"error" => model_result[:error],
|
|
817
|
+
"model_slug" => model_result[:model_slug]
|
|
818
|
+
}
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
metadata_content = {
|
|
822
|
+
"timestamp" => Time.now.iso8601,
|
|
823
|
+
"models" => models_metadata,
|
|
824
|
+
"summary" => result[:summary]
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
File.write(metadata_file, YAML.dump(metadata_content))
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def add_review_metadata(response, review_data)
|
|
831
|
+
metadata = <<~METADATA
|
|
832
|
+
---
|
|
833
|
+
timestamp: #{Time.now.iso8601}
|
|
834
|
+
preset: #{review_data[:preset]}
|
|
835
|
+
model: #{review_data[:model]}
|
|
836
|
+
---
|
|
837
|
+
|
|
838
|
+
METADATA
|
|
839
|
+
|
|
840
|
+
metadata + response
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Handle PR comment posting workflow
|
|
844
|
+
# @param options [ReviewOptions] Review options
|
|
845
|
+
# @param review_file [String] Path to review file
|
|
846
|
+
# @param review_data [Hash] Review metadata
|
|
847
|
+
# @return [Hash, nil] Comment result or nil if no posting needed
|
|
848
|
+
def handle_pr_comment_posting(options, review_file, review_data)
|
|
849
|
+
return nil unless options && options.should_post_comment?
|
|
850
|
+
post_pr_comment(options, review_file, review_data)
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Build success response with optional comment info
|
|
854
|
+
# @param result [Hash] LLM execution result
|
|
855
|
+
# @param release_path [String] Path to saved release file
|
|
856
|
+
# @param comment_result [Hash, nil] Comment posting result
|
|
857
|
+
# @return [Hash] Final response hash
|
|
858
|
+
def build_success_response(result, release_path, comment_result)
|
|
859
|
+
# Build result message
|
|
860
|
+
messages = []
|
|
861
|
+
messages << "Review saved to #{release_path}" if release_path
|
|
862
|
+
messages << "Review saved to #{result[:output_file]}" if messages.empty?
|
|
863
|
+
|
|
864
|
+
# Build base response
|
|
865
|
+
response = {
|
|
866
|
+
success: true,
|
|
867
|
+
output_file: release_path || result[:output_file],
|
|
868
|
+
message: messages.join("\n"),
|
|
869
|
+
usage: result[:usage],
|
|
870
|
+
model_info: result[:model_info],
|
|
871
|
+
provider_info: result[:provider_info]
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
# Add comment info to response
|
|
875
|
+
if comment_result && comment_result[:success]
|
|
876
|
+
if comment_result[:dry_run]
|
|
877
|
+
# Dry-run mode: add preview to response
|
|
878
|
+
response[:dry_run_preview] = comment_result[:preview]
|
|
879
|
+
else
|
|
880
|
+
# Actual posting: add comment URL
|
|
881
|
+
response[:comment_url] = comment_result[:comment_url]
|
|
882
|
+
response[:message] += "\n✓ Review posted to PR: #{comment_result[:comment_url]}"
|
|
883
|
+
end
|
|
884
|
+
elsif comment_result && !comment_result[:success]
|
|
885
|
+
response[:comment_error] = comment_result[:error]
|
|
886
|
+
response[:message] += "\n✗ Failed to post comment: #{comment_result[:error]}"
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
response
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Determine if feedback extraction should be triggered
|
|
893
|
+
# Feedback extraction always runs if we have successful results,
|
|
894
|
+
# unless explicitly disabled via --no-feedback CLI flag.
|
|
895
|
+
# @param result [Hash] multi-model execution result
|
|
896
|
+
# @param options [ReviewOptions, nil] review options
|
|
897
|
+
# @return [Boolean] true if feedback extraction should run
|
|
898
|
+
def should_extract_feedback?(result, options)
|
|
899
|
+
# Check if feedback is disabled via CLI flag (--no-feedback)
|
|
900
|
+
return false if options&.no_feedback == true
|
|
901
|
+
|
|
902
|
+
# Need at least 1 successful result
|
|
903
|
+
success_count = result[:results].count { |_, r| r[:success] }
|
|
904
|
+
success_count >= 1
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
# Extract feedback for single-model reviews
|
|
908
|
+
# Wraps the single model result in the multi-model format and delegates to extract_feedback
|
|
909
|
+
# @param result [Hash] LLM execution result (single model)
|
|
910
|
+
# @param session_dir [String] session directory
|
|
911
|
+
# @param review_data [Hash] review metadata
|
|
912
|
+
# @param options [ReviewOptions, nil] review options
|
|
913
|
+
# @param model [String] model name used for review
|
|
914
|
+
# @return [Hash, nil] feedback extraction result or nil if disabled/failed
|
|
915
|
+
def maybe_extract_single_model_feedback(result, session_dir, review_data, options, model)
|
|
916
|
+
# Check if feedback is disabled via CLI flag (--no-feedback)
|
|
917
|
+
return nil if options&.no_feedback == true
|
|
918
|
+
|
|
919
|
+
# Build a result structure compatible with extract_feedback (multi-model format)
|
|
920
|
+
single_model_result = {
|
|
921
|
+
results: {model => {success: true, output_file: result[:output_file]}}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
extract_feedback(single_model_result, session_dir, review_data, options)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# Extract feedback items from review reports and save them
|
|
928
|
+
# @param result [Hash] multi-model execution result
|
|
929
|
+
# @param session_dir [String] session directory
|
|
930
|
+
# @param review_data [Hash] review metadata
|
|
931
|
+
# @param options [ReviewOptions, nil] review options
|
|
932
|
+
# @return [Hash, nil] feedback extraction result or nil on failure
|
|
933
|
+
def extract_feedback(result, session_dir, review_data, options)
|
|
934
|
+
require_relative "feedback_manager"
|
|
935
|
+
|
|
936
|
+
# Collect successful report paths
|
|
937
|
+
report_paths = collect_report_paths(result, session_dir)
|
|
938
|
+
|
|
939
|
+
return nil if report_paths.empty?
|
|
940
|
+
|
|
941
|
+
# Determine feedback base path
|
|
942
|
+
base_path = determine_feedback_path(review_data, session_dir)
|
|
943
|
+
|
|
944
|
+
# Build ordered list of models to try: primary + fallbacks
|
|
945
|
+
models_to_try = build_synthesis_model_list(options, review_data)
|
|
946
|
+
|
|
947
|
+
feedback_manager = FeedbackManager.new
|
|
948
|
+
last_error = nil
|
|
949
|
+
|
|
950
|
+
models_to_try.each do |model|
|
|
951
|
+
feedback_result = feedback_manager.extract_and_save(
|
|
952
|
+
report_paths: report_paths,
|
|
953
|
+
base_path: base_path,
|
|
954
|
+
model: model,
|
|
955
|
+
session_dir: File.join(session_dir, "feedback-synthesis")
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
if feedback_result[:success]
|
|
959
|
+
feedback_result[:synthesis_model] = model
|
|
960
|
+
return feedback_result
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
last_error = feedback_result[:error]
|
|
964
|
+
warn "Feedback synthesis failed with #{model}: #{last_error}"
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# All models failed
|
|
968
|
+
{success: false, error: last_error, models_tried: models_to_try}
|
|
969
|
+
rescue => e
|
|
970
|
+
warn "Feedback extraction error: #{e.message}"
|
|
971
|
+
{success: false, error: e.message}
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# Build ordered list of synthesis models: primary + fallbacks
|
|
975
|
+
# @param options [ReviewOptions, nil] review options
|
|
976
|
+
# @param review_data [Hash] review metadata
|
|
977
|
+
# @return [Array<String>] ordered list of models to try
|
|
978
|
+
def build_synthesis_model_list(options, review_data)
|
|
979
|
+
primary = options&.feedback_model ||
|
|
980
|
+
Ace::Review.get("feedback", "synthesis_model") ||
|
|
981
|
+
review_data[:model]
|
|
982
|
+
|
|
983
|
+
fallbacks = Ace::Review.get("feedback", "fallback_models") || []
|
|
984
|
+
|
|
985
|
+
[primary, *fallbacks].compact.uniq
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
# Collect report paths for feedback synthesis
|
|
989
|
+
#
|
|
990
|
+
# Collects all successful model reports for FeedbackSynthesizer processing.
|
|
991
|
+
# The synthesizer produces deduplicated findings with reviewer arrays.
|
|
992
|
+
#
|
|
993
|
+
# @param result [Hash] multi-model execution result
|
|
994
|
+
# @param session_dir [String] session directory
|
|
995
|
+
# @return [Array<String>] list of report file paths
|
|
996
|
+
def collect_report_paths(result, session_dir)
|
|
997
|
+
report_paths = []
|
|
998
|
+
|
|
999
|
+
# Add successful model reports
|
|
1000
|
+
result[:results].each do |_, model_result|
|
|
1001
|
+
if model_result[:success] && model_result[:output_file]
|
|
1002
|
+
report_paths << model_result[:output_file]
|
|
1003
|
+
end
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# Add dev-feedback report if it exists (PR comments)
|
|
1007
|
+
dev_feedback_path = File.join(session_dir, "review-dev-feedback.md")
|
|
1008
|
+
report_paths << dev_feedback_path if File.exist?(dev_feedback_path)
|
|
1009
|
+
|
|
1010
|
+
report_paths.compact.uniq
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
# Determine the base path for feedback storage
|
|
1014
|
+
#
|
|
1015
|
+
# Feedback lives in the session directory under .ace-local/review/sessions/.
|
|
1016
|
+
#
|
|
1017
|
+
# @param review_data [Hash] review metadata (unused, kept for API compatibility)
|
|
1018
|
+
# @param session_dir [String] session directory
|
|
1019
|
+
# @return [String] session directory (feedback lives in session)
|
|
1020
|
+
def determine_feedback_path(review_data, session_dir)
|
|
1021
|
+
session_dir
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
# Build multi-model response with optional feedback info
|
|
1025
|
+
# @param result [Hash] multi-model execution result
|
|
1026
|
+
# @param session_dir [String] session directory
|
|
1027
|
+
# @param feedback_result [Hash, nil] feedback extraction result
|
|
1028
|
+
# @return [Hash] response hash
|
|
1029
|
+
def build_multi_model_response(result, session_dir, feedback_result = nil)
|
|
1030
|
+
successful_models = result[:results].select { |_, r| r[:success] }
|
|
1031
|
+
failed_models = result[:results].reject { |_, r| r[:success] }
|
|
1032
|
+
|
|
1033
|
+
response = {
|
|
1034
|
+
success: true,
|
|
1035
|
+
session_dir: session_dir,
|
|
1036
|
+
summary: result[:summary],
|
|
1037
|
+
models: result[:results].keys,
|
|
1038
|
+
successful_models: successful_models.keys,
|
|
1039
|
+
failed_models: failed_models.keys
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
# Add output files
|
|
1043
|
+
output_files = successful_models.values.map { |r| r[:output_file] }.compact
|
|
1044
|
+
response[:output_files] = output_files
|
|
1045
|
+
|
|
1046
|
+
# Add feedback info
|
|
1047
|
+
if feedback_result && feedback_result[:success]
|
|
1048
|
+
response[:feedback_count] = feedback_result[:items_count]
|
|
1049
|
+
response[:feedback_paths] = feedback_result[:paths]
|
|
1050
|
+
elsif feedback_result && !feedback_result[:success]
|
|
1051
|
+
response[:feedback_error] = feedback_result[:error]
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
response
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
end
|
|
1058
|
+
end
|
|
1059
|
+
end
|