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,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
|