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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/review/presets/integration.yml +11 -0
  3. data/CHANGELOG.md +13 -0
  4. data/handbook/guides/code-review-process.g.md +35 -0
  5. data/handbook/prompts/focus/phase/integration.md +3 -0
  6. data/handbook/prompts/synthesize-feedback.system.md +2 -1
  7. data/handbook/skills/as-review-pr/SKILL.md +3 -6
  8. data/handbook/workflow-instructions/review/pr.wf.md +27 -261
  9. data/lib/ace/review/atoms/diff_boundary_finder.rb +47 -7
  10. data/lib/ace/review/atoms/prompt_budget.rb +79 -0
  11. data/lib/ace/review/atoms/token_estimator.rb +9 -3
  12. data/lib/ace/review/cli/commands/review.rb +23 -5
  13. data/lib/ace/review/cli.rb +17 -1
  14. data/lib/ace/review/models/feedback_item.rb +2 -2
  15. data/lib/ace/review/models/review_options.rb +2 -0
  16. data/lib/ace/review/molecules/diff_scope.rb +92 -0
  17. data/lib/ace/review/molecules/feedback_synthesizer.rb +40 -31
  18. data/lib/ace/review/molecules/gh_comment_poster.rb +13 -12
  19. data/lib/ace/review/molecules/gh_comment_resolver.rb +7 -7
  20. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +7 -6
  21. data/lib/ace/review/molecules/gh_pr_fetcher.rb +88 -23
  22. data/lib/ace/review/molecules/goals_brief.rb +236 -0
  23. data/lib/ace/review/molecules/llm_executor.rb +22 -0
  24. data/lib/ace/review/molecules/multi_model_executor.rb +1 -0
  25. data/lib/ace/review/molecules/preset_manager.rb +18 -2
  26. data/lib/ace/review/molecules/review_evidence.rb +52 -0
  27. data/lib/ace/review/molecules/subject_extractor.rb +2 -1
  28. data/lib/ace/review/molecules/subject_filter.rb +9 -2
  29. data/lib/ace/review/organisms/feedback_manager.rb +55 -25
  30. data/lib/ace/review/organisms/review_manager.rb +568 -76
  31. data/lib/ace/review/version.rb +1 -1
  32. data/lib/ace/review.rb +2 -1
  33. metadata +44 -11
  34. 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.reject { |k, _| k == :args }
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", "Preset", "Description", "Source")
316
+ puts format("%-20s %-50s %-10s", *%w[Preset Description Source])
299
317
  puts "-" * 80
300
318
 
301
319
  # Load preset manager to get descriptions
@@ -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?("=") || (index + 1 < args.length && !args[index + 1].start_with?("--"))
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.freeze
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
- system_prompt_cache[prompt_name] = File.read(prompt_path)
72
+ system_prompt_cache[prompt_name] = if prompt_path && File.exist?(prompt_path)
73
+ File.read(prompt_path)
71
74
  else
72
- system_prompt_cache[prompt_name] = FALLBACK_SYSTEM_PROMPT
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
- reports = []
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
- next if content.strip.empty?
167
+ raise ArgumentError, "Review report is empty: #{path}" if content.strip.empty?
171
168
 
172
169
  reviewer = extract_reviewer_from_filename(path)
173
- reports << {
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
- # Truncate individual reports if total is too large
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::Atoms::PrIdentifierParser.parse(pr_identifier)
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(gh_format)
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(gh_format, formatted_comment)
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::GhNotInstalledError, Ace::Git::GhAuthenticationError
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 gh_format [String] PR identifier in gh format
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(gh_format)
71
+ def self.check_pr_state(parsed)
71
72
  # Fetch PR metadata
72
- result = Ace::Git::Molecules::GhCliExecutor.execute(
73
+ result = Ace::Git::Github::CliExecutor.execute(
73
74
  "pr",
74
- ["view", gh_format, "--json", "state,number"]
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 gh_format [String] PR identifier in gh format
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(gh_format, comment_body)
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::Molecules::GhCliExecutor.execute(
174
+ Ace::Git::Github::CliExecutor.execute(
174
175
  "pr",
175
- ["comment", gh_format, "--body-file", file.path]
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::Atoms::PrIdentifierParser.parse(pr_identifier)
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::Molecules::GhCliExecutor.execute(
37
+ result = Ace::Git::Github::CliExecutor.execute(
38
38
  "pr",
39
- ["comment", gh_format, "--body", body],
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::GhNotInstalledError, Ace::Git::GhAuthenticationError
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::Molecules::GhCliExecutor.execute(
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::GhNotInstalledError, Ace::Git::GhAuthenticationError
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::Atoms::PrIdentifierParser.parse(pr_identifier)
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::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
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::GhNotInstalledError, Ace::Git::GhAuthenticationError
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::Molecules::GhCliExecutor.execute(
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::Molecules::GhCliExecutor.execute(
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 JSON::ParserError, StandardError
400
+ rescue
400
401
  nil
401
402
  end
402
403