ace-git-github 0.2.0 → 0.3.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/CHANGELOG.md +7 -0
- data/lib/ace/git/github/cli_executor.rb +14 -10
- data/lib/ace/git/github/provider.rb +648 -2
- data/lib/ace/git/github/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 865ef273c3954eefcfd0742e3a2dc3b2e9a02aa2e93aef1f13888f02e128aea7
|
|
4
|
+
data.tar.gz: 0f6167a9930838246beafea1454ce56a1b68938c96c6be658308e3724130801d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0043d7965efda107ac27aa490b6e7e329b7dbc4a792c919fb3bca22e11aabcf406203cf8b8578d662c8aba582d045f197de3ad7a42dba45af24dafb008a3a001
|
|
7
|
+
data.tar.gz: c3c2f64ae9a1a463113bb49f6f453104d358161e9c94b35261327ea3368e787a795d7460f605769cf8f938d5b14569a8290cdb14617f5c309676fd34d5980839
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Repository-bound GitHub PR comment and review retrieval, comment create/update, and thread resolution through the shared provider contract.
|
|
14
|
+
- Correlation-based comment reconciliation after uncertain posts and explicit malformed or unsupported result classification.
|
|
15
|
+
|
|
16
|
+
|
|
10
17
|
## [0.2.0] - 2026-09-28
|
|
11
18
|
|
|
12
19
|
### Added
|
|
@@ -28,12 +28,12 @@ module Ace
|
|
|
28
28
|
# @return [Hash] {success:, stdout:, stderr:, exit_code:}
|
|
29
29
|
# @raise [Ace::Git::ProviderCliMissingError] when `gh` is missing
|
|
30
30
|
# @raise [Ace::Git::ProviderUnreachableError] when the call times out
|
|
31
|
-
def execute(subcommand, args = [], timeout: nil, runner: nil)
|
|
31
|
+
def execute(subcommand, args = [], timeout: nil, runner: nil, stdin: nil)
|
|
32
32
|
timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_TIMEOUT
|
|
33
|
-
result = run_command([BINARY, subcommand] + args, timeout_seconds, runner)
|
|
33
|
+
result = run_command([BINARY, subcommand] + args, timeout_seconds, runner, stdin: stdin)
|
|
34
34
|
if result == :timeout
|
|
35
35
|
raise Ace::Git::ProviderUnreachableError,
|
|
36
|
-
"gh command timed out after #{timeout_seconds} seconds: #{([subcommand] + args).join(" ")}"
|
|
36
|
+
"gh command timed out after #{timeout_seconds} seconds: #{([subcommand] + args).join(" ")[0, 200]}"
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
result
|
|
@@ -80,11 +80,11 @@ module Ace
|
|
|
80
80
|
# Run a full argument vector against `gh`.
|
|
81
81
|
#
|
|
82
82
|
# @return [Hash] result hash, or :timeout sentinel on timeout
|
|
83
|
-
def run_command(command, timeout_seconds, runner = nil)
|
|
83
|
+
def run_command(command, timeout_seconds, runner = nil, stdin: nil)
|
|
84
84
|
if runner
|
|
85
|
-
call_runner(runner, command, timeout_seconds)
|
|
85
|
+
call_runner(runner, command, timeout_seconds, stdin: stdin)
|
|
86
86
|
else
|
|
87
|
-
spawn_with_timeout(command, timeout_seconds)
|
|
87
|
+
spawn_with_timeout(command, timeout_seconds, stdin: stdin)
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
90
|
|
|
@@ -93,15 +93,19 @@ module Ace
|
|
|
93
93
|
run_command([BINARY] + args, timeout_seconds, runner)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def call_runner(runner, command, timeout_seconds)
|
|
97
|
-
|
|
96
|
+
def call_runner(runner, command, timeout_seconds, stdin: nil)
|
|
97
|
+
if stdin.nil?
|
|
98
|
+
runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"})
|
|
99
|
+
else
|
|
100
|
+
runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"}, stdin: stdin)
|
|
101
|
+
end
|
|
98
102
|
rescue StandardError => e
|
|
99
103
|
{success: false, stdout: "", stderr: e.message, exit_code: 1}
|
|
100
104
|
end
|
|
101
105
|
|
|
102
|
-
def spawn_with_timeout(command, timeout_seconds)
|
|
106
|
+
def spawn_with_timeout(command, timeout_seconds, stdin: nil)
|
|
103
107
|
stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
|
|
104
|
-
Open3.capture3({"LC_ALL" => "C"}, *command)
|
|
108
|
+
Open3.capture3({"LC_ALL" => "C"}, *command, stdin_data: stdin.to_s)
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
{
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "open3"
|
|
3
5
|
require "json"
|
|
4
6
|
require "uri"
|
|
5
7
|
|
|
@@ -26,7 +28,7 @@ module Ace
|
|
|
26
28
|
"skipping" => :skipped
|
|
27
29
|
}.freeze
|
|
28
30
|
|
|
29
|
-
PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt,headRepositoryOwner,headRepository"
|
|
31
|
+
PR_FIELDS = "number,state,isDraft,title,body,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt,headRepositoryOwner,headRepository"
|
|
30
32
|
LIST_FIELDS = PrFetcher::LIST_FIELDS
|
|
31
33
|
REPO_FIELDS = "nameWithOwner,defaultBranchRef,url"
|
|
32
34
|
ISSUE_FIELDS = "number,title,state,author,url"
|
|
@@ -89,7 +91,15 @@ module Ace
|
|
|
89
91
|
# @return [String] unified diff text for the pull request
|
|
90
92
|
def pull_request_diff(number:)
|
|
91
93
|
result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner, repo: repo_target)
|
|
92
|
-
result[:diff]
|
|
94
|
+
return result[:diff] if result[:success]
|
|
95
|
+
# API size-limit rejections (HTTP 406) fall back to a local git
|
|
96
|
+
# diff at the exact reviewed head, never an approximation.
|
|
97
|
+
raise Ace::Git::ProviderObjectNotFoundError, "gh pr diff failed: #{result[:error]}" if result[:error].to_s.match?(PrFetcher::PR_NOT_FOUND_PATTERN)
|
|
98
|
+
raise Ace::Git::ProviderAuthenticationError, result[:error].to_s if result[:error].to_s.match?(PrFetcher::AUTH_ERROR_PATTERN)
|
|
99
|
+
if result[:error].to_s.match?(/\bHTTP 406\b|Not Acceptable|exceeded the maximum/)
|
|
100
|
+
return local_diff_fallback(number)
|
|
101
|
+
end
|
|
102
|
+
raise Ace::Git::ProviderUnreachableError, "gh pr diff failed: #{result[:error]}"
|
|
93
103
|
end
|
|
94
104
|
|
|
95
105
|
# @return [Array<ProviderPullRequest>] recent PRs, newest first
|
|
@@ -133,6 +143,434 @@ module Ace
|
|
|
133
143
|
)
|
|
134
144
|
end
|
|
135
145
|
|
|
146
|
+
def pull_request_body(number:)
|
|
147
|
+
pull_request(number: number).body
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def pull_request_review_evidence(number:, expected_head:)
|
|
151
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
152
|
+
threads = review_thread_index(pr)
|
|
153
|
+
comments = gh_api_pages("issues/#{pr.number}/comments").map do |entry|
|
|
154
|
+
review_comment(entry, pr, expected_head, threads)
|
|
155
|
+
end
|
|
156
|
+
inline = gh_api_pages("pulls/#{pr.number}/comments").map do |entry|
|
|
157
|
+
review_comment(entry, pr, expected_head, threads)
|
|
158
|
+
end
|
|
159
|
+
reviews = gh_api_pages("pulls/#{pr.number}/reviews").map do |entry|
|
|
160
|
+
review_entry(entry, pr, expected_head)
|
|
161
|
+
end
|
|
162
|
+
verify_expected_head!(pull_request(number: pr.number), expected_head)
|
|
163
|
+
Ace::Git::ProviderReviewEvidence.new(
|
|
164
|
+
server_name: server.name, repository_url: server.url,
|
|
165
|
+
pr_number: pr.number, head_sha: expected_head,
|
|
166
|
+
comments: comments + inline, reviews: reviews
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Map REST comment database ids to their review thread (GraphQL id,
|
|
171
|
+
# resolution state). A truncated thread listing would silently
|
|
172
|
+
# understate resolution evidence, so pagination limits fail closed.
|
|
173
|
+
def review_thread_index(pr)
|
|
174
|
+
host, repository = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2)
|
|
175
|
+
owner, name = repository.to_s.split("/", 2)
|
|
176
|
+
unless owner && name
|
|
177
|
+
raise Ace::Git::ConfigError, "Invalid selected GitHub repository #{server.url}"
|
|
178
|
+
end
|
|
179
|
+
threads = []
|
|
180
|
+
cursor = nil
|
|
181
|
+
seen_thread_pages = {}
|
|
182
|
+
seen_thread_ids = {}
|
|
183
|
+
total_count = nil
|
|
184
|
+
loop do
|
|
185
|
+
query = <<~GRAPHQL
|
|
186
|
+
query($id: Int!, $cursor: String) {
|
|
187
|
+
repository(owner: "#{owner}", name: "#{name}") {
|
|
188
|
+
pullRequest(number: $id) {
|
|
189
|
+
reviewThreads(first: 100, after: $cursor) {
|
|
190
|
+
totalCount
|
|
191
|
+
pageInfo { hasNextPage endCursor }
|
|
192
|
+
nodes {
|
|
193
|
+
id
|
|
194
|
+
isResolved
|
|
195
|
+
comments(first: 100) {
|
|
196
|
+
pageInfo { hasNextPage endCursor }
|
|
197
|
+
nodes { databaseId }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
GRAPHQL
|
|
205
|
+
pull_request_node = gh_graphql(query, id: pr.number, cursor: cursor).dig("data", "repository", "pullRequest")
|
|
206
|
+
threads_page = pull_request_node.is_a?(Hash) && pull_request_node["reviewThreads"]
|
|
207
|
+
unless threads_page.is_a?(Hash) && threads_page["totalCount"].is_a?(Integer) &&
|
|
208
|
+
threads_page["pageInfo"].is_a?(Hash) && threads_page["nodes"].is_a?(Array)
|
|
209
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
|
|
210
|
+
end
|
|
211
|
+
if total_count && total_count != threads_page["totalCount"]
|
|
212
|
+
raise Ace::Git::ProviderMalformedOutputError, "Inconsistent GitHub review thread total"
|
|
213
|
+
end
|
|
214
|
+
total_count = threads_page["totalCount"]
|
|
215
|
+
threads.concat(threads_page["nodes"])
|
|
216
|
+
page_digest = Digest::SHA256.hexdigest(threads_page["nodes"].to_s)
|
|
217
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
218
|
+
"GitHub review thread pagination repeated a page" if seen_thread_pages[page_digest]
|
|
219
|
+
|
|
220
|
+
seen_thread_pages[page_digest] = true
|
|
221
|
+
break unless threads_page["pageInfo"]["hasNextPage"] == true
|
|
222
|
+
cursor = threads_page["pageInfo"]["endCursor"]
|
|
223
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
224
|
+
"Incomplete GitHub review thread evidence" unless cursor.is_a?(String)
|
|
225
|
+
end
|
|
226
|
+
if total_count != threads.length
|
|
227
|
+
raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub review thread evidence"
|
|
228
|
+
end
|
|
229
|
+
threads.each do |thread|
|
|
230
|
+
next unless thread.is_a?(Hash)
|
|
231
|
+
if seen_thread_ids[thread["id"]]
|
|
232
|
+
raise Ace::Git::ProviderMalformedOutputError, "Duplicate GitHub review thread id"
|
|
233
|
+
end
|
|
234
|
+
seen_thread_ids[thread["id"]] = true
|
|
235
|
+
end
|
|
236
|
+
index = {}
|
|
237
|
+
threads.each do |thread|
|
|
238
|
+
page_info = thread.is_a?(Hash) && thread["comments"].is_a?(Hash) && thread["comments"]["pageInfo"]
|
|
239
|
+
unless thread.is_a?(Hash) && thread["id"].is_a?(String) &&
|
|
240
|
+
[true, false].include?(thread["isResolved"]) &&
|
|
241
|
+
thread["comments"].is_a?(Hash) && thread["comments"]["nodes"].is_a?(Array) &&
|
|
242
|
+
page_info.is_a?(Hash) && [true, false].include?(page_info["hasNextPage"])
|
|
243
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
|
|
244
|
+
end
|
|
245
|
+
resolved = thread["isResolved"]
|
|
246
|
+
thread_comment_ids(thread["id"], thread["comments"]).each do |database_id|
|
|
247
|
+
index[database_id] = [thread["id"], resolved]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
index
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A thread's comment list may itself be paginated; follow its cursor
|
|
254
|
+
# so resolution state covers every comment in the thread.
|
|
255
|
+
def thread_comment_ids(thread_id, first_page)
|
|
256
|
+
cursor = nil
|
|
257
|
+
ids = []
|
|
258
|
+
seen_cursors = {}
|
|
259
|
+
comments_page = first_page
|
|
260
|
+
loop do
|
|
261
|
+
unless comments_page.is_a?(Hash) && comments_page["pageInfo"].is_a?(Hash) &&
|
|
262
|
+
[true, false].include?(comments_page["pageInfo"]["hasNextPage"]) &&
|
|
263
|
+
comments_page["nodes"].is_a?(Array)
|
|
264
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
|
|
265
|
+
end
|
|
266
|
+
comments_page["nodes"].each do |comment|
|
|
267
|
+
unless comment.is_a?(Hash) && comment["databaseId"].is_a?(Integer)
|
|
268
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
|
|
269
|
+
end
|
|
270
|
+
ids << comment["databaseId"]
|
|
271
|
+
end
|
|
272
|
+
break unless comments_page["pageInfo"]["hasNextPage"] == true
|
|
273
|
+
cursor = comments_page["pageInfo"]["endCursor"]
|
|
274
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
275
|
+
"Incomplete GitHub review thread evidence" unless cursor.is_a?(String)
|
|
276
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
277
|
+
"GitHub review thread comment pagination repeated a cursor" if seen_cursors[cursor]
|
|
278
|
+
|
|
279
|
+
seen_cursors[cursor] = true
|
|
280
|
+
|
|
281
|
+
query = <<~GRAPHQL
|
|
282
|
+
query($id: ID!, $cursor: String) {
|
|
283
|
+
node(id: $id) {
|
|
284
|
+
... on PullRequestReviewThread {
|
|
285
|
+
comments(first: 100, after: $cursor) {
|
|
286
|
+
pageInfo { hasNextPage endCursor }
|
|
287
|
+
nodes { databaseId }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
GRAPHQL
|
|
293
|
+
node = gh_graphql(query, id: thread_id, cursor: cursor).dig("data", "node")
|
|
294
|
+
comments_page = node.is_a?(Hash) ? node["comments"] : nil
|
|
295
|
+
end
|
|
296
|
+
ids
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def pull_request_review_details(number:)
|
|
300
|
+
number = Integer(number)
|
|
301
|
+
data = gh_api("pulls/#{number}")
|
|
302
|
+
files = gh_api_pages("pulls/#{number}/files").map do |entry|
|
|
303
|
+
entry.is_a?(Hash) && entry["filename"]
|
|
304
|
+
end
|
|
305
|
+
unless data.is_a?(Hash) && data["base"].is_a?(Hash) && data["number"] == number &&
|
|
306
|
+
data.dig("base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/) &&
|
|
307
|
+
data["changed_files"].is_a?(Integer) && data["changed_files"] == files.length &&
|
|
308
|
+
files.all? { |path| path.is_a?(String) && !path.empty? }
|
|
309
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed or incomplete GitHub PR file evidence"
|
|
310
|
+
end
|
|
311
|
+
Ace::Git::ProviderReviewDetails.new(
|
|
312
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
313
|
+
base_sha: data.dig("base", "sha"), files: files
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def pull_request_checks(number:, head_sha:)
|
|
318
|
+
verify_expected_head!(pull_request(number: number), head_sha)
|
|
319
|
+
pages = gh_api("commits/#{head_sha}/check-runs?per_page=100", paginate: true)
|
|
320
|
+
# --paginate --slurp returns one payload per page; a single-page
|
|
321
|
+
# response stays a bare object.
|
|
322
|
+
page_payloads = pages.is_a?(Array) ? pages : [pages]
|
|
323
|
+
unless page_payloads.is_a?(Array) && page_payloads.all? { |page| page.is_a?(Hash) && page["check_runs"].is_a?(Array) }
|
|
324
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR check evidence"
|
|
325
|
+
end
|
|
326
|
+
runs = page_payloads.flat_map { |page| page["check_runs"] }
|
|
327
|
+
unless runs.all? { |run| run.is_a?(Hash) && run["name"].is_a?(String) }
|
|
328
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR check evidence"
|
|
329
|
+
end
|
|
330
|
+
total = page_payloads.filter_map { |page| page["total_count"] }.last
|
|
331
|
+
# The accumulated pages must account for every reported run; a
|
|
332
|
+
# shortfall would silently understate check evidence. GitHub
|
|
333
|
+
# caps the check-runs endpoint at the 1,000 most recent check
|
|
334
|
+
# suites, so a maxed-out page cannot be exhausted further and the
|
|
335
|
+
# snapshot is marked incomplete instead.
|
|
336
|
+
if total.is_a?(Integer) && total > runs.length && runs.length >= 1000
|
|
337
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
338
|
+
"Incomplete GitHub PR check evidence: endpoint capped at the 1,000 most recent check suites"
|
|
339
|
+
end
|
|
340
|
+
unless total.is_a?(Integer) && total <= runs.length
|
|
341
|
+
raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub PR check evidence"
|
|
342
|
+
end
|
|
343
|
+
checks = runs.map do |run|
|
|
344
|
+
Ace::Git::ProviderCheck.new(
|
|
345
|
+
server_name: server.name, name: run["name"],
|
|
346
|
+
state: run["status"].to_s.downcase.to_sym,
|
|
347
|
+
conclusion: run["conclusion"]&.downcase&.to_sym,
|
|
348
|
+
url: run["html_url"]
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
checks + commit_statuses(head_sha)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Combined commit statuses are a separate GitHub evidence stream from
|
|
355
|
+
# check runs; both must appear or the snapshot overstates success.
|
|
356
|
+
def commit_statuses(head_sha)
|
|
357
|
+
data = gh_api("commits/#{head_sha}/status")
|
|
358
|
+
statuses = data.is_a?(Hash) && data["statuses"]
|
|
359
|
+
unless statuses.is_a?(Array) && statuses.all? { |s| s.is_a?(Hash) && s["context"].is_a?(String) }
|
|
360
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
|
|
361
|
+
end
|
|
362
|
+
total = data["total_count"]
|
|
363
|
+
unless total.is_a?(Integer)
|
|
364
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
|
|
365
|
+
end
|
|
366
|
+
# The combined view can cap its inline statuses array; the
|
|
367
|
+
# paginated statuses endpoint is the complete stream.
|
|
368
|
+
if total > statuses.length
|
|
369
|
+
statuses = gh_api("commits/#{head_sha}/statuses?per_page=100", paginate: true)
|
|
370
|
+
statuses = statuses.is_a?(Array) ? statuses.flat_map { |page| page.is_a?(Array) ? page : [page] } : [statuses]
|
|
371
|
+
unless statuses.all? { |st| st.is_a?(Hash) && st["context"].is_a?(String) }
|
|
372
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
|
|
373
|
+
end
|
|
374
|
+
# The statuses stream is reverse-chronological with one entry
|
|
375
|
+
# per state change; the combined total counts distinct
|
|
376
|
+
# contexts. Keep the first (latest) entry per context.
|
|
377
|
+
statuses = statuses.uniq { |st| st["context"] }
|
|
378
|
+
end
|
|
379
|
+
unless statuses.length == total
|
|
380
|
+
raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub commit status evidence"
|
|
381
|
+
end
|
|
382
|
+
statuses.map do |status|
|
|
383
|
+
Ace::Git::ProviderCheck.new(
|
|
384
|
+
server_name: server.name, name: status["context"],
|
|
385
|
+
state: status["state"].to_s.downcase.to_sym,
|
|
386
|
+
conclusion: nil,
|
|
387
|
+
url: status["target_url"]
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def repository_file(path:, ref:)
|
|
393
|
+
require "base64"
|
|
394
|
+
# %20, not form encoding: "+" in a path segment is a literal plus.
|
|
395
|
+
# Explicit unreserved-character allowlist: percent-encode
|
|
396
|
+
# everything else (DEFAULT_PARSER leaves ? and + ambiguous).
|
|
397
|
+
escaped = path.split("/").map { |part|
|
|
398
|
+
part.gsub(/[^A-Za-z0-9._~!$&'()*+,;=@:-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
399
|
+
}.join("/")
|
|
400
|
+
data = gh_api("contents/#{escaped}?ref=#{ref}")
|
|
401
|
+
unless data.is_a?(Hash) && data["encoding"] == "base64" && data["content"].is_a?(String)
|
|
402
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub repository file evidence"
|
|
403
|
+
end
|
|
404
|
+
Base64.strict_decode64(data["content"].delete("\n"))
|
|
405
|
+
rescue ArgumentError => e
|
|
406
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub repository file content: #{e.message}"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def create_pull_request_comment(number:, expected_head:, body:, correlation:)
|
|
410
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
411
|
+
require_open_pr!(pr)
|
|
412
|
+
marker = comment_marker(correlation)
|
|
413
|
+
with_session_lock(correlation) do
|
|
414
|
+
existing = matching_review_comments(pr, marker, expected_head)
|
|
415
|
+
if existing.length > 1
|
|
416
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
417
|
+
"Multiple comments match review session #{correlation} on PR ##{pr.number}"
|
|
418
|
+
end
|
|
419
|
+
if existing.one?
|
|
420
|
+
# A repeat reconciles only the exact session comment; a marker
|
|
421
|
+
# match with different content is a conflict, never our post.
|
|
422
|
+
sent = "#{body}\n\n#{marker}"
|
|
423
|
+
if existing.first.body == sent
|
|
424
|
+
verify_post_mutation_head!(pr, expected_head, correlation)
|
|
425
|
+
return review_mutation(pr, expected_head, existing.first, :existing)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
429
|
+
"Review session #{correlation} comment exists on PR ##{pr.number} with different content"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
second = verify_expected_head!(pull_request(number: pr.number), expected_head)
|
|
433
|
+
require_open_pr!(second)
|
|
434
|
+
begin
|
|
435
|
+
gh_api("issues/#{pr.number}/comments", method: :post, body: "#{body}\n\n#{marker}")
|
|
436
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
437
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
438
|
+
"PR comment sent but response unreadable for #{server.name}/#{pr.number}, head #{expected_head}, session #{correlation}: #{e.message}; reconcile before repeating"
|
|
439
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
440
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
441
|
+
"PR comment outcome unknown for #{server.name}/#{pr.number}, head #{expected_head}, " \
|
|
442
|
+
"session #{correlation}: #{e.message}; reconcile before repeating"
|
|
443
|
+
end
|
|
444
|
+
begin
|
|
445
|
+
matches = matching_review_comments(pr, marker, expected_head)
|
|
446
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
447
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
448
|
+
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
449
|
+
end
|
|
450
|
+
unless matches.one? && matches.first.body == "#{body}\n\n#{marker}"
|
|
451
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
452
|
+
"PR comment sent but reconciliation found #{matches.length} exact-content match(es) for session #{correlation}"
|
|
453
|
+
end
|
|
454
|
+
verify_post_mutation_head!(pr, expected_head, correlation)
|
|
455
|
+
review_mutation(pr, expected_head, matches.first, :created)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
|
|
460
|
+
comment_id = Integer(comment_id)
|
|
461
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
462
|
+
# Collected evidence includes issue and inline comments; the
|
|
463
|
+
# update must locate the id in both and use the matching route.
|
|
464
|
+
# Inline (review) comments take precedence: if an id exists in
|
|
465
|
+
# both collections, editing must target the inline comment the
|
|
466
|
+
# reviewer thread actually references.
|
|
467
|
+
inline_matches = gh_api_pages("pulls/#{pr.number}/comments").select { |entry| entry["id"] == comment_id }
|
|
468
|
+
issue_matches = inline_matches.empty? ? gh_api_pages("issues/#{pr.number}/comments").select { |entry| entry["id"] == comment_id } : []
|
|
469
|
+
matches = inline_matches + issue_matches
|
|
470
|
+
update_route = inline_matches.any? ? "pulls/comments/#{comment_id}" : "issues/comments/#{comment_id}"
|
|
471
|
+
unless matches.one?
|
|
472
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
473
|
+
"Comment #{comment_id} does not belong to selected PR ##{pr.number}"
|
|
474
|
+
end
|
|
475
|
+
comment = review_comment(matches.first, pr, expected_head)
|
|
476
|
+
if comment.body == body
|
|
477
|
+
verify_post_mutation_head!(pr, expected_head, "comment #{comment_id}")
|
|
478
|
+
return review_mutation(pr, expected_head, comment, :existing)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
482
|
+
begin
|
|
483
|
+
gh_api(update_route, method: :patch, body: body)
|
|
484
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
485
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
486
|
+
"Comment update sent but response unreadable for #{server.name}/#{pr.number}, comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
487
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
488
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
489
|
+
"Comment update outcome unknown for #{server.name}/#{pr.number}, " \
|
|
490
|
+
"head #{expected_head}, comment #{comment_id}: #{e.message}"
|
|
491
|
+
end
|
|
492
|
+
begin
|
|
493
|
+
# Fetch the edited comment individually (both patch routes are
|
|
494
|
+
# valid GET item endpoints) and validate its PR identity.
|
|
495
|
+
begin
|
|
496
|
+
updated = gh_api(update_route)
|
|
497
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
498
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
499
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
500
|
+
end
|
|
501
|
+
unless comment_belongs_to_pr?(updated, pr.number)
|
|
502
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
503
|
+
"Comment update sent but edited comment #{comment_id} does not verify against PR ##{pr.number}; reconcile before repeating"
|
|
504
|
+
end
|
|
505
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
506
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
507
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
508
|
+
end
|
|
509
|
+
unless updated && updated["id"] == comment_id && updated["body"] == body
|
|
510
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
511
|
+
"Comment update sent but exact PR comment #{comment_id} could not be verified"
|
|
512
|
+
end
|
|
513
|
+
verify_post_mutation_head!(pr, expected_head, "comment #{comment_id}")
|
|
514
|
+
review_mutation(pr, expected_head, review_comment(updated, pr, expected_head), :updated)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def resolve_pull_request_thread(number:, expected_head:, thread_id:)
|
|
518
|
+
unless thread_id.to_s.match?(/\APRRT_[A-Za-z0-9_=-]+\z/)
|
|
519
|
+
raise Ace::Git::ConfigError, "Invalid GitHub review thread ID"
|
|
520
|
+
end
|
|
521
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
522
|
+
query = <<~GRAPHQL
|
|
523
|
+
query($id: ID!) {
|
|
524
|
+
node(id: $id) {
|
|
525
|
+
... on PullRequestReviewThread {
|
|
526
|
+
id isResolved
|
|
527
|
+
pullRequest { number headRefOid repository { url } }
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
GRAPHQL
|
|
532
|
+
thread = gh_graphql(query, id: thread_id).dig("data", "node")
|
|
533
|
+
verify_review_thread!(thread, pr, expected_head)
|
|
534
|
+
if thread["isResolved"] == true
|
|
535
|
+
verify_post_mutation_head!(pr, expected_head, "thread #{thread_id}")
|
|
536
|
+
return true
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
540
|
+
mutation = <<~GRAPHQL
|
|
541
|
+
mutation($id: ID!) {
|
|
542
|
+
resolveReviewThread(input: {threadId: $id}) {
|
|
543
|
+
thread { id isResolved pullRequest { number headRefOid repository { url } } }
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
GRAPHQL
|
|
547
|
+
begin
|
|
548
|
+
resolved = gh_graphql(mutation, id: thread_id).dig("data", "resolveReviewThread", "thread")
|
|
549
|
+
raise Ace::Git::ProviderMalformedOutputError, "Missing thread confirmation" unless resolved.is_a?(Hash)
|
|
550
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
551
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
552
|
+
"Thread resolution sent but confirmation unreadable for #{server.name}/#{pr.number}, thread #{thread_id}: #{e.message}; reconcile before repeating"
|
|
553
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
554
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
555
|
+
"Thread resolution outcome unknown for #{server.name}/#{pr.number}, " \
|
|
556
|
+
"head #{expected_head}, thread #{thread_id}: #{e.message}"
|
|
557
|
+
end
|
|
558
|
+
begin
|
|
559
|
+
verify_review_thread!(resolved, pr, expected_head)
|
|
560
|
+
rescue Ace::Git::ProviderIdentityMismatchError, Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderExpectedHeadConflictError => e
|
|
561
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
562
|
+
"Thread resolution sent but confirmation unreadable for #{server.name}/#{pr.number}, thread #{thread_id}: #{e.message}; reconcile before repeating"
|
|
563
|
+
end
|
|
564
|
+
unless resolved["isResolved"] == true
|
|
565
|
+
# The resolution mutation may have landed; only its evidence
|
|
566
|
+
# is unreadable. Reconcile via the thread id.
|
|
567
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
568
|
+
"GitHub did not confirm thread resolution for #{server.name}/#{pr.number}, thread #{thread_id}; reconcile before repeating"
|
|
569
|
+
end
|
|
570
|
+
verify_post_mutation_head!(pr, expected_head, "thread #{thread_id}")
|
|
571
|
+
true
|
|
572
|
+
end
|
|
573
|
+
|
|
136
574
|
# ---- PR lifecycle mutations ----
|
|
137
575
|
|
|
138
576
|
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
@@ -217,6 +655,213 @@ module Ace
|
|
|
217
655
|
|
|
218
656
|
private
|
|
219
657
|
|
|
658
|
+
def require_open_pr!(pr)
|
|
659
|
+
return if pr.state == :open
|
|
660
|
+
|
|
661
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
662
|
+
"Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def verify_review_thread!(thread, pr, expected_head)
|
|
666
|
+
unless thread.is_a?(Hash) && thread["id"] && thread["pullRequest"].is_a?(Hash) &&
|
|
667
|
+
thread.dig("pullRequest", "number") == pr.number &&
|
|
668
|
+
Ace::Git::Atoms::ServerUrl.match?(thread.dig("pullRequest", "repository", "url"), server.url)
|
|
669
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
670
|
+
"GitHub review thread does not belong to selected PR ##{pr.number}"
|
|
671
|
+
end
|
|
672
|
+
unless thread.dig("pullRequest", "headRefOid") == expected_head
|
|
673
|
+
raise Ace::Git::ProviderExpectedHeadConflictError,
|
|
674
|
+
"GitHub review thread PR head changed before resolution"
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def gh_graphql(query, id:, cursor: nil)
|
|
679
|
+
host = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first
|
|
680
|
+
args = ["graphql", "-f", "query=#{query}", "-F", "id=#{id}", "--hostname", host]
|
|
681
|
+
args += ["-F", "cursor=#{cursor}"] if cursor
|
|
682
|
+
result = CliExecutor.execute("api", args, timeout: timeout, runner: runner)
|
|
683
|
+
classify_failure(result[:stderr], context: "api graphql") unless result[:success]
|
|
684
|
+
data = JSON.parse(result[:stdout])
|
|
685
|
+
if data.is_a?(Hash) && data["errors"].is_a?(Array) && data["errors"].any?
|
|
686
|
+
raise Ace::Git::ProviderUnreachableError,
|
|
687
|
+
"GitHub GraphQL rejected review operation: #{data["errors"].first["message"]}"
|
|
688
|
+
end
|
|
689
|
+
unless data.is_a?(Hash) && data["data"].is_a?(Hash)
|
|
690
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub GraphQL response"
|
|
691
|
+
end
|
|
692
|
+
data
|
|
693
|
+
rescue JSON::ParserError => e
|
|
694
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub GraphQL JSON: #{e.message}"
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# A mutation receipt may only bind the expected head; if the PR head
|
|
698
|
+
# moved during the mutation the outcome stays uncertain.
|
|
699
|
+
def verify_post_mutation_head!(pr, expected_head, correlation)
|
|
700
|
+
current = begin
|
|
701
|
+
pull_request(number: pr.number)
|
|
702
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
703
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
704
|
+
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
705
|
+
end
|
|
706
|
+
return if current.head_sha == expected_head
|
|
707
|
+
|
|
708
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
709
|
+
"PR head moved during mutation for #{server.name}/##{pr.number} " \
|
|
710
|
+
"(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# GitHub identity URLs end with the PR number for both comment kinds.
|
|
714
|
+
def comment_belongs_to_pr?(entry, number)
|
|
715
|
+
return false unless entry.is_a?(Hash)
|
|
716
|
+
issue_url = entry["issue_url"].to_s
|
|
717
|
+
# Review comments expose pull_request_url as a plain string.
|
|
718
|
+
pr_url = (entry.dig("pull_request", "url") || entry["pull_request_url"]).to_s
|
|
719
|
+
issue_url.end_with?("/issues/#{number}") || pr_url.end_with?("/pulls/#{number}")
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Serialize the read/post/reconcile sequence per session so two
|
|
723
|
+
# concurrent callers cannot both observe "no marker" and post.
|
|
724
|
+
def with_session_lock(correlation)
|
|
725
|
+
require "tmpdir"
|
|
726
|
+
lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
|
|
727
|
+
File.open(lock_path, File::CREAT | File::RDWR, 0600) do |lock|
|
|
728
|
+
lock.flock(File::LOCK_EX)
|
|
729
|
+
yield
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def comment_marker(correlation)
|
|
734
|
+
unless correlation.to_s.match?(/\A[a-zA-Z0-9._:-]+\z/)
|
|
735
|
+
raise Ace::Git::ConfigError, "Invalid review session correlation"
|
|
736
|
+
end
|
|
737
|
+
"<!-- ace-review-session:#{correlation} -->"
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def matching_review_comments(pr, marker, head)
|
|
741
|
+
gh_api_pages("issues/#{pr.number}/comments").filter_map do |entry|
|
|
742
|
+
next unless entry.is_a?(Hash) && entry["body"].is_a?(String) && entry["body"].include?(marker)
|
|
743
|
+
|
|
744
|
+
review_comment(entry, pr, head)
|
|
745
|
+
end
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def review_comment(entry, pr, head, threads = {})
|
|
749
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
750
|
+
entry.dig("user", "login").is_a?(String)
|
|
751
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR comment evidence"
|
|
752
|
+
end
|
|
753
|
+
thread_id, resolved = threads[entry["id"]] || [nil, nil]
|
|
754
|
+
Ace::Git::ProviderReviewComment.new(
|
|
755
|
+
server_name: server.name, repository_url: server.url, pr_number: pr.number,
|
|
756
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
757
|
+
url: entry["html_url"], path: entry["path"], line: entry["line"],
|
|
758
|
+
head_sha: entry["commit_id"] || head, resolved: resolved, thread_id: thread_id
|
|
759
|
+
)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def review_entry(entry, pr, head)
|
|
763
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry.dig("user", "login").is_a?(String) &&
|
|
764
|
+
entry["state"].is_a?(String) && !entry["state"].empty?
|
|
765
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR review evidence"
|
|
766
|
+
end
|
|
767
|
+
Ace::Git::ProviderReview.new(
|
|
768
|
+
server_name: server.name, repository_url: server.url, pr_number: pr.number,
|
|
769
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
770
|
+
state: entry["state"], url: entry["html_url"], head_sha: entry["commit_id"] || head
|
|
771
|
+
)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def review_mutation(pr, head, comment, idempotency)
|
|
775
|
+
Ace::Git::ProviderReviewMutation.new(
|
|
776
|
+
server_name: server.name, repository_url: server.url, pr_number: pr.number,
|
|
777
|
+
head_sha: head, comment: comment, idempotency: idempotency
|
|
778
|
+
)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def gh_api_pages(suffix)
|
|
782
|
+
data = gh_api(suffix, paginate: true)
|
|
783
|
+
pages = data.is_a?(Array) ? data : nil
|
|
784
|
+
unless pages && pages.all? { |page| page.is_a?(Array) }
|
|
785
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR collection"
|
|
786
|
+
end
|
|
787
|
+
pages.flatten(1)
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def gh_api(suffix, method: :get, body: nil, paginate: false)
|
|
791
|
+
target = Ace::Git::Atoms::ServerUrl.normalize(server.url)
|
|
792
|
+
host, repository = target.split("/", 2)
|
|
793
|
+
unless repository&.match?(%r{\A[^/]+/[^/]+\z})
|
|
794
|
+
raise Ace::Git::ConfigError, "Invalid selected GitHub repository #{server.url}"
|
|
795
|
+
end
|
|
796
|
+
args = ["repos/#{repository}/#{suffix}", "--hostname", host]
|
|
797
|
+
args += ["--paginate", "--slurp"] if paginate
|
|
798
|
+
stdin = nil
|
|
799
|
+
if %i[post patch].include?(method)
|
|
800
|
+
# The body travels on stdin (`--input -`), never as a
|
|
801
|
+
# command argument: argv leaks into process listings and
|
|
802
|
+
# timeout errors, and huge review bodies exceed arg limits.
|
|
803
|
+
args += ["-X", method.to_s.upcase, "--input", "-"]
|
|
804
|
+
stdin = JSON.generate(body: body)
|
|
805
|
+
end
|
|
806
|
+
result = CliExecutor.execute("api", args, timeout: timeout, runner: runner, stdin: stdin)
|
|
807
|
+
classify_failure(result[:stderr], context: "api #{suffix}") unless result[:success]
|
|
808
|
+
JSON.parse(result[:stdout])
|
|
809
|
+
rescue JSON::ParserError => e
|
|
810
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub API JSON: #{e.message}"
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# Exact-head local fallback for oversized API diffs: fetch the PR
|
|
814
|
+
# head ref from the selected host into a temp ref, verify the SHA
|
|
815
|
+
# matches, and diff base..head locally.
|
|
816
|
+
def local_diff_fallback(number)
|
|
817
|
+
base_oid = pull_request_review_details(number: number).base_sha
|
|
818
|
+
head_oid = pull_request(number: number).head_sha
|
|
819
|
+
# Preserve the configured scheme (http or https) rather than
|
|
820
|
+
# assuming https, and never use scp-style host:path remotes.
|
|
821
|
+
scheme = server.url.to_s[/\A[a-z][a-z0-9+.-]*:\/\//i].to_s.downcase
|
|
822
|
+
scheme = "https://" if scheme.empty?
|
|
823
|
+
host = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first
|
|
824
|
+
remote = "#{scheme}#{host}/#{repo_path}"
|
|
825
|
+
head_ref = "refs/ace/review/pr-#{number}-#{Process.pid}"
|
|
826
|
+
base_ref = "#{head_ref}-base"
|
|
827
|
+
begin
|
|
828
|
+
run_git("fetch", "--no-tags", remote, "+refs/pull/#{number}/head:#{head_ref}")
|
|
829
|
+
fetched = run_git("rev-parse", head_ref).strip
|
|
830
|
+
unless fetched == head_oid
|
|
831
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
832
|
+
"Local fallback fetched #{fetched[0, 12]} but the reviewed head is #{head_oid[0, 12]}"
|
|
833
|
+
end
|
|
834
|
+
# The base commit may not exist locally and a two-dot diff
|
|
835
|
+
# would include unrelated target-branch changes: fetch the
|
|
836
|
+
# exact base SHA and diff from the merge base.
|
|
837
|
+
run_git("fetch", "--no-tags", remote, "+#{base_oid}:#{base_ref}")
|
|
838
|
+
merge_base = run_git("merge-base", base_ref, head_ref).strip
|
|
839
|
+
diff = run_git("diff", "#{merge_base}..#{head_ref}")
|
|
840
|
+
raise Ace::Git::ProviderMalformedOutputError, "Local fallback produced an empty diff" if diff.strip.empty?
|
|
841
|
+
diff
|
|
842
|
+
ensure
|
|
843
|
+
run_git("update-ref", "-d", head_ref) rescue nil
|
|
844
|
+
run_git("update-ref", "-d", base_ref) rescue nil
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def repo_path
|
|
849
|
+
Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).last
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def run_git(*args)
|
|
853
|
+
require "open3"
|
|
854
|
+
require "timeout"
|
|
855
|
+
out, status = Timeout.timeout(timeout) do
|
|
856
|
+
Open3.capture2({"LC_ALL" => "C"}, "git", *args)
|
|
857
|
+
end
|
|
858
|
+
rescue Timeout::Error
|
|
859
|
+
raise Ace::Git::ProviderUnreachableError, "git #{args.first} timed out after #{timeout}s"
|
|
860
|
+
else
|
|
861
|
+
raise Ace::Git::ProviderUnreachableError, "git #{args.first} failed" unless status.success?
|
|
862
|
+
out
|
|
863
|
+
end
|
|
864
|
+
|
|
220
865
|
STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
|
|
221
866
|
|
|
222
867
|
# Send one mutating `gh` command. When `ambiguous` is true (create),
|
|
@@ -300,6 +945,7 @@ module Ace
|
|
|
300
945
|
server_name: server_name,
|
|
301
946
|
number: data["number"],
|
|
302
947
|
title: data["title"],
|
|
948
|
+
body: data["body"],
|
|
303
949
|
state: STATE_MAP.fetch(data["state"].to_s.upcase, data["state"].to_s.downcase.to_sym),
|
|
304
950
|
head_ref: data["headRefName"],
|
|
305
951
|
base_ref: data["baseRefName"],
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-github
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.26'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.26'
|
|
26
26
|
description: 'Implements the shared ace-git provider contract for GitHub: all `gh`
|
|
27
27
|
CLI invocation, output parsing, authentication verification, and classified failure
|
|
28
28
|
handling live behind one normalized evidence boundary.'
|