ace-review 0.54.2 → 0.55.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/.ace-defaults/review/presets/integration.yml +11 -0
- data/CHANGELOG.md +13 -0
- data/handbook/guides/code-review-process.g.md +35 -0
- data/handbook/prompts/focus/phase/integration.md +3 -0
- data/handbook/prompts/synthesize-feedback.system.md +2 -1
- data/handbook/skills/as-review-pr/SKILL.md +3 -6
- data/handbook/workflow-instructions/review/pr.wf.md +27 -261
- data/lib/ace/review/atoms/diff_boundary_finder.rb +47 -7
- data/lib/ace/review/atoms/prompt_budget.rb +79 -0
- data/lib/ace/review/atoms/token_estimator.rb +9 -3
- data/lib/ace/review/cli/commands/review.rb +23 -5
- data/lib/ace/review/cli.rb +17 -1
- data/lib/ace/review/models/feedback_item.rb +2 -2
- data/lib/ace/review/models/review_options.rb +2 -0
- data/lib/ace/review/molecules/diff_scope.rb +92 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +40 -31
- data/lib/ace/review/molecules/gh_comment_poster.rb +13 -12
- data/lib/ace/review/molecules/gh_comment_resolver.rb +7 -7
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +7 -6
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +88 -23
- data/lib/ace/review/molecules/goals_brief.rb +236 -0
- data/lib/ace/review/molecules/llm_executor.rb +22 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +1 -0
- data/lib/ace/review/molecules/preset_manager.rb +18 -2
- data/lib/ace/review/molecules/review_evidence.rb +52 -0
- data/lib/ace/review/molecules/subject_extractor.rb +2 -1
- data/lib/ace/review/molecules/subject_filter.rb +9 -2
- data/lib/ace/review/organisms/feedback_manager.rb +55 -25
- data/lib/ace/review/organisms/review_manager.rb +568 -76
- data/lib/ace/review/version.rb +1 -1
- data/lib/ace/review.rb +2 -1
- metadata +44 -11
- data/lib/ace/review/molecules/context_extractor.rb +0 -159
|
@@ -60,6 +60,8 @@ module Ace
|
|
|
60
60
|
option :pr_comments, type: :boolean, desc: "Include PR comments as feedback source (default: true for --pr)"
|
|
61
61
|
option :post_comment, type: :boolean, desc: "Post review as PR comment (requires --pr)"
|
|
62
62
|
option :gh_timeout, type: :integer, desc: "Timeout for gh CLI operations in seconds (default: 30)"
|
|
63
|
+
option :evidence_session, type: :array, desc: "Include selected prior review sessions as context"
|
|
64
|
+
option :prepare_goals_brief, type: :boolean, desc: "Generate or reuse the shared goals brief for this PR and preset"
|
|
63
65
|
|
|
64
66
|
# Standard options
|
|
65
67
|
option :version, type: :boolean, desc: "Show version information"
|
|
@@ -73,7 +75,7 @@ module Ace
|
|
|
73
75
|
|
|
74
76
|
def call(**cli_options)
|
|
75
77
|
# Remove ace-support-cli specific keys (args is leftover arguments)
|
|
76
|
-
cli_options = cli_options.
|
|
78
|
+
cli_options = cli_options.except(:args)
|
|
77
79
|
|
|
78
80
|
if cli_options[:version]
|
|
79
81
|
puts "ace-review #{Ace::Review::VERSION}"
|
|
@@ -90,6 +92,11 @@ module Ace
|
|
|
90
92
|
return
|
|
91
93
|
end
|
|
92
94
|
|
|
95
|
+
if cli_options[:prepare_goals_brief]
|
|
96
|
+
prepare_goals_brief(cli_options)
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
93
100
|
# Type-convert numeric options (ace-support-cli returns strings, Thor converted to integers)
|
|
94
101
|
cli_options[:gh_timeout] = cli_options[:gh_timeout]&.to_i if cli_options[:gh_timeout]
|
|
95
102
|
|
|
@@ -101,6 +108,16 @@ module Ace
|
|
|
101
108
|
|
|
102
109
|
private
|
|
103
110
|
|
|
111
|
+
def prepare_goals_brief(cli_options)
|
|
112
|
+
raise Ace::Support::Cli::Error.new("--pr is required for goals-brief preparation") unless cli_options[:pr]
|
|
113
|
+
|
|
114
|
+
options = Models::ReviewOptions.new(build_review_options(cli_options))
|
|
115
|
+
result = Organisms::ReviewManager.new.prepare_goals_brief(options)
|
|
116
|
+
raise Ace::Support::Cli::Error.new(result[:error]) unless result[:success]
|
|
117
|
+
|
|
118
|
+
puts "✓ Goals brief #{result[:cache_hit] ? "reused" : "generated"}: #{result[:path]}"
|
|
119
|
+
end
|
|
120
|
+
|
|
104
121
|
def execute_review
|
|
105
122
|
display_config_summary
|
|
106
123
|
|
|
@@ -168,10 +185,8 @@ module Ace
|
|
|
168
185
|
validate_model_names(models)
|
|
169
186
|
# Store in :models (array) not :model (expects string)
|
|
170
187
|
options[:models] = models
|
|
171
|
-
options.delete(:model)
|
|
172
|
-
else
|
|
173
|
-
options.delete(:model)
|
|
174
188
|
end
|
|
189
|
+
options.delete(:model)
|
|
175
190
|
end
|
|
176
191
|
|
|
177
192
|
def validate_model_names(models)
|
|
@@ -194,6 +209,9 @@ module Ace
|
|
|
194
209
|
puts "✓ Review saved: #{result[:output_file]}"
|
|
195
210
|
elsif result[:session_dir]
|
|
196
211
|
puts "✓ Review session prepared: #{result[:session_dir]}"
|
|
212
|
+
if result[:budget]
|
|
213
|
+
puts " Estimated complete input: #{result[:budget][:total_tokens]} / #{result[:budget][:input_limit]} tokens"
|
|
214
|
+
end
|
|
197
215
|
|
|
198
216
|
# Display prompt files for ace-bundle workflow
|
|
199
217
|
if result[:system_prompt_file] && result[:user_prompt_file]
|
|
@@ -295,7 +313,7 @@ module Ace
|
|
|
295
313
|
puts
|
|
296
314
|
|
|
297
315
|
# Header
|
|
298
|
-
puts format("%-20s %-50s %-10s",
|
|
316
|
+
puts format("%-20s %-50s %-10s", *%w[Preset Description Source])
|
|
299
317
|
puts "-" * 80
|
|
300
318
|
|
|
301
319
|
# Load preset manager to get descriptions
|
data/lib/ace/review/cli.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Ace
|
|
|
16
16
|
# Separator for array options that won't conflict with internal commas
|
|
17
17
|
# ASCII Unit Separator (0x1F) is designed for separating fields
|
|
18
18
|
ARRAY_SEPARATOR = "\x1F"
|
|
19
|
+
CONTEXT_FLAGS = %w[--evidence-session].freeze
|
|
19
20
|
|
|
20
21
|
# Pre-process array options to work around ace-support-cli limitation
|
|
21
22
|
#
|
|
@@ -31,6 +32,7 @@ module Ace
|
|
|
31
32
|
i = 0
|
|
32
33
|
accumulated_subject = []
|
|
33
34
|
accumulated_model = []
|
|
35
|
+
accumulated_context = Hash.new { |hash, key| hash[key] = [] }
|
|
34
36
|
|
|
35
37
|
while i < args.length
|
|
36
38
|
arg = args[i]
|
|
@@ -51,6 +53,15 @@ module Ace
|
|
|
51
53
|
next
|
|
52
54
|
end
|
|
53
55
|
|
|
56
|
+
CONTEXT_FLAGS.each do |flag|
|
|
57
|
+
next unless arg == flag || arg.start_with?("#{flag}=")
|
|
58
|
+
|
|
59
|
+
accumulated_context[flag] << extract_flag_value(arg, args, i)
|
|
60
|
+
i = skip_to_next_arg(args, i)
|
|
61
|
+
break
|
|
62
|
+
end
|
|
63
|
+
next if CONTEXT_FLAGS.any? { |flag| arg == flag || arg.start_with?("#{flag}=") }
|
|
64
|
+
|
|
54
65
|
result << arg
|
|
55
66
|
i += 1
|
|
56
67
|
end
|
|
@@ -59,6 +70,9 @@ module Ace
|
|
|
59
70
|
result.insert(0, "--model", accumulated_model.join(",")) unless accumulated_model.empty?
|
|
60
71
|
# Subject uses ARRAY_SEPARATOR to preserve internal commas (e.g., files:a.rb,b.rb)
|
|
61
72
|
result.insert(0, "--subject", accumulated_subject.join(ARRAY_SEPARATOR)) unless accumulated_subject.empty?
|
|
73
|
+
accumulated_context.each do |flag, values|
|
|
74
|
+
result.insert(0, flag, values.join(ARRAY_SEPARATOR)) unless values.empty?
|
|
75
|
+
end
|
|
62
76
|
|
|
63
77
|
result
|
|
64
78
|
end
|
|
@@ -85,7 +99,9 @@ module Ace
|
|
|
85
99
|
# @param index [Integer] Current index
|
|
86
100
|
# @return [Integer] Next index to process
|
|
87
101
|
def self.skip_to_next_arg(args, index)
|
|
88
|
-
if args[index].include?("=")
|
|
102
|
+
if args[index].include?("=")
|
|
103
|
+
index + 1
|
|
104
|
+
elsif index + 1 < args.length && !args[index + 1].start_with?("--")
|
|
89
105
|
index + 2
|
|
90
106
|
else
|
|
91
107
|
index + 1
|
|
@@ -68,7 +68,7 @@ module Ace
|
|
|
68
68
|
|
|
69
69
|
@status = attrs[:status] || "draft"
|
|
70
70
|
@priority = attrs[:priority] || "medium"
|
|
71
|
-
@created = attrs[:created] || Time.now.utc.iso8601
|
|
71
|
+
@created = attrs[:created] || Time.now.utc.iso8601(6)
|
|
72
72
|
@updated = attrs[:updated] || @created
|
|
73
73
|
@finding = attrs[:finding]
|
|
74
74
|
@context = attrs[:context]
|
|
@@ -130,7 +130,7 @@ module Ace
|
|
|
130
130
|
# @return [FeedbackItem] New instance with merged attributes
|
|
131
131
|
def dup_with(**changes)
|
|
132
132
|
# Auto-update the updated timestamp when making changes
|
|
133
|
-
changes[:updated] ||= Time.now.utc.iso8601
|
|
133
|
+
changes[:updated] ||= Time.now.utc.iso8601(6)
|
|
134
134
|
FeedbackItem.new(to_h.merge(stringify_keys(changes)))
|
|
135
135
|
end
|
|
136
136
|
|
|
@@ -11,6 +11,7 @@ module Ace
|
|
|
11
11
|
:auto_execute, :save_session, :session_dir,
|
|
12
12
|
:pr, :post_comment, :pr_metadata, :gh_timeout,
|
|
13
13
|
:pr_comments, :pr_comment_data,
|
|
14
|
+
:evidence_sessions,
|
|
14
15
|
:no_feedback, :feedback_model,
|
|
15
16
|
:list_presets, :list_prompts, :help
|
|
16
17
|
|
|
@@ -57,6 +58,7 @@ module Ace
|
|
|
57
58
|
# PR comment options
|
|
58
59
|
@pr_comments = hash[:pr_comments] # nil = use default, true/false = explicit
|
|
59
60
|
@pr_comment_data = nil # Populated during execution
|
|
61
|
+
@evidence_sessions = Array(hash[:evidence_session]).flat_map { |value| value.to_s.split("\x1F") }.reject(&:empty?)
|
|
60
62
|
|
|
61
63
|
# Feedback extraction options
|
|
62
64
|
@no_feedback = hash[:no_feedback] || false
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "../atoms/diff_boundary_finder"
|
|
5
|
+
require_relative "subject_filter"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Review
|
|
9
|
+
module Molecules
|
|
10
|
+
# Selects complete file diffs before ace-bundle renders a review packet.
|
|
11
|
+
# Refuses unparsed input instead of silently dropping a changed file.
|
|
12
|
+
module DiffScope
|
|
13
|
+
def self.select(diff, patterns = nil, groups: nil)
|
|
14
|
+
patterns = patterns.transform_keys(&:to_s) if patterns.is_a?(Hash)
|
|
15
|
+
groups = groups.map { |group| group.is_a?(Hash) ? group.transform_keys(&:to_s) : group } if groups.is_a?(Array)
|
|
16
|
+
valid_groups = groups.nil? || (groups.is_a?(Array) && groups.all? { |group| valid_patterns?(group) && !group.nil? })
|
|
17
|
+
unless valid_patterns?(patterns) && valid_groups
|
|
18
|
+
return {success: false, error: "Review file patterns/groups must contain include/exclude arrays of glob strings"}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
blocks = Atoms::DiffBoundaryFinder.parse(diff)
|
|
22
|
+
unless blocks.any? && blocks.map { |block| block[:content] }.join == diff
|
|
23
|
+
return {success: false, error: "Cannot account for every PR diff block; review input is incomplete"}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
filters = [patterns, *Array(groups)].compact
|
|
27
|
+
selected, excluded = blocks.partition { |block| selected_block?(block, filters) }
|
|
28
|
+
if selected.empty?
|
|
29
|
+
return {success: false, error: "Review scope contains no changed files"}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
success: true,
|
|
34
|
+
diff: selected.map { |block| block[:content] }.join,
|
|
35
|
+
manifest: {
|
|
36
|
+
raw_sha256: Digest::SHA256.hexdigest(diff),
|
|
37
|
+
selected_sha256: Digest::SHA256.hexdigest(selected.map { |block| block[:content] }.join),
|
|
38
|
+
selected_files: selected.map { |block| block[:path] },
|
|
39
|
+
excluded_files: excluded.map { |block| block[:path] },
|
|
40
|
+
binary_files: binary_paths(blocks),
|
|
41
|
+
renames: selected.filter_map do |block|
|
|
42
|
+
{from: block[:old_path], to: block[:path]} if block[:old_path] != block[:path]
|
|
43
|
+
end,
|
|
44
|
+
received_diff_accounted_for: true,
|
|
45
|
+
file_pattern_groups: filters
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
rescue ArgumentError => e
|
|
49
|
+
{success: false, error: "Cannot account for every PR diff block: #{e.message}"}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.binary_paths(blocks)
|
|
53
|
+
blocks.select { |block| binary_only?(block) }.map { |block| block[:path] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.valid_patterns?(patterns)
|
|
57
|
+
return true if patterns.nil?
|
|
58
|
+
return false unless patterns.is_a?(Hash)
|
|
59
|
+
return false unless (patterns.keys.map(&:to_s) - %w[include exclude]).empty?
|
|
60
|
+
|
|
61
|
+
patterns.values.all? { |value| value.is_a?(Array) && value.all? { |item| item.is_a?(String) && !item.empty? } }
|
|
62
|
+
end
|
|
63
|
+
private_class_method :valid_patterns?
|
|
64
|
+
|
|
65
|
+
def self.binary_only?(block)
|
|
66
|
+
block[:content].each_line.any? do |line|
|
|
67
|
+
line.start_with?("Binary files ", "GIT binary patch")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
private_class_method :binary_only?
|
|
71
|
+
|
|
72
|
+
def self.selected_block?(block, filters)
|
|
73
|
+
return true if filters.empty?
|
|
74
|
+
paths = [block[:path], block[:old_path]].compact.uniq
|
|
75
|
+
return false if filters.any? do |patterns|
|
|
76
|
+
paths.any? do |path|
|
|
77
|
+
Array(patterns["exclude"]).any? { |pattern| SubjectFilter.glob_match?(pattern, path) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
paths.any? do |path|
|
|
82
|
+
filters.all? do |patterns|
|
|
83
|
+
includes = Array(patterns["include"])
|
|
84
|
+
includes.empty? || includes.any? { |pattern| SubjectFilter.glob_match?(pattern, path) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
private_class_method :selected_block?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "tmpdir"
|
|
6
|
+
require_relative "../atoms/prompt_budget"
|
|
6
7
|
|
|
7
8
|
module Ace
|
|
8
9
|
module Review
|
|
@@ -28,7 +29,7 @@ module Ace
|
|
|
28
29
|
class FeedbackSynthesizer
|
|
29
30
|
# Cached system prompt and prompt-path lookups to avoid repeated
|
|
30
31
|
# shell/file reads during test and command-heavy runs.
|
|
31
|
-
FALLBACK_SYSTEM_PROMPT = <<~PROMPT
|
|
32
|
+
FALLBACK_SYSTEM_PROMPT = <<~PROMPT
|
|
32
33
|
Synthesize feedback from code review reports into unique findings.
|
|
33
34
|
|
|
34
35
|
For each unique issue found:
|
|
@@ -56,6 +57,8 @@ module Ace
|
|
|
56
57
|
- When only one report: extract all findings as-is with that reviewer
|
|
57
58
|
- When multiple reports: deduplicate findings that describe the same issue
|
|
58
59
|
- When multiple reviewers find the same issue, list ALL of them in reviewers array
|
|
60
|
+
- Preserve every distinct finding, including ones reported by only one reviewer
|
|
61
|
+
- Consensus is metadata, never a reason to omit a finding
|
|
59
62
|
- Merge file arrays from all sources for each finding
|
|
60
63
|
- Return ONLY the JSON, no markdown code fences
|
|
61
64
|
PROMPT
|
|
@@ -66,10 +69,10 @@ module Ace
|
|
|
66
69
|
system_prompt_cache.fetch(prompt_name) do
|
|
67
70
|
prompt_path = resolve_prompt_path_cached(prompt_name)
|
|
68
71
|
|
|
69
|
-
if prompt_path && File.exist?(prompt_path)
|
|
70
|
-
|
|
72
|
+
system_prompt_cache[prompt_name] = if prompt_path && File.exist?(prompt_path)
|
|
73
|
+
File.read(prompt_path)
|
|
71
74
|
else
|
|
72
|
-
|
|
75
|
+
FALLBACK_SYSTEM_PROMPT
|
|
73
76
|
end
|
|
74
77
|
end
|
|
75
78
|
end
|
|
@@ -117,9 +120,6 @@ module Ace
|
|
|
117
120
|
end
|
|
118
121
|
end
|
|
119
122
|
|
|
120
|
-
# Maximum combined report size before truncation (characters)
|
|
121
|
-
MAX_COMBINED_SIZE = 200_000
|
|
122
|
-
|
|
123
123
|
attr_reader :llm_executor
|
|
124
124
|
|
|
125
125
|
def initialize(llm_executor: nil)
|
|
@@ -161,26 +161,19 @@ module Ace
|
|
|
161
161
|
# @param report_paths [Array<String>] Paths to report files
|
|
162
162
|
# @return [Array<Hash>] Array of report hashes with :path, :reviewer, :content
|
|
163
163
|
def read_reports(report_paths)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
report_paths.each do |path|
|
|
167
|
-
next unless File.exist?(path)
|
|
168
|
-
|
|
164
|
+
report_paths.map do |path|
|
|
165
|
+
raise ArgumentError, "Review report is missing: #{path}" unless File.file?(path)
|
|
169
166
|
content = File.read(path)
|
|
170
|
-
|
|
167
|
+
raise ArgumentError, "Review report is empty: #{path}" if content.strip.empty?
|
|
171
168
|
|
|
172
169
|
reviewer = extract_reviewer_from_filename(path)
|
|
173
|
-
|
|
170
|
+
{
|
|
174
171
|
path: path,
|
|
175
172
|
reviewer: reviewer,
|
|
176
173
|
content: content,
|
|
177
174
|
size: content.bytesize
|
|
178
175
|
}
|
|
179
|
-
rescue => e
|
|
180
|
-
warn "Warning: Failed to read report #{path}: #{e.message}" if Ace::Review.debug?
|
|
181
176
|
end
|
|
182
|
-
|
|
183
|
-
reports
|
|
184
177
|
end
|
|
185
178
|
|
|
186
179
|
# Synthesize reports into unified findings
|
|
@@ -200,6 +193,14 @@ module Ace
|
|
|
200
193
|
output_file = File.join(session_dir, "feedback-synthesis.raw.txt")
|
|
201
194
|
|
|
202
195
|
synthesis_model = model || default_synthesis_model
|
|
196
|
+
budget = Atoms::PromptBudget.check(system_prompt: system_prompt, user_prompt: user_prompt,
|
|
197
|
+
models: [synthesis_model])
|
|
198
|
+
unless budget[:success]
|
|
199
|
+
return synthesize_reports_separately(reports, session_dir, synthesis_model) if reports.length > 1
|
|
200
|
+
|
|
201
|
+
return error_result("Synthesis prompt exceeds budget: #{budget[:errors].join("; ")}")
|
|
202
|
+
end
|
|
203
|
+
|
|
203
204
|
display_synthesis_start(reports.size, synthesis_model)
|
|
204
205
|
|
|
205
206
|
result = @llm_executor.execute(
|
|
@@ -225,6 +226,26 @@ module Ace
|
|
|
225
226
|
)
|
|
226
227
|
end
|
|
227
228
|
|
|
229
|
+
def synthesize_reports_separately(reports, session_dir, model)
|
|
230
|
+
extracted = reports.each_with_index.map do |report, index|
|
|
231
|
+
part_dir = File.join(session_dir, "report-#{index + 1}")
|
|
232
|
+
FileUtils.mkdir_p(part_dir)
|
|
233
|
+
result = synthesize_reports([report], part_dir, model)
|
|
234
|
+
return error_result("Report #{index + 1} could not be extracted: #{result[:error]}") unless result[:success]
|
|
235
|
+
|
|
236
|
+
result[:items]
|
|
237
|
+
end.flatten
|
|
238
|
+
|
|
239
|
+
# Keep every finding. Per-report extraction cannot safely infer that
|
|
240
|
+
# two differently worded findings are the same defect.
|
|
241
|
+
ids = extracted.empty? ? [] : Atoms::FeedbackIdGenerator.generate_sequence(extracted.length)
|
|
242
|
+
items = extracted.each_with_index.map { |item, index| item.dup_with(id: ids[index]) }
|
|
243
|
+
persist_cleaned_json(session_dir, {"findings" => items.map(&:to_h)})
|
|
244
|
+
{success: true, items: items,
|
|
245
|
+
metadata: {total_findings: items.length, consensus_findings: 0,
|
|
246
|
+
reviewers_count: reports.length, segmented: true}}
|
|
247
|
+
end
|
|
248
|
+
|
|
228
249
|
# Load the synthesis system prompt
|
|
229
250
|
#
|
|
230
251
|
# @return [String] System prompt content
|
|
@@ -244,9 +265,6 @@ module Ace
|
|
|
244
265
|
# @param reports [Array<Hash>] Array of report data
|
|
245
266
|
# @return [String] User prompt
|
|
246
267
|
def build_reports_prompt(reports)
|
|
247
|
-
# Check total size
|
|
248
|
-
total_size = reports.sum { |r| r[:size] }
|
|
249
|
-
|
|
250
268
|
content = "Synthesize these #{reports.size} code review report"
|
|
251
269
|
content += (reports.size == 1) ? "." : "s into unique, deduplicated findings."
|
|
252
270
|
content += "\n\n"
|
|
@@ -256,16 +274,7 @@ module Ace
|
|
|
256
274
|
content += "---\n"
|
|
257
275
|
content += "## Report #{idx + 1}: #{report[:reviewer]}\n\n"
|
|
258
276
|
|
|
259
|
-
|
|
260
|
-
report_content = report[:content]
|
|
261
|
-
if total_size > MAX_COMBINED_SIZE
|
|
262
|
-
max_per_report = MAX_COMBINED_SIZE / reports.size
|
|
263
|
-
if report_content.length > max_per_report
|
|
264
|
-
report_content = report_content[0, max_per_report] + "\n\n[... truncated ...]"
|
|
265
|
-
end
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
content += report_content
|
|
277
|
+
content += report[:content]
|
|
269
278
|
content += "\n\n"
|
|
270
279
|
end
|
|
271
280
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ace/git/github"
|
|
3
4
|
require "tempfile"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
@@ -17,11 +18,11 @@ module Ace
|
|
|
17
18
|
# @return [Hash] Result with :success, :comment_url, :preview, :error
|
|
18
19
|
def self.post_comment(pr_identifier, review_text, options = {})
|
|
19
20
|
# Parse identifier using ace-git
|
|
20
|
-
parsed = Ace::Git::
|
|
21
|
+
parsed = Ace::Git::Github::PrIdentifier.parse(pr_identifier)
|
|
21
22
|
gh_format = parsed.gh_format
|
|
22
23
|
|
|
23
24
|
# Check PR state before posting
|
|
24
|
-
state_check = check_pr_state(
|
|
25
|
+
state_check = check_pr_state(parsed)
|
|
25
26
|
return state_check unless state_check[:success]
|
|
26
27
|
|
|
27
28
|
# Format comment with metadata
|
|
@@ -38,7 +39,7 @@ module Ace
|
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
# Post comment via gh CLI
|
|
41
|
-
result = post_via_gh(
|
|
42
|
+
result = post_via_gh(parsed, formatted_comment)
|
|
42
43
|
|
|
43
44
|
if result[:success]
|
|
44
45
|
comment_url = extract_comment_url(result[:stdout], parsed.to_h)
|
|
@@ -54,7 +55,7 @@ module Ace
|
|
|
54
55
|
}
|
|
55
56
|
end
|
|
56
57
|
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
|
|
57
|
-
Ace::Git::
|
|
58
|
+
Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError
|
|
58
59
|
raise
|
|
59
60
|
rescue => e
|
|
60
61
|
{
|
|
@@ -65,13 +66,13 @@ module Ace
|
|
|
65
66
|
|
|
66
67
|
# Check if PR is in a state that allows comments
|
|
67
68
|
#
|
|
68
|
-
# @param
|
|
69
|
+
# @param parsed [Ace::Git::Github::PrIdentifier::ParseResult] Parsed PR target
|
|
69
70
|
# @return [Hash] Result with :success or :error
|
|
70
|
-
def self.check_pr_state(
|
|
71
|
+
def self.check_pr_state(parsed)
|
|
71
72
|
# Fetch PR metadata
|
|
72
|
-
result = Ace::Git::
|
|
73
|
+
result = Ace::Git::Github::CliExecutor.execute(
|
|
73
74
|
"pr",
|
|
74
|
-
["view",
|
|
75
|
+
["view", *parsed.cli_target_args, "--json", "state,number"]
|
|
75
76
|
)
|
|
76
77
|
|
|
77
78
|
unless result[:success]
|
|
@@ -160,19 +161,19 @@ module Ace
|
|
|
160
161
|
|
|
161
162
|
# Post comment via gh CLI using a temp file for the body
|
|
162
163
|
#
|
|
163
|
-
# @param
|
|
164
|
+
# @param parsed [Ace::Git::Github::PrIdentifier::ParseResult] Parsed PR target
|
|
164
165
|
# @param comment_body [String] Comment content
|
|
165
166
|
# @return [Hash] Result from gh CLI
|
|
166
|
-
def self.post_via_gh(
|
|
167
|
+
def self.post_via_gh(parsed, comment_body)
|
|
167
168
|
# Write comment to temp file (gh pr comment reads from file)
|
|
168
169
|
Tempfile.create(["review-comment", ".md"]) do |file|
|
|
169
170
|
file.write(comment_body)
|
|
170
171
|
file.flush
|
|
171
172
|
|
|
172
173
|
# Post using gh pr comment
|
|
173
|
-
Ace::Git::
|
|
174
|
+
Ace::Git::Github::CliExecutor.execute(
|
|
174
175
|
"pr",
|
|
175
|
-
["comment",
|
|
176
|
+
["comment", *parsed.cli_target_args, "--body-file", file.path]
|
|
176
177
|
)
|
|
177
178
|
end
|
|
178
179
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ace/git/github"
|
|
3
4
|
require "json"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
@@ -23,8 +24,7 @@ module Ace
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Parse identifier using ace-git
|
|
26
|
-
parsed = Ace::Git::
|
|
27
|
-
gh_format = parsed.gh_format
|
|
27
|
+
parsed = Ace::Git::Github::PrIdentifier.parse(pr_identifier)
|
|
28
28
|
|
|
29
29
|
# Build message
|
|
30
30
|
short_sha = commit_sha.to_s[0..6]
|
|
@@ -34,9 +34,9 @@ module Ace
|
|
|
34
34
|
timeout = options[:timeout] || 30
|
|
35
35
|
|
|
36
36
|
# Post comment using gh CLI
|
|
37
|
-
result = Ace::Git::
|
|
37
|
+
result = Ace::Git::Github::CliExecutor.execute(
|
|
38
38
|
"pr",
|
|
39
|
-
["comment",
|
|
39
|
+
["comment", *parsed.cli_target_args, "--body", body],
|
|
40
40
|
timeout: timeout
|
|
41
41
|
)
|
|
42
42
|
|
|
@@ -55,7 +55,7 @@ module Ace
|
|
|
55
55
|
}
|
|
56
56
|
end
|
|
57
57
|
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
|
|
58
|
-
Ace::Git::
|
|
58
|
+
Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError
|
|
59
59
|
raise
|
|
60
60
|
rescue => e
|
|
61
61
|
{
|
|
@@ -92,7 +92,7 @@ module Ace
|
|
|
92
92
|
mutation = build_resolve_thread_mutation(thread_id)
|
|
93
93
|
|
|
94
94
|
# Execute via gh api graphql
|
|
95
|
-
result = Ace::Git::
|
|
95
|
+
result = Ace::Git::Github::CliExecutor.execute(
|
|
96
96
|
"api",
|
|
97
97
|
["graphql", "-f", "query=#{mutation}"],
|
|
98
98
|
timeout: timeout
|
|
@@ -120,7 +120,7 @@ module Ace
|
|
|
120
120
|
}
|
|
121
121
|
end
|
|
122
122
|
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
|
|
123
|
-
Ace::Git::
|
|
123
|
+
Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError
|
|
124
124
|
raise
|
|
125
125
|
rescue => e
|
|
126
126
|
{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "ace/git/github"
|
|
3
4
|
require "json"
|
|
4
5
|
require_relative "../atoms/retry_with_backoff"
|
|
5
6
|
|
|
@@ -71,7 +72,7 @@ module Ace
|
|
|
71
72
|
# @return [Hash] Result with :success, :comments, :reviews, :review_threads, :error
|
|
72
73
|
def self.fetch(pr_identifier, options = {})
|
|
73
74
|
# Parse identifier to get gh CLI format using ace-git
|
|
74
|
-
parsed = Ace::Git::
|
|
75
|
+
parsed = Ace::Git::Github::PrIdentifier.parse(pr_identifier)
|
|
75
76
|
gh_format = parsed.gh_format
|
|
76
77
|
|
|
77
78
|
# Default timeout for PR operations
|
|
@@ -82,7 +83,7 @@ module Ace
|
|
|
82
83
|
fields = "comments,reviews,number,title,author"
|
|
83
84
|
|
|
84
85
|
result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
|
|
85
|
-
Ace::Git::
|
|
86
|
+
Ace::Git::Github::CliExecutor.execute("pr", ["view", *parsed.cli_target_args, "--json", fields], timeout: timeout)
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
if result[:success]
|
|
@@ -117,7 +118,7 @@ module Ace
|
|
|
117
118
|
error: "Failed to parse PR comments: #{e.message}"
|
|
118
119
|
}
|
|
119
120
|
rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError,
|
|
120
|
-
Ace::Git::
|
|
121
|
+
Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError
|
|
121
122
|
raise
|
|
122
123
|
rescue => e
|
|
123
124
|
{
|
|
@@ -251,7 +252,7 @@ module Ace
|
|
|
251
252
|
|
|
252
253
|
# Execute GraphQL query
|
|
253
254
|
result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
|
|
254
|
-
Ace::Git::
|
|
255
|
+
Ace::Git::Github::CliExecutor.execute(
|
|
255
256
|
"api",
|
|
256
257
|
[
|
|
257
258
|
"graphql",
|
|
@@ -383,7 +384,7 @@ module Ace
|
|
|
383
384
|
# @return [String, nil] "owner/name" format or nil if not a GitHub repo
|
|
384
385
|
def self.discover_repo_from_remote(options = {})
|
|
385
386
|
timeout = options[:timeout] || 10
|
|
386
|
-
result = Ace::Git::
|
|
387
|
+
result = Ace::Git::Github::CliExecutor.execute(
|
|
387
388
|
"repo",
|
|
388
389
|
["view", "--json", "owner,name"],
|
|
389
390
|
timeout: timeout
|
|
@@ -396,7 +397,7 @@ module Ace
|
|
|
396
397
|
return nil unless owner && name
|
|
397
398
|
|
|
398
399
|
"#{owner}/#{name}"
|
|
399
|
-
rescue
|
|
400
|
+
rescue
|
|
400
401
|
nil
|
|
401
402
|
end
|
|
402
403
|
|