ace-git-github 0.1.2 → 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: 87f0b4b44d02a10b05ea01ab8bee7ea5f5c3d0560e4602782ee7d05010ab688b
4
- data.tar.gz: 3b7332516e0866d09e162f909c9cee66083ac3b16bc368bd17dcadb4f4adafa3
3
+ metadata.gz: 865ef273c3954eefcfd0742e3a2dc3b2e9a02aa2e93aef1f13888f02e128aea7
4
+ data.tar.gz: 0f6167a9930838246beafea1454ce56a1b68938c96c6be658308e3724130801d
5
5
  SHA512:
6
- metadata.gz: 84ecc1d39e1622757fa67377c41cf376cc190564b69871a03750313ceadd30cd7448fa6773f4211020cdcf90f08d3118c49da13112a207557d6f614b385ef380
7
- data.tar.gz: 736f512c9293e23ba4569187036db44d8016344d293eb307048d5798bc7575d37aecc7a73a344708a97b891a3d3bb96b3de1dcd75e4e8d6daf382be615307733
6
+ metadata.gz: 0043d7965efda107ac27aa490b6e7e329b7dbc4a792c919fb3bca22e11aabcf406203cf8b8578d662c8aba582d045f197de3ad7a42dba45af24dafb008a3a001
7
+ data.tar.gz: c3c2f64ae9a1a463113bb49f6f453104d358161e9c94b35261327ea3368e787a795d7460f605769cf8f938d5b14569a8290cdb14617f5c309676fd34d5980839
data/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ 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
+
17
+ ## [0.2.0] - 2026-09-28
18
+
19
+ ### Added
20
+ - PR lifecycle mutations with atomic provider-side expected-head enforcement:
21
+ exact-match lookup (`find_open_pull_requests`), idempotent draft-create
22
+ reconciliation, expected-head merge via the gh match-head-commit flag,
23
+ head-verified update/ready.
24
+ - Cross-repository PR provenance: head repository URL resolved from gh
25
+ evidence and returned in normalized PR evidence.
26
+ - Classified lifecycle failures (conflicting matches, expected-head conflict,
27
+ unknown create outcome with reconciliation identity).
28
+
29
+ ### Changed
30
+ - `gh pr view`/`list` JSON field sets extended with head-repository fields.
31
+
32
+
33
+ ### Added
34
+ - PR lifecycle mutations with atomic provider-side expected-head enforcement:
35
+ exact-match lookup (`find_open_pull_requests`), idempotent draft-create
36
+ reconciliation, expected-head merge via the gh match-head-commit flag,
37
+ head-verified update/ready.
38
+ - Cross-repository PR provenance: head repository URL resolved from gh
39
+ evidence and returned in normalized PR evidence.
40
+ - Classified lifecycle failures (conflicting matches, expected-head conflict,
41
+ unknown create outcome with reconciliation identity).
42
+
43
+ ### Changed
44
+ - `gh pr view`/`list` JSON field sets extended with head-repository fields.
45
+
46
+
10
47
  ## [0.1.2] - 2026-09-26
11
48
 
12
49
  ### 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
  {
@@ -53,13 +53,15 @@ module Ace
53
53
  # @param timeout [Integer] Timeout in seconds
54
54
  # @param runner [Proc, nil] injectable command runner for tests
55
55
  # @return [Hash] Result with :success, :diff, :identifier, :source
56
- def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil)
56
+ def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
57
57
  parsed = PrIdentifier.parse(identifier)
58
58
  raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
59
59
 
60
60
  validate_identifier_characters(parsed.gh_format)
61
61
 
62
- result = CliExecutor.execute("pr", ["diff", *parsed.cli_target_args], timeout: timeout, runner: runner)
62
+ args = ["diff", *parsed.cli_target_args]
63
+ args += ["--repo", repo] if repo
64
+ result = CliExecutor.execute("pr", args, timeout: timeout, runner: runner)
63
65
 
64
66
  if result[:success]
65
67
  {
@@ -182,9 +184,11 @@ module Ace
182
184
  # @param timeout [Integer] Timeout in seconds
183
185
  # @param runner [Proc, nil] injectable command runner for tests
184
186
  # @return [Hash] Result with :success, :prs array, or :error
185
- def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil)
187
+ def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
188
+ args = ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS]
189
+ args += ["--repo", repo] if repo
186
190
  result = CliExecutor.execute(
187
- "pr", ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS],
191
+ "pr", args,
188
192
  timeout: timeout, runner: runner
189
193
  )
190
194
 
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "open3"
3
5
  require "json"
6
+ require "uri"
4
7
 
5
8
  module Ace
6
9
  module Git
@@ -25,11 +28,14 @@ module Ace
25
28
  "skipping" => :skipped
26
29
  }.freeze
27
30
 
28
- PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt"
31
+ PR_FIELDS = "number,state,isDraft,title,body,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt,headRepositoryOwner,headRepository"
29
32
  LIST_FIELDS = PrFetcher::LIST_FIELDS
30
33
  REPO_FIELDS = "nameWithOwner,defaultBranchRef,url"
31
34
  ISSUE_FIELDS = "number,title,state,author,url"
32
35
 
36
+ # Fields needed to match pull requests by exact base/head identity.
37
+ LIFECYCLE_LIST_FIELDS = "number,title,state,isDraft,author,headRefName,baseRefName,url,headRefOid,headRepositoryOwner,headRepository"
38
+
33
39
  class << self
34
40
  # Human-facing provider type name used in failure messages.
35
41
  def display_name
@@ -66,6 +72,13 @@ module Ace
66
72
  normalize_pr(data, server.name)
67
73
  end
68
74
 
75
+ # @return [String] HOST/OWNER/REPO for the resolved server; appended
76
+ # to every data/mutation command so a `--server` selection can never
77
+ # be silently retargeted to the checkout's repository.
78
+ def repo_target
79
+ @repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(server.url)
80
+ end
81
+
69
82
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
70
83
  def pull_request_for_branch(branch:)
71
84
  candidates = recent_pull_requests(limit: 30)
@@ -77,13 +90,21 @@ module Ace
77
90
 
78
91
  # @return [String] unified diff text for the pull request
79
92
  def pull_request_diff(number:)
80
- result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner)
81
- result[:diff]
93
+ result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner, repo: repo_target)
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]}"
82
103
  end
83
104
 
84
105
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
85
106
  def recent_pull_requests(limit:)
86
- result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner)
107
+ result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner, repo: repo_target)
87
108
  result[:prs]
88
109
  .map { |pr| normalize_pr(pr, server.name) }
89
110
  .sort_by { |pr| -pr.number.to_i }
@@ -112,7 +133,8 @@ module Ace
112
133
 
113
134
  # @return [ProviderRepository] normalized repository evidence
114
135
  def repository
115
- output = gh_json(["repo", "view", "--json", REPO_FIELDS])
136
+ # `gh repo view` rejects `--repo`; the repository is positional.
137
+ output = gh_json(["repo", "view", repo_target, "--json", REPO_FIELDS], bind_repo: false)
116
138
  Ace::Git::ProviderRepository.new(
117
139
  server_name: server.name,
118
140
  full_name: output["nameWithOwner"],
@@ -121,13 +143,791 @@ module Ace
121
143
  )
122
144
  end
123
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
+
574
+ # ---- PR lifecycle mutations ----
575
+
576
+ # @return [Array<ProviderPullRequest>] open PRs matching the exact
577
+ # base/head identity (head repository URL/ref plus base URL/ref)
578
+ def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
579
+ require_base_on_server!(base_repository_url)
580
+ data = gh_json(["pr", "list", "--state", "open", "--limit", "200", "--json", LIFECYCLE_LIST_FIELDS])
581
+ data.filter_map { |entry| normalize_pr(entry, server.name) }
582
+ .select do |pr|
583
+ pr.head_ref == head_ref &&
584
+ pr.base_ref == base_ref &&
585
+ Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, head_repository_url)
586
+ end
587
+ end
588
+
589
+ # Create a PR, reconciling to an exact open match first; proves the
590
+ # created/reconciled head against `expected_head`.
591
+ #
592
+ # @return [ProviderMutationReceipt] idempotency :created/:existing
593
+ def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
594
+ matches = find_open_pull_requests(
595
+ head_repository_url: head_repository_url, head_ref: head_ref,
596
+ base_repository_url: server.url, base_ref: base_ref
597
+ )
598
+ if matches.size > 1
599
+ raise Ace::Git::ProviderConflictingMatchesError,
600
+ "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
601
+ "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
602
+ end
603
+ if matches.size == 1
604
+ existing = matches.first
605
+ return receipt(:create, verify_head!(existing, expected_head), :existing)
606
+ end
607
+
608
+ args = ["pr", "create", "--head", gh_head_arg(head_ref, head_repository_url), "--base", base_ref, "--title", title]
609
+ args += ["--body", body] if body
610
+ args << "--draft" if draft
611
+
612
+ result = send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
613
+ number = result[:stdout].to_s.strip[%r{/pull/(\d+)\z}, 1]
614
+ unless number
615
+ raise Ace::Git::ProviderMalformedOutputError,
616
+ "gh pr create did not return a pull request URL: #{result[:stdout].to_s.strip}"
617
+ end
618
+
619
+ created = verify_head!(pull_request(number: number), expected_head)
620
+ receipt(:create, created, :created)
621
+ end
622
+
623
+ # @return [ProviderMutationReceipt] operation :update
624
+ def update_pull_request(number:, expected_head:, title: nil, body: nil)
625
+ verify_head!(pull_request(number: number), expected_head)
626
+ args = ["pr", "edit", number.to_s]
627
+ args += ["--title", title] if title
628
+ args += ["--body", body] if body
629
+ send_mutation(args, ambiguous: false)
630
+ receipt(:update, pull_request(number: number), nil)
631
+ end
632
+
633
+ # @return [ProviderMutationReceipt] operation :ready
634
+ def ready_pull_request(number:, expected_head:)
635
+ verify_head!(pull_request(number: number), expected_head)
636
+ send_mutation(["pr", "ready", number.to_s], ambiguous: false)
637
+ receipt(:ready, pull_request(number: number), nil)
638
+ end
639
+
640
+ # Merge with atomic provider-side expected-head enforcement via
641
+ # `gh pr merge --match-head-commit`.
642
+ #
643
+ # @return [ProviderMutationReceipt] operation :merge
644
+ def merge_pull_request(number:, expected_head:, method:)
645
+ unless %i[squash merge rebase].include?(method)
646
+ raise ArgumentError, "Invalid merge method #{method.inspect}"
647
+ end
648
+
649
+ send_mutation(
650
+ ["pr", "merge", number.to_s, "--#{method}", "--match-head-commit", expected_head],
651
+ ambiguous: false
652
+ )
653
+ receipt(:merge, pull_request(number: number), nil)
654
+ end
655
+
124
656
  private
125
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
+
126
865
  STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
127
866
 
867
+ # Send one mutating `gh` command. When `ambiguous` is true (create),
868
+ # any transport-level failure is reported as an unknown outcome: the
869
+ # request may have mutated the forge, so callers must reconcile by
870
+ # exact identity instead of retrying blindly.
871
+ def send_mutation(args, ambiguous:, identity: nil)
872
+ result = CliExecutor.execute(args.first, (args[1..] || []) + ["--repo", repo_target], timeout: timeout, runner: runner)
873
+ return result if result[:success]
874
+
875
+ classify_failure(result[:stderr], context: args.join(" "))
876
+ result
877
+ rescue Ace::Git::ProviderUnreachableError => e
878
+ raise e unless ambiguous
879
+
880
+ raise Ace::Git::ProviderUnknownOutcomeError,
881
+ "PR create outcome unknown after transport failure (#{e.message}); " \
882
+ "reconcile by exact identity before repeating: #{identity}"
883
+ end
884
+
885
+ # Exact base/head identity text for reconciliation contexts.
886
+ def identity_text(head_repository_url, head_ref, base_ref)
887
+ "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}"
888
+ end
889
+
890
+ # Refuse when the live head differs from the caller's expected head
891
+ # (shared guard from Providers::Base).
892
+ def verify_head!(pr, expected_head)
893
+ verify_expected_head!(pr, expected_head)
894
+ end
895
+
896
+ # `gh` head selector: "user:branch" for fork sources, plain ref for
897
+ # the base repository. The owner comes from the declared URL only.
898
+ def gh_head_arg(head_ref, head_repository_url)
899
+ return head_ref if head_repository_url.nil? ||
900
+ Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
901
+
902
+ path = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/", 2)[1]
903
+ owner = path.to_s.split("/").first
904
+ raise ArgumentError, "Cannot derive fork owner from #{head_repository_url}" if owner.nil? || owner.empty?
905
+
906
+ "#{owner}:#{head_ref}"
907
+ end
908
+
909
+ def require_base_on_server!(base_repository_url)
910
+ return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
911
+
912
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
913
+ "GitHub provider operates on the resolved server repository (#{server.url}); " \
914
+ "refusing base repository #{base_repository_url}"
915
+ end
916
+
917
+ def receipt(operation, pull_request, idempotency)
918
+ Ace::Git::ProviderMutationReceipt.new(
919
+ server_name: server.name, operation: operation,
920
+ pull_request: pull_request, idempotency: idempotency
921
+ )
922
+ end
923
+
128
924
  # Run a `gh` command expecting JSON output; classify all failures.
129
- def gh_json(args)
130
- result = CliExecutor.execute(args.first, args[1..] || [], timeout: timeout, runner: runner)
925
+ # Every command is bound to the resolved server repository via
926
+ # `--repo`, except commands that take the repository positionally.
927
+ def gh_json(args, bind_repo: true)
928
+ argv = args[1..] || []
929
+ argv += ["--repo", repo_target] if bind_repo
930
+ result = CliExecutor.execute(args.first, argv, timeout: timeout, runner: runner)
131
931
  unless result[:success]
132
932
  classify_failure(result[:stderr], context: args.join(" "))
133
933
  end
@@ -145,6 +945,7 @@ module Ace
145
945
  server_name: server_name,
146
946
  number: data["number"],
147
947
  title: data["title"],
948
+ body: data["body"],
148
949
  state: STATE_MAP.fetch(data["state"].to_s.upcase, data["state"].to_s.downcase.to_sym),
149
950
  head_ref: data["headRefName"],
150
951
  base_ref: data["baseRefName"],
@@ -152,10 +953,31 @@ module Ace
152
953
  author: normalize_author(data["author"]),
153
954
  url: data["url"],
154
955
  draft: data["isDraft"],
155
- merged_at: merged_at_of(data)
956
+ merged_at: merged_at_of(data),
957
+ head_repository_url: head_repository_url_of(data),
958
+ base_repository_url: server.url,
959
+ merge_commit_sha: data.dig("mergeCommit", "oid")
156
960
  )
157
961
  end
158
962
 
963
+ # Source repository URL for the PR head: the fork's owner/name against
964
+ # the server host for cross-repository PRs, the configured repository
965
+ # otherwise. Never inferred beyond what `gh` reported.
966
+ def head_repository_url_of(data)
967
+ owner = data.dig("headRepositoryOwner", "login")
968
+ name = data.dig("headRepository", "name")
969
+ return server.url if owner.nil? || name.nil?
970
+
971
+ "#{server_host_root}/#{owner}/#{name}"
972
+ end
973
+
974
+ def server_host_root
975
+ @server_host_root ||= begin
976
+ uri = URI.parse(server.url.to_s)
977
+ "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
978
+ end
979
+ end
980
+
159
981
  def merged_at_of(data)
160
982
  data["mergedAt"]
161
983
  end
@@ -184,6 +1006,9 @@ module Ace
184
1006
  raise Ace::Git::ProviderObjectNotFoundError, "Object not found: #{context}: #{message}"
185
1007
  elsif message.match?(PrFetcher::AUTH_ERROR_PATTERN)
186
1008
  raise Ace::Git::ProviderAuthenticationError, "Not authenticated with GitHub: #{message}"
1009
+ elsif message.match?(/match[- ]head[- ]commit|head commit/i)
1010
+ raise Ace::Git::ProviderExpectedHeadConflictError,
1011
+ "GitHub refused the merge: pull request head does not match --match-head-commit (#{context}): #{message}"
187
1012
  else
188
1013
  raise Ace::Git::ProviderUnreachableError, "GitHub request failed (#{context}): #{message}"
189
1014
  end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Github
6
- VERSION = "0.1.2"
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.1.2
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-27 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.'