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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. metadata +351 -0
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../atoms/retry_with_backoff"
5
+
6
+ module Ace
7
+ module Review
8
+ module Molecules
9
+ # Fetch PR comments and reviews via gh CLI
10
+ class GhPrCommentFetcher
11
+ # Known bot usernames to filter out
12
+ BOT_PATTERNS = %w[
13
+ dependabot
14
+ github-actions
15
+ renovate
16
+ codecov
17
+ sonarcloud
18
+ snyk
19
+ mergify
20
+ greenkeeper
21
+ ].freeze
22
+
23
+ # GraphQL query limits
24
+ # Note: These are hardcoded limits. PRs exceeding these will have truncated data.
25
+ MAX_REVIEW_THREADS = 100
26
+ MAX_COMMENTS_PER_THREAD = 50
27
+
28
+ # GraphQL query template for fetching review threads
29
+ REVIEW_THREADS_QUERY = <<~GRAPHQL
30
+ query($owner: String!, $repo: String!, $number: Int!) {
31
+ repository(owner: $owner, name: $repo) {
32
+ pullRequest(number: $number) {
33
+ reviewThreads(first: #{MAX_REVIEW_THREADS}) {
34
+ totalCount
35
+ pageInfo {
36
+ hasNextPage
37
+ }
38
+ nodes {
39
+ id
40
+ isResolved
41
+ path
42
+ line
43
+ comments(first: #{MAX_COMMENTS_PER_THREAD}) {
44
+ totalCount
45
+ pageInfo {
46
+ hasNextPage
47
+ }
48
+ nodes {
49
+ id
50
+ body
51
+ author { login }
52
+ createdAt
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ GRAPHQL
61
+
62
+ # Fetch PR comments and reviews
63
+ #
64
+ # @param pr_identifier [String] PR identifier (number, URL, or owner/repo#number)
65
+ # @param options [Hash] Fetch options
66
+ # @option options [Integer] :max_retries Maximum retry attempts (default: 3)
67
+ # @option options [Integer] :initial_backoff Initial backoff in seconds (default: 1)
68
+ # @option options [Integer] :timeout Timeout in seconds for gh CLI (default: 30)
69
+ # @option options [Boolean] :include_resolved Include resolved review threads (default: false)
70
+ # @option options [Boolean] :include_bots Include bot comments (default: false)
71
+ # @return [Hash] Result with :success, :comments, :reviews, :review_threads, :error
72
+ def self.fetch(pr_identifier, options = {})
73
+ # Parse identifier to get gh CLI format using ace-git
74
+ parsed = Ace::Git::Atoms::PrIdentifierParser.parse(pr_identifier)
75
+ gh_format = parsed.gh_format
76
+
77
+ # Default timeout for PR operations
78
+ timeout = options[:timeout] || 30
79
+
80
+ # Fetch comments and reviews as JSON
81
+ # Fields: comments (issue-level), reviews (code review objects)
82
+ fields = "comments,reviews,number,title,author"
83
+
84
+ result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
85
+ Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
86
+ end
87
+
88
+ if result[:success]
89
+ data = JSON.parse(result[:stdout])
90
+
91
+ # Extract and structure comments
92
+ comments = extract_comments(data, options)
93
+ reviews = extract_reviews(data, options)
94
+
95
+ # Fetch review threads via GraphQL (inline code comments)
96
+ # Convert parsed to hash for fetch_review_threads which expects hash access
97
+ review_threads = fetch_review_threads(parsed.to_h, options)
98
+
99
+ {
100
+ success: true,
101
+ comments: comments,
102
+ reviews: reviews,
103
+ review_threads: review_threads,
104
+ pr_number: data["number"],
105
+ pr_title: data["title"],
106
+ pr_author: data.dig("author", "login"),
107
+ identifier: gh_format,
108
+ parsed: parsed.to_h,
109
+ raw_data: data
110
+ }
111
+ else
112
+ handle_fetch_error(result, pr_identifier)
113
+ end
114
+ rescue JSON::ParserError => e
115
+ {
116
+ success: false,
117
+ error: "Failed to parse PR comments: #{e.message}"
118
+ }
119
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
120
+ raise
121
+ rescue => e
122
+ {
123
+ success: false,
124
+ error: "Failed to fetch PR comments: #{e.message}"
125
+ }
126
+ end
127
+
128
+ # Check if there are any meaningful comments
129
+ #
130
+ # @param result [Hash] Result from fetch
131
+ # @return [Boolean] true if there are comments, reviews, or review threads worth reporting
132
+ def self.has_comments?(result)
133
+ return false unless result[:success]
134
+
135
+ (result[:comments]&.any? || false) ||
136
+ (result[:reviews]&.any? || false) ||
137
+ (result[:review_threads]&.any? || false)
138
+ end
139
+
140
+ private
141
+
142
+ # Extract and structure issue-level comments
143
+ #
144
+ # @param data [Hash] Parsed JSON from gh CLI
145
+ # @param options [Hash] Fetch options
146
+ # @return [Array<Hash>] Structured comments
147
+ def self.extract_comments(data, options = {})
148
+ comments = data["comments"] || []
149
+ include_bots = options[:include_bots] || false
150
+
151
+ comments.filter_map do |comment|
152
+ author = comment.dig("author", "login") || "unknown"
153
+
154
+ # Skip bot comments unless explicitly included
155
+ next if !include_bots && bot_author?(author)
156
+
157
+ # Skip empty comments
158
+ body = comment["body"]&.strip
159
+ next if body.nil? || body.empty?
160
+
161
+ {
162
+ type: "issue_comment",
163
+ id: comment["id"] || "IC_#{comment["databaseId"]}",
164
+ author: author,
165
+ body: body,
166
+ created_at: comment["createdAt"],
167
+ url: comment["url"]
168
+ }
169
+ end
170
+ end
171
+
172
+ # Extract and structure code reviews
173
+ #
174
+ # @param data [Hash] Parsed JSON from gh CLI
175
+ # @param options [Hash] Fetch options
176
+ # @return [Array<Hash>] Structured reviews
177
+ def self.extract_reviews(data, options = {})
178
+ reviews = data["reviews"] || []
179
+ include_bots = options[:include_bots] || false
180
+
181
+ reviews.filter_map do |review|
182
+ author = review.dig("author", "login") || "unknown"
183
+
184
+ # Skip bot reviews unless explicitly included
185
+ next if !include_bots && bot_author?(author)
186
+
187
+ state = review["state"] || "COMMENTED"
188
+ body = review["body"]&.strip
189
+
190
+ # Include reviews with meaningful state even if body is empty
191
+ # Approvals and change-requests signal important reviewer decisions
192
+ has_meaningful_state = %w[APPROVED CHANGES_REQUESTED].include?(state)
193
+ next if (body.nil? || body.empty?) && !has_meaningful_state
194
+
195
+ # Set placeholder body for state-only reviews
196
+ effective_body = if body.nil? || body.empty?
197
+ case state
198
+ when "APPROVED" then "(Approved without comment)"
199
+ when "CHANGES_REQUESTED" then "(Changes requested without comment)"
200
+ else body
201
+ end
202
+ else
203
+ body
204
+ end
205
+
206
+ {
207
+ type: "review",
208
+ id: review["id"] || "PRR_#{review["databaseId"]}",
209
+ author: author,
210
+ state: state,
211
+ body: effective_body,
212
+ created_at: review["submittedAt"] || review["createdAt"],
213
+ url: review["url"]
214
+ }
215
+ end
216
+ end
217
+
218
+ # Fetch review threads via GraphQL API
219
+ #
220
+ # @param parsed [Hash] Parsed PR identifier with :repo (owner/repo format), :number
221
+ # @param options [Hash] Fetch options
222
+ # @return [Array<Hash>] Structured review threads, empty array on failure
223
+ def self.fetch_review_threads(parsed, options = {})
224
+ repo_full = parsed[:repo]
225
+ number = parsed[:number]
226
+
227
+ # Try to discover repo from git remote if not provided in identifier
228
+ if repo_full.nil? && number
229
+ repo_full = discover_repo_from_remote(options)
230
+ end
231
+
232
+ # Skip if we still don't have repo info
233
+ unless repo_full && number
234
+ warn "Warning: Cannot fetch inline code comments - repository info not available. " \
235
+ "Use full PR format (owner/repo#number) or run from within a git repository."
236
+ return []
237
+ end
238
+
239
+ # Parse owner/repo from combined format (e.g., "owner/repo")
240
+ parts = repo_full.split("/", 2)
241
+ unless parts.length == 2
242
+ warn "Warning: Cannot fetch inline code comments - invalid repo format: #{repo_full}"
243
+ return []
244
+ end
245
+ owner = parts[0]
246
+ repo = parts[1]
247
+
248
+ timeout = options[:timeout] || 30
249
+ include_resolved = options[:include_resolved] || false
250
+
251
+ # Execute GraphQL query
252
+ result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
253
+ Ace::Review::Molecules::GhCliExecutor.execute(
254
+ "api",
255
+ [
256
+ "graphql",
257
+ "-f", "query=#{REVIEW_THREADS_QUERY}",
258
+ "-F", "owner=#{owner}",
259
+ "-F", "repo=#{repo}",
260
+ "-F", "number=#{number}"
261
+ ],
262
+ timeout: timeout
263
+ )
264
+ end
265
+
266
+ return [] unless result[:success]
267
+
268
+ data = JSON.parse(result[:stdout])
269
+
270
+ # Check for GraphQL errors in response
271
+ if data["errors"]
272
+ error_messages = data["errors"].map { |e| e["message"] }.join("; ")
273
+ warn "Warning: GraphQL errors fetching review threads: #{error_messages}"
274
+ end
275
+
276
+ extract_review_threads(data, include_resolved)
277
+ rescue JSON::ParserError => e
278
+ warn "Warning: Failed to parse review threads response: #{e.message}"
279
+ []
280
+ rescue => e
281
+ warn "Warning: Failed to fetch review threads: #{e.message}"
282
+ []
283
+ end
284
+
285
+ # Extract and structure review threads from GraphQL response
286
+ #
287
+ # @param data [Hash] Parsed GraphQL response
288
+ # @param include_resolved [Boolean] Whether to include resolved threads
289
+ # @return [Array<Hash>] Structured review threads
290
+ def self.extract_review_threads(data, include_resolved = false)
291
+ threads_data = data.dig("data", "repository", "pullRequest", "reviewThreads") || {}
292
+ threads = threads_data["nodes"] || []
293
+
294
+ # Warn if results are truncated
295
+ check_pagination_limits(threads_data, threads)
296
+
297
+ threads.filter_map do |thread|
298
+ # Skip resolved threads unless explicitly included
299
+ next if thread["isResolved"] && !include_resolved
300
+
301
+ comments_data = thread["comments"] || {}
302
+ comments = (comments_data["nodes"] || []).map do |comment|
303
+ {
304
+ id: comment["id"],
305
+ author: comment.dig("author", "login") || "unknown",
306
+ body: comment["body"]&.strip,
307
+ created_at: comment["createdAt"]
308
+ }
309
+ end
310
+
311
+ # Warn if thread comments are truncated
312
+ if comments_data.dig("pageInfo", "hasNextPage")
313
+ total = comments_data["totalCount"]
314
+ warn "Warning: Thread #{thread["id"]} has #{total} comments, only #{MAX_COMMENTS_PER_THREAD} fetched"
315
+ end
316
+
317
+ # Skip threads with no comments
318
+ next if comments.empty?
319
+
320
+ {
321
+ type: "review_thread",
322
+ id: thread["id"],
323
+ path: thread["path"],
324
+ line: thread["line"],
325
+ is_resolved: thread["isResolved"],
326
+ comments: comments
327
+ }
328
+ end
329
+ end
330
+
331
+ # Check and warn about pagination limits
332
+ #
333
+ # @param threads_data [Hash] Review threads data from GraphQL response
334
+ # @param threads [Array] Extracted thread nodes
335
+ def self.check_pagination_limits(threads_data, threads)
336
+ return unless threads_data.dig("pageInfo", "hasNextPage")
337
+
338
+ total = threads_data["totalCount"]
339
+ warn "Warning: PR has #{total} review threads, only #{MAX_REVIEW_THREADS} fetched. " \
340
+ "Some comments may be missing."
341
+ end
342
+
343
+ # Check if author is a known bot
344
+ #
345
+ # @param author [String] GitHub username
346
+ # @return [Boolean] true if author appears to be a bot
347
+ def self.bot_author?(author)
348
+ return false if author.nil?
349
+
350
+ lowered = author.downcase
351
+ BOT_PATTERNS.any? { |pattern| lowered.include?(pattern) } ||
352
+ lowered.end_with?("[bot]") ||
353
+ lowered.end_with?("-bot")
354
+ end
355
+
356
+ # Handle fetch errors and return appropriate error response
357
+ #
358
+ # @param result [Hash] gh CLI result
359
+ # @param pr_identifier [String] Original PR identifier
360
+ # @return [Hash] Error response
361
+ def self.handle_fetch_error(result, pr_identifier)
362
+ error_msg = result[:stderr].to_s
363
+
364
+ # Check for specific error types
365
+ if error_msg.include?("not found") || error_msg.include?("Could not resolve")
366
+ raise Ace::Review::Errors::PrNotFoundError.new(pr_identifier, error_msg)
367
+ elsif error_msg.include?("authentication") || error_msg.include?("Unauthorized")
368
+ raise Ace::Review::Errors::GhAuthenticationError
369
+ end
370
+
371
+ # Generic error
372
+ {
373
+ success: false,
374
+ error: "Failed to fetch PR comments: #{error_msg}"
375
+ }
376
+ end
377
+
378
+ # Discover repository owner/name from git remote via gh CLI
379
+ #
380
+ # @param options [Hash] Fetch options
381
+ # @option options [Integer] :timeout Timeout in seconds (default: 10)
382
+ # @return [String, nil] "owner/name" format or nil if not a GitHub repo
383
+ def self.discover_repo_from_remote(options = {})
384
+ timeout = options[:timeout] || 10
385
+ result = Ace::Review::Molecules::GhCliExecutor.execute(
386
+ "repo",
387
+ ["view", "--json", "owner,name"],
388
+ timeout: timeout
389
+ )
390
+ return nil unless result[:success]
391
+
392
+ data = JSON.parse(result[:stdout])
393
+ owner = data.dig("owner", "login")
394
+ name = data["name"]
395
+ return nil unless owner && name
396
+
397
+ "#{owner}/#{name}"
398
+ rescue JSON::ParserError, StandardError
399
+ nil
400
+ end
401
+
402
+ private_class_method :extract_comments, :extract_reviews, :fetch_review_threads,
403
+ :extract_review_threads, :check_pagination_limits,
404
+ :bot_author?, :handle_fetch_error, :discover_repo_from_remote
405
+ end
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../atoms/retry_with_backoff"
5
+
6
+ module Ace
7
+ module Review
8
+ module Molecules
9
+ # Fetch PR diff and metadata via gh CLI
10
+ class GhPrFetcher
11
+ # Fetch PR diff content
12
+ #
13
+ # @param pr_identifier [String] PR identifier (number, URL, or owner/repo#number)
14
+ # @param options [Hash] Fetch options
15
+ # @option options [Integer] :max_retries Maximum retry attempts (default: 3)
16
+ # @option options [Integer] :initial_backoff Initial backoff in seconds (default: 1)
17
+ # @option options [Integer] :timeout Timeout in seconds for gh CLI (default: 30)
18
+ # @return [Hash] Result with :success, :diff, :error
19
+ def self.fetch_diff(pr_identifier, options = {})
20
+ # Parse identifier to get gh CLI format using ace-git
21
+ parsed = Ace::Git::Atoms::PrIdentifierParser.parse(pr_identifier)
22
+ gh_format = parsed.gh_format
23
+
24
+ # Default timeout for PR diff operations
25
+ timeout = options[:timeout] || 30
26
+
27
+ # Fetch diff with retry logic
28
+ result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
29
+ Ace::Review::Molecules::GhCliExecutor.execute("pr", ["diff", gh_format], timeout: timeout)
30
+ end
31
+
32
+ if result[:success]
33
+ {
34
+ success: true,
35
+ diff: result[:stdout],
36
+ identifier: gh_format,
37
+ parsed: parsed.to_h
38
+ }
39
+ else
40
+ handle_fetch_error(result, pr_identifier)
41
+ end
42
+ rescue Ace::Review::Errors::DiffTooLargeError
43
+ # Fall back to local git diff when GitHub API rejects large diffs
44
+ fetch_local_diff_fallback(pr_identifier, options)
45
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
46
+ # Re-raise authentication and installation errors
47
+ raise
48
+ rescue => e
49
+ {
50
+ success: false,
51
+ error: "Failed to fetch PR diff: #{e.message}"
52
+ }
53
+ end
54
+
55
+ # Fetch PR metadata (state, draft status, title, etc.)
56
+ #
57
+ # @param pr_identifier [String] PR identifier
58
+ # @param options [Hash] Fetch options
59
+ # @option options [Integer] :timeout Timeout in seconds for gh CLI (default: 30)
60
+ # @return [Hash] Result with :success, :metadata, :error
61
+ def self.fetch_metadata(pr_identifier, options = {})
62
+ # Parse identifier using ace-git
63
+ parsed = Ace::Git::Atoms::PrIdentifierParser.parse(pr_identifier)
64
+ gh_format = parsed.gh_format
65
+
66
+ # Default timeout for PR operations
67
+ timeout = options[:timeout] || 30
68
+
69
+ # Fetch metadata as JSON
70
+ fields = "number,state,isDraft,title,body,author,headRefName,baseRefName,url"
71
+
72
+ result = Ace::Review::Atoms::RetryWithBackoff.execute(options) do
73
+ Ace::Review::Molecules::GhCliExecutor.execute("pr", ["view", gh_format, "--json", fields], timeout: timeout)
74
+ end
75
+
76
+ if result[:success]
77
+ metadata = JSON.parse(result[:stdout])
78
+ {
79
+ success: true,
80
+ metadata: metadata,
81
+ identifier: gh_format,
82
+ parsed: parsed.to_h
83
+ }
84
+ else
85
+ handle_fetch_error(result, pr_identifier)
86
+ end
87
+ rescue JSON::ParserError => e
88
+ {
89
+ success: false,
90
+ error: "Failed to parse PR metadata: #{e.message}"
91
+ }
92
+ rescue Ace::Review::Errors::GhCliNotInstalledError, Ace::Review::Errors::GhAuthenticationError
93
+ raise
94
+ rescue => e
95
+ {
96
+ success: false,
97
+ error: "Failed to fetch PR metadata: #{e.message}"
98
+ }
99
+ end
100
+
101
+ # Fetch both diff and metadata in one call
102
+ #
103
+ # @param pr_identifier [String] PR identifier
104
+ # @param options [Hash] Fetch options
105
+ # @return [Hash] Result with :success, :diff, :metadata, :error
106
+ def self.fetch_pr(pr_identifier, options = {})
107
+ # Fetch diff and metadata
108
+ diff_result = fetch_diff(pr_identifier, options)
109
+ return diff_result unless diff_result[:success]
110
+
111
+ metadata_result = fetch_metadata(pr_identifier, options)
112
+ return metadata_result unless metadata_result[:success]
113
+
114
+ {
115
+ success: true,
116
+ diff: diff_result[:diff],
117
+ metadata: metadata_result[:metadata],
118
+ identifier: diff_result[:identifier],
119
+ parsed: diff_result[:parsed]
120
+ }
121
+ end
122
+
123
+ # Handle fetch errors and return appropriate error response
124
+ #
125
+ # @param result [Hash] gh CLI result
126
+ # @param pr_identifier [String] Original PR identifier
127
+ # @return [Hash] Error response
128
+ def self.handle_fetch_error(result, pr_identifier)
129
+ error_msg = result[:stderr].to_s
130
+ exit_code = result[:exit_code]
131
+
132
+ # Check for diff too large (HTTP 406 / file limit exceeded)
133
+ if exit_code == 1 && (error_msg.match?(/\bHTTP 406\b|Not Acceptable/) || error_msg.include?("exceeded the maximum"))
134
+ raise Ace::Review::Errors::DiffTooLargeError.new(pr_identifier, error_msg)
135
+ end
136
+
137
+ # Check for specific error types
138
+ if error_msg.include?("not found") || error_msg.include?("Could not resolve")
139
+ raise Ace::Review::Errors::PrNotFoundError.new(pr_identifier, error_msg)
140
+ elsif error_msg.include?("authentication") || error_msg.include?("Unauthorized")
141
+ raise Ace::Review::Errors::GhAuthenticationError
142
+ end
143
+
144
+ # Generic error
145
+ {
146
+ success: false,
147
+ error: "Failed to fetch PR: #{error_msg}"
148
+ }
149
+ end
150
+
151
+ # Fetch local git diff as fallback when GitHub API rejects large diffs
152
+ #
153
+ # @param pr_identifier [String] PR identifier (used to fetch base branch)
154
+ # @param options [Hash] Fetch options
155
+ # @return [Hash] Result with :success, :diff, :fallback
156
+ def self.fetch_local_diff_fallback(pr_identifier, options = {})
157
+ temp_ref = nil
158
+
159
+ # Fetch PR metadata to get base branch and PR number
160
+ metadata_result = fetch_metadata(pr_identifier, options)
161
+ unless metadata_result[:success]
162
+ return {
163
+ success: false,
164
+ error: "Cannot fall back to local diff: failed to fetch PR metadata — #{metadata_result[:error]}"
165
+ }
166
+ end
167
+
168
+ base_ref = metadata_result[:metadata]["baseRefName"]
169
+ pull_number = metadata_result[:metadata]["number"] || metadata_result.dig(:parsed, "number")
170
+ temp_ref = "refs/ace/review/pr-#{pull_number}-#{Process.pid}"
171
+
172
+ fetch_result = run_local_command("git", "fetch", "--no-tags", "origin",
173
+ "+refs/pull/#{pull_number}/head:#{temp_ref}")
174
+ unless fetch_result[:success]
175
+ return {
176
+ success: false,
177
+ error: "Cannot fall back to local diff: git fetch PR head failed — #{fetch_result[:stderr]}"
178
+ }
179
+ end
180
+
181
+ # Find merge base
182
+ merge_base_result = run_local_command("git", "merge-base", "origin/#{base_ref}", temp_ref)
183
+ unless merge_base_result[:success]
184
+ return {
185
+ success: false,
186
+ error: "Cannot fall back to local diff: git merge-base failed — #{merge_base_result[:stderr]}"
187
+ }
188
+ end
189
+
190
+ merge_base = merge_base_result[:stdout].strip
191
+
192
+ # Diff against the fetched PR head rather than the caller's checkout state.
193
+ diff_result = run_local_command("git", "diff", merge_base, temp_ref)
194
+ unless diff_result[:success]
195
+ return {
196
+ success: false,
197
+ error: "Cannot fall back to local diff: git diff failed — #{diff_result[:stderr]}"
198
+ }
199
+ end
200
+
201
+ {
202
+ success: true,
203
+ diff: diff_result[:stdout],
204
+ identifier: metadata_result[:identifier],
205
+ parsed: metadata_result[:parsed],
206
+ fallback: :local_git_diff
207
+ }
208
+ ensure
209
+ delete_temp_ref(temp_ref) if temp_ref
210
+ end
211
+
212
+ # Execute a local command and return structured result
213
+ #
214
+ # @param args [Array<String>] Command and arguments
215
+ # @return [Hash] Result with :success, :stdout, :stderr
216
+ def self.run_local_command(*args)
217
+ require "open3"
218
+ stdout, stderr, status = Open3.capture3(*args)
219
+ {
220
+ success: status.success?,
221
+ stdout: stdout,
222
+ stderr: stderr
223
+ }
224
+ rescue => e
225
+ {
226
+ success: false,
227
+ stdout: "",
228
+ stderr: e.message
229
+ }
230
+ end
231
+
232
+ def self.delete_temp_ref(temp_ref)
233
+ run_local_command("git", "update-ref", "-d", temp_ref)
234
+ end
235
+
236
+ private_class_method :handle_fetch_error, :fetch_local_diff_fallback, :run_local_command, :delete_temp_ref
237
+ end
238
+ end
239
+ end
240
+ end