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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 998ca300a7c6610ff632dac2a6051fe84daa68a4e8daac13181e10fd86223798
4
- data.tar.gz: 8cc1cf649582eed6600fc53bb0d70ac8bdc73226c02072400f8cf6c5de450d4a
3
+ metadata.gz: 865ef273c3954eefcfd0742e3a2dc3b2e9a02aa2e93aef1f13888f02e128aea7
4
+ data.tar.gz: 0f6167a9930838246beafea1454ce56a1b68938c96c6be658308e3724130801d
5
5
  SHA512:
6
- metadata.gz: 5f8d10b98f72f43767031476e9a980658462834374ec26af019a7d7b753c5bb3d5e78257b2a6ee7261856069c72db234993541223b4f14c52ac84e83ba9a28f0
7
- data.tar.gz: d1399752f36d349a36fa0bbb21406d62c820806ad78ff62eefe3ec0423b0bfc28f75964ac389f45e99b7ed599c148b8c638152ecf0bef8e51d77b596ea5a0ec8
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
- runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"})
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"],
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Github
6
- VERSION = "0.2.0"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
9
9
  end
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.2.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-09-29 00:00:00.000000000 Z
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.24'
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.24'
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.'