ace-git-github 0.2.0 → 0.4.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: baa6f879ef34125b1099214ec6387d091ebbca80116f51c7ea4ea37fc9fc08a9
4
+ data.tar.gz: b7f37f202295ba924168e9b3e2f639c221bf93c63a8d2f6112a2fc243cdb8888
5
5
  SHA512:
6
- metadata.gz: 5f8d10b98f72f43767031476e9a980658462834374ec26af019a7d7b753c5bb3d5e78257b2a6ee7261856069c72db234993541223b4f14c52ac84e83ba9a28f0
7
- data.tar.gz: d1399752f36d349a36fa0bbb21406d62c820806ad78ff62eefe3ec0423b0bfc28f75964ac389f45e99b7ed599c148b8c638152ecf0bef8e51d77b596ea5a0ec8
6
+ metadata.gz: 1f49e0c270b7235b7db93ecbeebef80a78ca5a9791959fae5f13b3d6e06c29576583c8f6a270654ffb7815f0703e5be5aaf41cae1c2e123dc80167d4fcf4e1ce
7
+ data.tar.gz: 770eec189bf46c9502bc405fbf0e5b1258c154e3a49a1467df4479f71c2118d29a2910c9830e2d78c5fbeb81e3239fd1c824aefa78f4b8cad8a9e99a7fca3513
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-03
11
+
12
+ ### Added
13
+ - GitHub issue tracking through the shared provider contract, including repository-bound lookup and mutation.
14
+
15
+ ### Fixed
16
+ - Issue labels are looked up and created on demand before attachment (concurrent creations tolerated), and the label listing no longer sends the gh-incompatible `--slurp` + `--jq` combination.
17
+
18
+ ## [0.3.0] - 2026-10-02
19
+
20
+ ### Added
21
+ - Repository-bound GitHub PR comment and review retrieval, comment create/update, and thread resolution through the shared provider contract.
22
+ - Correlation-based comment reconciliation after uncertain posts and explicit malformed or unsupported result classification.
23
+
10
24
  ## [0.2.0] - 2026-09-28
11
25
 
12
26
  ### 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"
@@ -74,7 +76,12 @@ module Ace
74
76
  # to every data/mutation command so a `--server` selection can never
75
77
  # be silently retargeted to the checkout's repository.
76
78
  def repo_target
77
- @repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(server.url)
79
+ # Issue operations talk to the HTTPS web endpoint derived from the
80
+ # configured URL; a plain normalize would carry an SSH-style
81
+ # host:port into gh --hostname/--repo.
82
+ @repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(
83
+ Ace::Git::Atoms::ServerUrl.web_base(server.url)
84
+ )
78
85
  end
79
86
 
80
87
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
@@ -89,7 +96,15 @@ module Ace
89
96
  # @return [String] unified diff text for the pull request
90
97
  def pull_request_diff(number:)
91
98
  result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner, repo: repo_target)
92
- result[:diff]
99
+ return result[:diff] if result[:success]
100
+ # API size-limit rejections (HTTP 406) fall back to a local git
101
+ # diff at the exact reviewed head, never an approximation.
102
+ raise Ace::Git::ProviderObjectNotFoundError, "gh pr diff failed: #{result[:error]}" if result[:error].to_s.match?(PrFetcher::PR_NOT_FOUND_PATTERN)
103
+ raise Ace::Git::ProviderAuthenticationError, result[:error].to_s if result[:error].to_s.match?(PrFetcher::AUTH_ERROR_PATTERN)
104
+ if result[:error].to_s.match?(/\bHTTP 406\b|Not Acceptable|exceeded the maximum/)
105
+ return local_diff_fallback(number)
106
+ end
107
+ raise Ace::Git::ProviderUnreachableError, "gh pr diff failed: #{result[:error]}"
93
108
  end
94
109
 
95
110
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
@@ -106,16 +121,107 @@ module Ace
106
121
  normalize_issue(output)
107
122
  end
108
123
 
124
+ def issue_tracking(number:)
125
+ data = gh_json(["issue", "view", number.to_s, "--json", "number,title,state,author,url,labels"])
126
+ evidence = normalize_issue(data)
127
+ unless evidence.number.to_i == number.to_i && url_matches_server?(evidence.url, number)
128
+ raise Ace::Git::ProviderIdentityMismatchError, "Issue ##{number} is not in #{server.url}"
129
+ end
130
+ uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
131
+ owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
132
+ pages = gh_json(["api", "repos/#{owner_repo}/issues/#{number}/comments", "--hostname", forge_hostname(uri),
133
+ "--paginate", "--slurp"], bind_repo: false)
134
+ unless pages.is_a?(Array) && pages.all? { |page| page.is_a?(Array) }
135
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub comment pagination for issue ##{number}"
136
+ end
137
+ {
138
+ issue: evidence,
139
+ comments: pages.flatten.map do |comment|
140
+ id = comment["id"]
141
+ raise Ace::Git::ProviderMalformedOutputError, "Issue comment has no API id" unless id.to_s.match?(/\A\d+\z/)
142
+
143
+ {id: id.to_i, body: comment["body"].to_s}
144
+ end,
145
+ labels: Array(data["labels"]).map { |label| label["name"].to_s }
146
+ }
147
+ end
148
+
149
+ def create_issue_comment(number:, body:)
150
+ issue_api("POST", "issues/#{number}/comments", fields: ["body=#{body}"])
151
+ end
152
+
153
+ def update_issue_comment(number:, comment_id:, body:)
154
+ assert_issue_comment_owned!(number, comment_id)
155
+ issue_api("PATCH", "issues/comments/#{Integer(comment_id)}", fields: ["body=#{body}"])
156
+ end
157
+
158
+ def delete_issue_comment(number:, comment_id:)
159
+ assert_issue_comment_owned!(number, comment_id)
160
+ issue_api("DELETE", "issues/comments/#{Integer(comment_id)}")
161
+ end
162
+
163
+ def add_issue_label(number:, label:)
164
+ ensure_repo_label(label)
165
+ issue_api("POST", "issues/#{number}/labels", fields: ["labels[]=#{label}"])
166
+ end
167
+
168
+ def remove_issue_label(number:, label:)
169
+ issue_api("DELETE", "issues/#{number}/labels/#{URI.encode_www_form_component(label)}")
170
+ end
171
+
172
+ def set_issue_state(number:, state:)
173
+ raise ArgumentError, "Invalid issue state #{state.inspect}" unless %i[open closed].include?(state)
174
+
175
+ issue_api("PATCH", "issues/#{number}", fields: ["state=#{state}"])
176
+ end
177
+
178
+ # Fresh repositories lack the tracking label: look it up and create
179
+ # it on demand BEFORE attaching, so linking cannot commit a tracking
180
+ # comment it could never label (mirrors the Forgejo provider). A
181
+ # concurrent creator surfaces as a failed create whose re-list
182
+ # confirms the ensured state; any other lookup/create failure still
183
+ # surfaces through the attach call.
184
+ def ensure_repo_label(label)
185
+ uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
186
+ owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
187
+ hostname = forge_hostname(uri)
188
+ return if label_exists?(owner_repo, hostname, label)
189
+
190
+ CliExecutor.execute("api", ["repos/#{owner_repo}/labels", "--hostname", hostname,
191
+ "--method", "POST", "--raw-field", "name=#{label}", "--raw-field", "color=0e8a16"],
192
+ timeout: timeout, runner: runner)
193
+ label_exists?(owner_repo, hostname, label)
194
+ end
195
+
196
+ def label_exists?(owner_repo, hostname, label)
197
+ # gh api rejects --slurp combined with --jq; per-page jq output is
198
+ # one name per line, which the line parse below handles.
199
+ result = CliExecutor.execute("api", ["repos/#{owner_repo}/labels?per_page=100", "--hostname", hostname,
200
+ "--paginate", "--jq", ".[].name"], timeout: timeout, runner: runner)
201
+ return false unless result[:success]
202
+
203
+ result[:stdout].to_s.lines.map(&:strip).include?(label)
204
+ rescue Ace::Git::Error
205
+ false
206
+ end
207
+
109
208
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
110
209
  def checks(ref:)
111
210
  output = gh_json(["pr", "checks", ref.to_s, "--json", "name,state,bucket"])
112
211
  output.map do |check|
212
+ state = check["state"].to_s.downcase
213
+ raise Ace::Git::ProviderMalformedOutputError,
214
+ "Malformed GitHub check entry: missing state" if state.empty?
113
215
  bucket = check["bucket"].to_s.downcase
216
+ unless bucket.empty? || BUCKET_MAP.key?(bucket)
217
+ raise Ace::Git::ProviderMalformedOutputError,
218
+ "Malformed GitHub check entry: unrecognized bucket #{bucket}"
219
+ end
114
220
  Ace::Git::ProviderCheck.new(
115
221
  server_name: server.name,
116
222
  name: check["name"].to_s,
117
- state: check["state"].to_s.downcase.to_sym,
118
- conclusion: BUCKET_MAP.fetch(bucket, bucket.empty? ? nil : bucket.to_sym),
223
+ state: state.to_sym,
224
+ conclusion: BUCKET_MAP.fetch(bucket, nil),
119
225
  url: nil
120
226
  )
121
227
  end
@@ -133,6 +239,442 @@ module Ace
133
239
  )
134
240
  end
135
241
 
242
+ def pull_request_body(number:)
243
+ pull_request(number: number).body
244
+ end
245
+
246
+ def pull_request_review_evidence(number:, expected_head:)
247
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
248
+ threads = review_thread_index(pr)
249
+ comments = gh_api_pages("issues/#{pr.number}/comments").map do |entry|
250
+ review_comment(entry, pr, expected_head, threads)
251
+ end
252
+ inline = gh_api_pages("pulls/#{pr.number}/comments").map do |entry|
253
+ review_comment(entry, pr, expected_head, threads)
254
+ end
255
+ reviews = gh_api_pages("pulls/#{pr.number}/reviews").map do |entry|
256
+ review_entry(entry, pr, expected_head)
257
+ end
258
+ verify_expected_head!(pull_request(number: pr.number), expected_head)
259
+ Ace::Git::ProviderReviewEvidence.new(
260
+ server_name: server.name, repository_url: server.url,
261
+ pr_number: pr.number, head_sha: expected_head,
262
+ comments: comments + inline, reviews: reviews
263
+ )
264
+ end
265
+
266
+ # Map REST comment database ids to their review thread (GraphQL id,
267
+ # resolution state). A truncated thread listing would silently
268
+ # understate resolution evidence, so pagination limits fail closed.
269
+ def review_thread_index(pr)
270
+ host, repository = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2)
271
+ owner, name = repository.to_s.split("/", 2)
272
+ unless owner && name
273
+ raise Ace::Git::ConfigError, "Invalid selected GitHub repository #{server.url}"
274
+ end
275
+ threads = []
276
+ cursor = nil
277
+ seen_thread_pages = {}
278
+ seen_thread_ids = {}
279
+ total_count = nil
280
+ loop do
281
+ query = <<~GRAPHQL
282
+ query($id: Int!, $cursor: String) {
283
+ repository(owner: "#{owner}", name: "#{name}") {
284
+ pullRequest(number: $id) {
285
+ reviewThreads(first: 100, after: $cursor) {
286
+ totalCount
287
+ pageInfo { hasNextPage endCursor }
288
+ nodes {
289
+ id
290
+ isResolved
291
+ comments(first: 100) {
292
+ pageInfo { hasNextPage endCursor }
293
+ nodes { databaseId }
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ GRAPHQL
301
+ pull_request_node = gh_graphql(query, id: pr.number, cursor: cursor).dig("data", "repository", "pullRequest")
302
+ threads_page = pull_request_node.is_a?(Hash) && pull_request_node["reviewThreads"]
303
+ unless threads_page.is_a?(Hash) && threads_page["totalCount"].is_a?(Integer) &&
304
+ threads_page["pageInfo"].is_a?(Hash) && threads_page["nodes"].is_a?(Array)
305
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
306
+ end
307
+ if total_count && total_count != threads_page["totalCount"]
308
+ raise Ace::Git::ProviderMalformedOutputError, "Inconsistent GitHub review thread total"
309
+ end
310
+ total_count = threads_page["totalCount"]
311
+ threads.concat(threads_page["nodes"])
312
+ page_digest = Digest::SHA256.hexdigest(threads_page["nodes"].to_s)
313
+ raise Ace::Git::ProviderMalformedOutputError,
314
+ "GitHub review thread pagination repeated a page" if seen_thread_pages[page_digest]
315
+
316
+ seen_thread_pages[page_digest] = true
317
+ break unless threads_page["pageInfo"]["hasNextPage"] == true
318
+ cursor = threads_page["pageInfo"]["endCursor"]
319
+ raise Ace::Git::ProviderMalformedOutputError,
320
+ "Incomplete GitHub review thread evidence" unless cursor.is_a?(String)
321
+ end
322
+ if total_count != threads.length
323
+ raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub review thread evidence"
324
+ end
325
+ threads.each do |thread|
326
+ next unless thread.is_a?(Hash)
327
+ if seen_thread_ids[thread["id"]]
328
+ raise Ace::Git::ProviderMalformedOutputError, "Duplicate GitHub review thread id"
329
+ end
330
+ seen_thread_ids[thread["id"]] = true
331
+ end
332
+ index = {}
333
+ threads.each do |thread|
334
+ page_info = thread.is_a?(Hash) && thread["comments"].is_a?(Hash) && thread["comments"]["pageInfo"]
335
+ unless thread.is_a?(Hash) && thread["id"].is_a?(String) &&
336
+ [true, false].include?(thread["isResolved"]) &&
337
+ thread["comments"].is_a?(Hash) && thread["comments"]["nodes"].is_a?(Array) &&
338
+ page_info.is_a?(Hash) && [true, false].include?(page_info["hasNextPage"])
339
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
340
+ end
341
+ resolved = thread["isResolved"]
342
+ thread_comment_ids(thread["id"], thread["comments"]).each do |database_id|
343
+ index[database_id] = [thread["id"], resolved]
344
+ end
345
+ end
346
+ index
347
+ end
348
+
349
+ # A thread's comment list may itself be paginated; follow its cursor
350
+ # so resolution state covers every comment in the thread.
351
+ def thread_comment_ids(thread_id, first_page)
352
+ cursor = nil
353
+ ids = []
354
+ seen_cursors = {}
355
+ comments_page = first_page
356
+ loop do
357
+ unless comments_page.is_a?(Hash) && comments_page["pageInfo"].is_a?(Hash) &&
358
+ [true, false].include?(comments_page["pageInfo"]["hasNextPage"]) &&
359
+ comments_page["nodes"].is_a?(Array)
360
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
361
+ end
362
+ comments_page["nodes"].each do |comment|
363
+ unless comment.is_a?(Hash) && comment["databaseId"].is_a?(Integer)
364
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub review thread evidence"
365
+ end
366
+ ids << comment["databaseId"]
367
+ end
368
+ break unless comments_page["pageInfo"]["hasNextPage"] == true
369
+ cursor = comments_page["pageInfo"]["endCursor"]
370
+ raise Ace::Git::ProviderMalformedOutputError,
371
+ "Incomplete GitHub review thread evidence" unless cursor.is_a?(String)
372
+ raise Ace::Git::ProviderMalformedOutputError,
373
+ "GitHub review thread comment pagination repeated a cursor" if seen_cursors[cursor]
374
+
375
+ seen_cursors[cursor] = true
376
+
377
+ query = <<~GRAPHQL
378
+ query($id: ID!, $cursor: String) {
379
+ node(id: $id) {
380
+ ... on PullRequestReviewThread {
381
+ comments(first: 100, after: $cursor) {
382
+ pageInfo { hasNextPage endCursor }
383
+ nodes { databaseId }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ GRAPHQL
389
+ node = gh_graphql(query, id: thread_id, cursor: cursor).dig("data", "node")
390
+ comments_page = node.is_a?(Hash) ? node["comments"] : nil
391
+ end
392
+ ids
393
+ end
394
+
395
+ def pull_request_review_details(number:)
396
+ number = Integer(number)
397
+ data = gh_api("pulls/#{number}")
398
+ files = gh_api_pages("pulls/#{number}/files").map do |entry|
399
+ entry.is_a?(Hash) && entry["filename"]
400
+ end
401
+ unless data.is_a?(Hash) && data["base"].is_a?(Hash) && data["number"] == number &&
402
+ data.dig("base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/) &&
403
+ data["changed_files"].is_a?(Integer) && data["changed_files"] == files.length &&
404
+ files.all? { |path| path.is_a?(String) && !path.empty? }
405
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed or incomplete GitHub PR file evidence"
406
+ end
407
+ Ace::Git::ProviderReviewDetails.new(
408
+ server_name: server.name, repository_url: server.url, pr_number: number,
409
+ base_sha: data.dig("base", "sha"), files: files
410
+ )
411
+ end
412
+
413
+ def pull_request_checks(number:, head_sha:)
414
+ verify_expected_head!(pull_request(number: number), head_sha)
415
+ pages = gh_api("commits/#{head_sha}/check-runs?per_page=100", paginate: true)
416
+ # --paginate --slurp returns one payload per page; a single-page
417
+ # response stays a bare object.
418
+ page_payloads = pages.is_a?(Array) ? pages : [pages]
419
+ unless page_payloads.is_a?(Array) && page_payloads.all? { |page| page.is_a?(Hash) && page["check_runs"].is_a?(Array) }
420
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR check evidence"
421
+ end
422
+ runs = page_payloads.flat_map { |page| page["check_runs"] }
423
+ unless runs.all? { |run| run.is_a?(Hash) && run["name"].is_a?(String) }
424
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR check evidence"
425
+ end
426
+ total = page_payloads.filter_map { |page| page["total_count"] }.last
427
+ # The accumulated pages must account for every reported run; a
428
+ # shortfall would silently understate check evidence. GitHub
429
+ # caps the check-runs endpoint at the 1,000 most recent check
430
+ # suites, so a maxed-out page cannot be exhausted further and the
431
+ # snapshot is marked incomplete instead.
432
+ if total.is_a?(Integer) && total > runs.length && runs.length >= 1000
433
+ raise Ace::Git::ProviderMalformedOutputError,
434
+ "Incomplete GitHub PR check evidence: endpoint capped at the 1,000 most recent check suites"
435
+ end
436
+ unless total.is_a?(Integer) && total <= runs.length
437
+ raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub PR check evidence"
438
+ end
439
+ checks = runs.map do |run|
440
+ status = run["status"].to_s.downcase
441
+ raise Ace::Git::ProviderMalformedOutputError,
442
+ "Malformed GitHub check run entry: missing status" if status.empty?
443
+ conclusion = run["conclusion"]
444
+ unless conclusion.nil? || conclusion.is_a?(String)
445
+ raise Ace::Git::ProviderMalformedOutputError,
446
+ "Malformed GitHub check run entry: conclusion must be a string or null"
447
+ end
448
+ Ace::Git::ProviderCheck.new(
449
+ server_name: server.name, name: run["name"],
450
+ state: status.to_sym,
451
+ conclusion: conclusion&.downcase&.to_sym,
452
+ url: run["html_url"]
453
+ )
454
+ end
455
+ checks + commit_statuses(head_sha)
456
+ end
457
+
458
+ # Combined commit statuses are a separate GitHub evidence stream from
459
+ # check runs; both must appear or the snapshot overstates success.
460
+ def commit_statuses(head_sha)
461
+ data = gh_api("commits/#{head_sha}/status")
462
+ statuses = data.is_a?(Hash) && data["statuses"]
463
+ unless statuses.is_a?(Array) && statuses.all? { |s| s.is_a?(Hash) && s["context"].is_a?(String) }
464
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
465
+ end
466
+ total = data["total_count"]
467
+ unless total.is_a?(Integer)
468
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
469
+ end
470
+ # The combined view can cap its inline statuses array; the
471
+ # paginated statuses endpoint is the complete stream.
472
+ if total > statuses.length
473
+ statuses = gh_api("commits/#{head_sha}/statuses?per_page=100", paginate: true)
474
+ statuses = statuses.is_a?(Array) ? statuses.flat_map { |page| page.is_a?(Array) ? page : [page] } : [statuses]
475
+ unless statuses.all? { |st| st.is_a?(Hash) && st["context"].is_a?(String) }
476
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub commit status evidence"
477
+ end
478
+ # The statuses stream is reverse-chronological with one entry
479
+ # per state change; the combined total counts distinct
480
+ # contexts. Keep the first (latest) entry per context.
481
+ statuses = statuses.uniq { |st| st["context"] }
482
+ end
483
+ unless statuses.length == total
484
+ raise Ace::Git::ProviderMalformedOutputError, "Incomplete GitHub commit status evidence"
485
+ end
486
+ statuses.map do |status|
487
+ Ace::Git::ProviderCheck.new(
488
+ server_name: server.name, name: status["context"],
489
+ state: status["state"].to_s.downcase.to_sym,
490
+ conclusion: nil,
491
+ url: status["target_url"]
492
+ )
493
+ end
494
+ end
495
+
496
+ def repository_file(path:, ref:)
497
+ require "base64"
498
+ # %20, not form encoding: "+" in a path segment is a literal plus.
499
+ # Explicit unreserved-character allowlist: percent-encode
500
+ # everything else (DEFAULT_PARSER leaves ? and + ambiguous).
501
+ escaped = path.split("/").map { |part|
502
+ part.gsub(/[^A-Za-z0-9._~!$&'()*+,;=@:-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
503
+ }.join("/")
504
+ data = gh_api("contents/#{escaped}?ref=#{ref}")
505
+ unless data.is_a?(Hash) && data["encoding"] == "base64" && data["content"].is_a?(String)
506
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub repository file evidence"
507
+ end
508
+ Base64.strict_decode64(data["content"].delete("\n"))
509
+ rescue ArgumentError => e
510
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub repository file content: #{e.message}"
511
+ end
512
+
513
+ def create_pull_request_comment(number:, expected_head:, body:, correlation:)
514
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
515
+ require_open_pr!(pr)
516
+ marker = comment_marker(correlation)
517
+ with_session_lock(correlation) do
518
+ existing = matching_review_comments(pr, marker, expected_head)
519
+ if existing.length > 1
520
+ raise Ace::Git::ProviderConflictingMatchesError,
521
+ "Multiple comments match review session #{correlation} on PR ##{pr.number}"
522
+ end
523
+ if existing.one?
524
+ # A repeat reconciles only the exact session comment; a marker
525
+ # match with different content is a conflict, never our post.
526
+ sent = "#{body}\n\n#{marker}"
527
+ if existing.first.body == sent
528
+ verify_post_mutation_head!(pr, expected_head, correlation)
529
+ return review_mutation(pr, expected_head, existing.first, :existing)
530
+ end
531
+
532
+ raise Ace::Git::ProviderConflictingMatchesError,
533
+ "Review session #{correlation} comment exists on PR ##{pr.number} with different content"
534
+ end
535
+
536
+ second = verify_expected_head!(pull_request(number: pr.number), expected_head)
537
+ require_open_pr!(second)
538
+ begin
539
+ gh_api("issues/#{pr.number}/comments", method: :post, body: "#{body}\n\n#{marker}")
540
+ rescue Ace::Git::ProviderMalformedOutputError => e
541
+ raise Ace::Git::ProviderUnknownOutcomeError,
542
+ "PR comment sent but response unreadable for #{server.name}/#{pr.number}, head #{expected_head}, session #{correlation}: #{e.message}; reconcile before repeating"
543
+ rescue Ace::Git::ProviderUnreachableError => e
544
+ raise Ace::Git::ProviderUnknownOutcomeError,
545
+ "PR comment outcome unknown for #{server.name}/#{pr.number}, head #{expected_head}, " \
546
+ "session #{correlation}: #{e.message}; reconcile before repeating"
547
+ end
548
+ begin
549
+ matches = matching_review_comments(pr, marker, expected_head)
550
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
551
+ raise Ace::Git::ProviderUnknownOutcomeError,
552
+ "PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
553
+ end
554
+ unless matches.one? && matches.first.body == "#{body}\n\n#{marker}"
555
+ raise Ace::Git::ProviderUnknownOutcomeError,
556
+ "PR comment sent but reconciliation found #{matches.length} exact-content match(es) for session #{correlation}"
557
+ end
558
+ verify_post_mutation_head!(pr, expected_head, correlation)
559
+ review_mutation(pr, expected_head, matches.first, :created)
560
+ end
561
+ end
562
+
563
+ def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
564
+ comment_id = Integer(comment_id)
565
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
566
+ # Collected evidence includes issue and inline comments; the
567
+ # update must locate the id in both and use the matching route.
568
+ # Inline (review) comments take precedence: if an id exists in
569
+ # both collections, editing must target the inline comment the
570
+ # reviewer thread actually references.
571
+ inline_matches = gh_api_pages("pulls/#{pr.number}/comments").select { |entry| entry["id"] == comment_id }
572
+ issue_matches = inline_matches.empty? ? gh_api_pages("issues/#{pr.number}/comments").select { |entry| entry["id"] == comment_id } : []
573
+ matches = inline_matches + issue_matches
574
+ update_route = inline_matches.any? ? "pulls/comments/#{comment_id}" : "issues/comments/#{comment_id}"
575
+ unless matches.one?
576
+ raise Ace::Git::ProviderIdentityMismatchError,
577
+ "Comment #{comment_id} does not belong to selected PR ##{pr.number}"
578
+ end
579
+ comment = review_comment(matches.first, pr, expected_head)
580
+ if comment.body == body
581
+ verify_post_mutation_head!(pr, expected_head, "comment #{comment_id}")
582
+ return review_mutation(pr, expected_head, comment, :existing)
583
+ end
584
+
585
+ verify_expected_head!(pull_request(number: number), expected_head)
586
+ begin
587
+ gh_api(update_route, method: :patch, body: body)
588
+ rescue Ace::Git::ProviderMalformedOutputError => e
589
+ raise Ace::Git::ProviderUnknownOutcomeError,
590
+ "Comment update sent but response unreadable for #{server.name}/#{pr.number}, comment #{comment_id}: #{e.message}; reconcile before repeating"
591
+ rescue Ace::Git::ProviderUnreachableError => e
592
+ raise Ace::Git::ProviderUnknownOutcomeError,
593
+ "Comment update outcome unknown for #{server.name}/#{pr.number}, " \
594
+ "head #{expected_head}, comment #{comment_id}: #{e.message}"
595
+ end
596
+ begin
597
+ # Fetch the edited comment individually (both patch routes are
598
+ # valid GET item endpoints) and validate its PR identity.
599
+ begin
600
+ updated = gh_api(update_route)
601
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
602
+ raise Ace::Git::ProviderUnknownOutcomeError,
603
+ "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
604
+ end
605
+ unless comment_belongs_to_pr?(updated, pr.number)
606
+ raise Ace::Git::ProviderUnknownOutcomeError,
607
+ "Comment update sent but edited comment #{comment_id} does not verify against PR ##{pr.number}; reconcile before repeating"
608
+ end
609
+ rescue Ace::Git::ProviderUnreachableError => e
610
+ raise Ace::Git::ProviderUnknownOutcomeError,
611
+ "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
612
+ end
613
+ unless updated && updated["id"] == comment_id && updated["body"] == body
614
+ raise Ace::Git::ProviderUnknownOutcomeError,
615
+ "Comment update sent but exact PR comment #{comment_id} could not be verified"
616
+ end
617
+ verify_post_mutation_head!(pr, expected_head, "comment #{comment_id}")
618
+ review_mutation(pr, expected_head, review_comment(updated, pr, expected_head), :updated)
619
+ end
620
+
621
+ def resolve_pull_request_thread(number:, expected_head:, thread_id:)
622
+ unless thread_id.to_s.match?(/\APRRT_[A-Za-z0-9_=-]+\z/)
623
+ raise Ace::Git::ConfigError, "Invalid GitHub review thread ID"
624
+ end
625
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
626
+ query = <<~GRAPHQL
627
+ query($id: ID!) {
628
+ node(id: $id) {
629
+ ... on PullRequestReviewThread {
630
+ id isResolved
631
+ pullRequest { number headRefOid repository { url } }
632
+ }
633
+ }
634
+ }
635
+ GRAPHQL
636
+ thread = gh_graphql(query, id: thread_id).dig("data", "node")
637
+ verify_review_thread!(thread, pr, expected_head, thread_id: thread_id)
638
+ if thread["isResolved"] == true
639
+ verify_post_mutation_head!(pr, expected_head, "thread #{thread_id}")
640
+ return true
641
+ end
642
+
643
+ verify_expected_head!(pull_request(number: number), expected_head)
644
+ mutation = <<~GRAPHQL
645
+ mutation($id: ID!) {
646
+ resolveReviewThread(input: {threadId: $id}) {
647
+ thread { id isResolved pullRequest { number headRefOid repository { url } } }
648
+ }
649
+ }
650
+ GRAPHQL
651
+ begin
652
+ resolved = gh_graphql(mutation, id: thread_id).dig("data", "resolveReviewThread", "thread")
653
+ raise Ace::Git::ProviderMalformedOutputError, "Missing thread confirmation" unless resolved.is_a?(Hash)
654
+ rescue Ace::Git::ProviderMalformedOutputError => e
655
+ raise Ace::Git::ProviderUnknownOutcomeError,
656
+ "Thread resolution sent but confirmation unreadable for #{server.name}/#{pr.number}, thread #{thread_id}: #{e.message}; reconcile before repeating"
657
+ rescue Ace::Git::ProviderUnreachableError => e
658
+ raise Ace::Git::ProviderUnknownOutcomeError,
659
+ "Thread resolution outcome unknown for #{server.name}/#{pr.number}, " \
660
+ "head #{expected_head}, thread #{thread_id}: #{e.message}"
661
+ end
662
+ begin
663
+ verify_review_thread!(resolved, pr, expected_head, thread_id: thread_id)
664
+ rescue Ace::Git::ProviderIdentityMismatchError, Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderExpectedHeadConflictError => e
665
+ raise Ace::Git::ProviderUnknownOutcomeError,
666
+ "Thread resolution sent but confirmation unreadable for #{server.name}/#{pr.number}, thread #{thread_id}: #{e.message}; reconcile before repeating"
667
+ end
668
+ unless resolved["isResolved"] == true
669
+ # The resolution mutation may have landed; only its evidence
670
+ # is unreadable. Reconcile via the thread id.
671
+ raise Ace::Git::ProviderUnknownOutcomeError,
672
+ "GitHub did not confirm thread resolution for #{server.name}/#{pr.number}, thread #{thread_id}; reconcile before repeating"
673
+ end
674
+ verify_post_mutation_head!(pr, expected_head, "thread #{thread_id}")
675
+ true
676
+ end
677
+
136
678
  # ---- PR lifecycle mutations ----
137
679
 
138
680
  # @return [Array<ProviderPullRequest>] open PRs matching the exact
@@ -217,6 +759,245 @@ module Ace
217
759
 
218
760
  private
219
761
 
762
+ def require_open_pr!(pr)
763
+ return if pr.state == :open
764
+
765
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
766
+ "Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
767
+ end
768
+
769
+ def verify_review_thread!(thread, pr, expected_head, thread_id: nil)
770
+ unless thread.is_a?(Hash) && thread["id"] && thread["pullRequest"].is_a?(Hash) &&
771
+ thread.dig("pullRequest", "number") == pr.number &&
772
+ Ace::Git::Atoms::ServerUrl.match?(thread.dig("pullRequest", "repository", "url"), server.url)
773
+ raise Ace::Git::ProviderIdentityMismatchError,
774
+ "GitHub review thread does not belong to selected PR ##{pr.number}"
775
+ end
776
+ if thread_id && thread["id"] != thread_id
777
+ raise Ace::Git::ProviderIdentityMismatchError,
778
+ "GitHub returned thread #{thread["id"]} instead of #{thread_id}"
779
+ end
780
+ unless thread.dig("pullRequest", "headRefOid") == expected_head
781
+ raise Ace::Git::ProviderExpectedHeadConflictError,
782
+ "GitHub review thread PR head changed before resolution"
783
+ end
784
+ end
785
+
786
+ def gh_graphql(query, id:, cursor: nil)
787
+ host = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first
788
+ args = ["graphql", "-f", "query=#{query}", "-F", "id=#{id}", "--hostname", host]
789
+ args += ["-F", "cursor=#{cursor}"] if cursor
790
+ result = CliExecutor.execute("api", args, timeout: timeout, runner: runner)
791
+ classify_failure(result[:stderr], context: "api graphql") unless result[:success]
792
+ data = JSON.parse(result[:stdout])
793
+ if data.is_a?(Hash) && data["errors"].is_a?(Array) && data["errors"].any?
794
+ raise Ace::Git::ProviderUnreachableError,
795
+ "GitHub GraphQL rejected review operation: #{data["errors"].first["message"]}"
796
+ end
797
+ unless data.is_a?(Hash) && data["data"].is_a?(Hash)
798
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub GraphQL response"
799
+ end
800
+ data
801
+ rescue JSON::ParserError => e
802
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub GraphQL JSON: #{e.message}"
803
+ end
804
+
805
+ # A mutation receipt may only bind the expected head; if the PR head
806
+ # moved during the mutation the outcome stays uncertain.
807
+ def verify_post_mutation_head!(pr, expected_head, correlation)
808
+ current = begin
809
+ pull_request(number: pr.number)
810
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
811
+ raise Ace::Git::ProviderUnknownOutcomeError,
812
+ "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
813
+ end
814
+ return if current.head_sha == expected_head
815
+
816
+ raise Ace::Git::ProviderUnknownOutcomeError,
817
+ "PR head moved during mutation for #{server.name}/##{pr.number} " \
818
+ "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
819
+ end
820
+
821
+ # GitHub identity URLs end with the PR number for both comment kinds.
822
+ def comment_belongs_to_pr?(entry, number)
823
+ return false unless entry.is_a?(Hash)
824
+ issue_url = entry["issue_url"].to_s
825
+ # Review comments expose pull_request_url as a plain string.
826
+ pr_url = (entry.dig("pull_request", "url") || entry["pull_request_url"]).to_s
827
+ issue_url.end_with?("/issues/#{number}") || pr_url.end_with?("/pulls/#{number}")
828
+ end
829
+
830
+ # Serialize the read/post/reconcile sequence per session so two
831
+ # concurrent callers cannot both observe "no marker" and post.
832
+ def with_session_lock(correlation)
833
+ require "tmpdir"
834
+ lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
835
+ File.open(lock_path, File::CREAT | File::RDWR, 0600) do |lock|
836
+ lock.flock(File::LOCK_EX)
837
+ yield
838
+ end
839
+ end
840
+
841
+ def comment_marker(correlation)
842
+ unless correlation.to_s.match?(/\A[a-zA-Z0-9._:-]+\z/)
843
+ raise Ace::Git::ConfigError, "Invalid review session correlation"
844
+ end
845
+ "<!-- ace-review-session:#{correlation} -->"
846
+ end
847
+
848
+ def matching_review_comments(pr, marker, head)
849
+ gh_api_pages("issues/#{pr.number}/comments").filter_map do |entry|
850
+ # Every row must be a valid comment; a row without a body
851
+ # means malformed collection and must not hide our session post.
852
+ unless entry.is_a?(Hash) && entry["body"].is_a?(String)
853
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR comment collection"
854
+ end
855
+ next unless entry["body"].include?(marker)
856
+
857
+ review_comment(entry, pr, head)
858
+ end
859
+ end
860
+
861
+ def review_comment(entry, pr, head, threads = {})
862
+ user = entry.is_a?(Hash) ? entry["user"] : nil
863
+ unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
864
+ user.is_a?(Hash) && user["login"].is_a?(String)
865
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR comment evidence"
866
+ end
867
+ thread_id, resolved = threads[entry["id"]] || [nil, nil]
868
+ Ace::Git::ProviderReviewComment.new(
869
+ server_name: server.name, repository_url: server.url, pr_number: pr.number,
870
+ id: entry["id"], author: user["login"], body: entry["body"],
871
+ url: entry["html_url"], path: entry["path"], line: entry["line"],
872
+ head_sha: entry["commit_id"], resolved: resolved, thread_id: thread_id
873
+ )
874
+ end
875
+
876
+ def review_entry(entry, pr, head)
877
+ user = entry.is_a?(Hash) ? entry["user"] : nil
878
+ unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? &&
879
+ user.is_a?(Hash) && user["login"].is_a?(String) &&
880
+ entry["state"].is_a?(String) && !entry["state"].empty?
881
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR review evidence"
882
+ end
883
+ Ace::Git::ProviderReview.new(
884
+ server_name: server.name, repository_url: server.url, pr_number: pr.number,
885
+ id: entry["id"], author: user["login"], body: entry["body"],
886
+ state: entry["state"], url: entry["html_url"], head_sha: entry["commit_id"]
887
+ )
888
+ end
889
+
890
+ def review_mutation(pr, head, comment, idempotency)
891
+ Ace::Git::ProviderReviewMutation.new(
892
+ server_name: server.name, repository_url: server.url, pr_number: pr.number,
893
+ head_sha: head, comment: comment, idempotency: idempotency
894
+ )
895
+ end
896
+
897
+ def gh_api_pages(suffix)
898
+ data = gh_api(suffix, paginate: true)
899
+ pages = data.is_a?(Array) ? data : nil
900
+ unless pages && pages.all? { |page| page.is_a?(Array) }
901
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub PR collection"
902
+ end
903
+ pages.flatten(1)
904
+ end
905
+
906
+ def gh_api(suffix, method: :get, body: nil, paginate: false)
907
+ target = Ace::Git::Atoms::ServerUrl.normalize(server.url)
908
+ host, repository = target.split("/", 2)
909
+ unless repository&.match?(%r{\A[^/]+/[^/]+\z})
910
+ raise Ace::Git::ConfigError, "Invalid selected GitHub repository #{server.url}"
911
+ end
912
+ args = ["repos/#{repository}/#{suffix}", "--hostname", host]
913
+ args += ["--paginate", "--slurp"] if paginate
914
+ stdin = nil
915
+ if %i[post patch].include?(method)
916
+ # The body travels on stdin (`--input -`), never as a
917
+ # command argument: argv leaks into process listings and
918
+ # timeout errors, and huge review bodies exceed arg limits.
919
+ args += ["-X", method.to_s.upcase, "--input", "-"]
920
+ stdin = JSON.generate(body: body)
921
+ end
922
+ result = CliExecutor.execute("api", args, timeout: timeout, runner: runner, stdin: stdin)
923
+ classify_failure(result[:stderr], context: "api #{suffix}") unless result[:success]
924
+ JSON.parse(result[:stdout])
925
+ rescue JSON::ParserError => e
926
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed GitHub API JSON: #{e.message}"
927
+ end
928
+
929
+ # Exact-head local fallback for oversized API diffs: fetch the PR
930
+ # head ref from the selected host into a temp ref, verify the SHA
931
+ # matches, and diff base..head locally.
932
+ def local_diff_fallback(number)
933
+ base_oid = pull_request_review_details(number: number).base_sha
934
+ head_oid = pull_request(number: number).head_sha
935
+ # Preserve the configured scheme (http or https) rather than
936
+ # assuming https, and never use scp-style host:path remotes.
937
+ scheme = server.url.to_s[/\A[a-z][a-z0-9+.-]*:\/\//i].to_s.downcase
938
+ scheme = "https://" if scheme.empty?
939
+ host = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first
940
+ remote = "#{scheme}#{host}/#{repo_path}"
941
+ head_ref = "refs/ace/review/pr-#{number}-#{Process.pid}"
942
+ base_ref = "#{head_ref}-base"
943
+ begin
944
+ run_git("fetch", "--no-tags", remote, "+refs/pull/#{number}/head:#{head_ref}")
945
+ fetched = run_git("rev-parse", head_ref).strip
946
+ unless fetched == head_oid
947
+ raise Ace::Git::ProviderMalformedOutputError,
948
+ "Local fallback fetched #{fetched[0, 12]} but the reviewed head is #{head_oid[0, 12]}"
949
+ end
950
+ # The base commit may not exist locally and a two-dot diff
951
+ # would include unrelated target-branch changes: fetch the
952
+ # exact base SHA and diff from the merge base.
953
+ run_git("fetch", "--no-tags", remote, "+#{base_oid}:#{base_ref}")
954
+ merge_base = run_git("merge-base", base_ref, head_ref).strip
955
+ diff = run_git("diff", "#{merge_base}..#{head_ref}")
956
+ raise Ace::Git::ProviderMalformedOutputError, "Local fallback produced an empty diff" if diff.strip.empty?
957
+ diff
958
+ ensure
959
+ run_git("update-ref", "-d", head_ref) rescue nil
960
+ run_git("update-ref", "-d", base_ref) rescue nil
961
+ end
962
+ end
963
+
964
+ def repo_path
965
+ Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).last
966
+ end
967
+
968
+ def run_git(*args)
969
+ require "open3"
970
+ require "timeout"
971
+ out, status = Timeout.timeout(timeout) do
972
+ Open3.capture2({"LC_ALL" => "C"}, "git", *args)
973
+ end
974
+ rescue Timeout::Error
975
+ raise Ace::Git::ProviderUnreachableError, "git #{args.first} timed out after #{timeout}s"
976
+ else
977
+ raise Ace::Git::ProviderUnreachableError, "git #{args.first} failed" unless status.success?
978
+ out
979
+ end
980
+
981
+ def assert_issue_comment_owned!(number, comment_id)
982
+ return if issue_tracking(number: number)[:comments].any? { |comment| comment[:id].to_i == comment_id.to_i }
983
+
984
+ raise Ace::Git::ProviderIdentityMismatchError,
985
+ "Comment #{comment_id} is not on selected issue ##{number} in #{server.url}"
986
+ end
987
+
988
+ def issue_api(method, suffix, fields: [])
989
+ uri = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url))
990
+ owner_repo = uri.path.sub(%r{\A/}, "").chomp("/").sub(/\.git\z/, "")
991
+ args = ["repos/#{owner_repo}/#{suffix}", "--hostname", forge_hostname(uri), "--method", method]
992
+ fields.each { |field| args += ["--raw-field", field] }
993
+ result = CliExecutor.execute("api", args, timeout: timeout, runner: runner)
994
+ classify_failure(result[:stderr], context: "issue #{method} #{suffix}") unless result[:success]
995
+ result
996
+ rescue Ace::Git::ProviderUnreachableError => e
997
+ raise Ace::Git::ProviderUnknownOutcomeError,
998
+ "Unknown outcome for issue mutation on #{server.url} (#{suffix}): #{e.message}"
999
+ end
1000
+
220
1001
  STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
221
1002
 
222
1003
  # Send one mutating `gh` command. When `ambiguous` is true (create),
@@ -300,6 +1081,7 @@ module Ace
300
1081
  server_name: server_name,
301
1082
  number: data["number"],
302
1083
  title: data["title"],
1084
+ body: data["body"],
303
1085
  state: STATE_MAP.fetch(data["state"].to_s.upcase, data["state"].to_s.downcase.to_sym),
304
1086
  head_ref: data["headRefName"],
305
1087
  base_ref: data["baseRefName"],
@@ -328,10 +1110,38 @@ module Ace
328
1110
  def server_host_root
329
1111
  @server_host_root ||= begin
330
1112
  uri = URI.parse(server.url.to_s)
331
- "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
1113
+ "#{uri.scheme || "https"}://#{forge_hostname(uri)}"
1114
+ end
1115
+ end
1116
+
1117
+ # gh --hostname accepts "host:port"; a bare uri.host would silently
1118
+ # retarget a port-configured server to the default authority.
1119
+ # Issue evidence must match the configured server URL including its
1120
+ # scheme; ServerUrl.match? intentionally ignores schemes for git
1121
+ # remotes, but a web issue URL must not downgrade the authority.
1122
+ def url_matches_server?(evidence_url, number)
1123
+ expected = "#{Ace::Git::Atoms::ServerUrl.web_base(server.url)}/issues/#{number.to_i}"
1124
+ return false unless evidence_url.to_s.chomp("/").casecmp?(expected)
1125
+
1126
+ configured = URI.parse(Ace::Git::Atoms::ServerUrl.web_base(server.url)).scheme
1127
+ supplied = URI.parse(evidence_url.to_s).scheme
1128
+ configured.nil? || supplied.nil? || configured.downcase == supplied.downcase
1129
+ end
1130
+
1131
+ # Unrecognized or missing state is malformed evidence: mapping it to
1132
+ # :closed would let sync close an issue without valid state proof.
1133
+ def normalize_state(value)
1134
+ case value.to_s.upcase
1135
+ when "OPEN" then :open
1136
+ when "CLOSED" then :closed
1137
+ else raise Ace::Git::ProviderMalformedOutputError, "Unknown issue state #{value.inspect}"
332
1138
  end
333
1139
  end
334
1140
 
1141
+ def forge_hostname(uri)
1142
+ uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
1143
+ end
1144
+
335
1145
  def merged_at_of(data)
336
1146
  data["mergedAt"]
337
1147
  end
@@ -347,7 +1157,7 @@ module Ace
347
1157
  server_name: server.name,
348
1158
  number: data["number"],
349
1159
  title: data["title"],
350
- state: data["state"].to_s.upcase == "OPEN" ? :open : :closed,
1160
+ state: normalize_state(data["state"]),
351
1161
  author: normalize_author(data["author"]),
352
1162
  url: data["url"],
353
1163
  labels: nil
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Github
6
- VERSION = "0.2.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
@@ -6,7 +6,6 @@ require_relative "github/version"
6
6
  require_relative "github/cli_executor"
7
7
  require_relative "github/pr_identifier"
8
8
  require_relative "github/pr_fetcher"
9
- require_relative "github/issue_sync"
10
9
  require_relative "github/provider"
11
10
 
12
11
  module Ace
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.4.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-03 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.'
@@ -38,7 +38,6 @@ files:
38
38
  - Rakefile
39
39
  - lib/ace/git/github.rb
40
40
  - lib/ace/git/github/cli_executor.rb
41
- - lib/ace/git/github/issue_sync.rb
42
41
  - lib/ace/git/github/pr_fetcher.rb
43
42
  - lib/ace/git/github/pr_identifier.rb
44
43
  - lib/ace/git/github/provider.rb
@@ -1,252 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "open3"
5
-
6
- module Ace
7
- module Git
8
- module Github
9
- # Synchronize ACE task linkage metadata to GitHub issues.
10
- class IssueSync
11
- class OwnershipConflict < StandardError; end
12
-
13
- STICKY_MARKER = "<!-- ace-task:tracked -->"
14
- TRACKED_LABEL = "ace:tracked"
15
- TERMINAL_STATUSES = %w[done completed shipped closed cancelled skipped archived].freeze
16
-
17
- # True when GitHub sync can run: the gh CLI is installed and authenticated.
18
- # GitHub sync is best-effort; callers skip it silently when this is false.
19
- def self.available?
20
- CliExecutor.authenticated?
21
- end
22
-
23
- def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
24
- current_issue_ids: nil)
25
- current_ids = if current_issue_ids.nil?
26
- Array(issue_ids).map(&:to_i).uniq
27
- else
28
- Array(current_issue_ids).map(&:to_i).uniq
29
- end
30
-
31
- issue_ids.each do |issue_id|
32
- sync_issue(
33
- issue_id: issue_id.to_i,
34
- task_id: task_id,
35
- task_title: task_title,
36
- task_status: task_status,
37
- task_path: task_path,
38
- reason: reason,
39
- previous: previous,
40
- currently_linked: current_ids.include?(issue_id.to_i)
41
- )
42
- end
43
-
44
- {success: true, synced: issue_ids.length, issues: issue_ids}
45
- end
46
-
47
- def self.sync_issue(issue_id:, task_id:, task_title:, task_status:, task_path:, reason:, previous:,
48
- currently_linked:)
49
- issue = fetch_issue(issue_id)
50
- sticky = find_sticky_comment(issue["comments"] || [])
51
-
52
- lines = tracked_lines(sticky&.dig("body"))
53
- validate_link!(issue_id: issue_id, task_id: task_id, previous_task_id: previous&.[](:id), lines: lines) if currently_linked
54
- lines = remove_previous_line(lines, previous)
55
- lines = upsert_line(lines, task_id: task_id, task_title: task_title, task_path: task_path) if currently_linked
56
- if lines.empty?
57
- cleanup_tracking_artifacts(issue_id: issue_id, sticky: sticky, labels: issue["labels"] || [])
58
- return {success: true, issue: issue_id, reason: reason}
59
- end
60
-
61
- body = render_body(lines)
62
-
63
- if sticky
64
- update_comment(issue_id: issue_id, comment: sticky, body: body)
65
- else
66
- create_comment(issue_id: issue_id, body: body)
67
- end
68
-
69
- ensure_label(issue_id, issue["labels"] || [])
70
- if currently_linked
71
- sync_lifecycle(
72
- issue_id: issue_id,
73
- issue_state: issue["state"],
74
- task_status: task_status
75
- )
76
- end
77
-
78
- {success: true, issue: issue_id, reason: reason}
79
- end
80
-
81
- def self.cleanup_tracking_artifacts(issue_id:, sticky:, labels:)
82
- delete_comment(issue_id: issue_id, comment: sticky) if sticky
83
- remove_label(issue_id, labels)
84
- end
85
-
86
- def self.fetch_issue(issue_id)
87
- result = CliExecutor.execute("issue", ["view", issue_id.to_s, "--json", "state,comments,labels"])
88
- raise "Failed to fetch issue #{issue_id}: #{result[:stderr]}" unless result[:success]
89
-
90
- JSON.parse(result[:stdout])
91
- rescue JSON::ParserError => e
92
- raise "Failed to parse issue #{issue_id} response: #{e.message}"
93
- end
94
-
95
- def self.find_sticky_comment(comments)
96
- comments.find { |comment| comment["body"].to_s.include?(STICKY_MARKER) }
97
- end
98
-
99
- def self.tracked_lines(body)
100
- body.to_s.lines.map(&:rstrip).select { |line| line.start_with?("Tracked in ace-task: ") }
101
- end
102
-
103
- def self.remove_previous_line(lines, previous)
104
- return lines unless previous.is_a?(Hash)
105
-
106
- previous_id = previous[:id] || previous["id"]
107
- return lines unless previous_id
108
-
109
- lines.reject { |line| extract_task_id(line) == previous_id.to_s }
110
- end
111
-
112
- def self.upsert_line(lines, task_id:, task_title:, task_path:)
113
- filtered = lines.reject { |line| extract_task_id(line) == task_id.to_s }
114
- filtered << tracked_line(task_id: task_id, task_title: task_title, task_path: task_path)
115
- filtered.uniq.sort
116
- end
117
-
118
- def self.tracked_line(task_id:, task_title:, task_path:)
119
- "Tracked in ace-task: [#{task_id}](#{task_url(task_path)})"
120
- end
121
-
122
- def self.extract_task_id(line)
123
- match = line.match(/\[([^\]]+)\]\(/)
124
- match ? match[1] : nil
125
- end
126
-
127
- def self.render_body(lines)
128
- ([STICKY_MARKER] + lines).join("\n")
129
- end
130
-
131
- def self.create_comment(issue_id:, body:)
132
- CliExecutor.execute("issue", ["comment", issue_id.to_s, "--body", body]).tap do |result|
133
- raise "Failed to create sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
134
- end
135
- end
136
-
137
- def self.update_comment(issue_id:, comment:, body:)
138
- comment_id = comment_api_id(comment)
139
- CliExecutor.execute("api", [
140
- "repos/{owner}/{repo}/issues/comments/#{comment_id}",
141
- "--method", "PATCH",
142
- "--field", "body=#{body}"
143
- ]).tap do |result|
144
- raise "Failed to update sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
145
- end
146
- end
147
-
148
- def self.delete_comment(issue_id:, comment:)
149
- comment_id = comment_api_id(comment)
150
- CliExecutor.execute("api", [
151
- "repos/{owner}/{repo}/issues/comments/#{comment_id}",
152
- "--method", "DELETE"
153
- ]).tap do |result|
154
- raise "Failed to delete sticky comment for issue #{issue_id}: #{result[:stderr]}" unless result[:success]
155
- end
156
- end
157
-
158
- def self.comment_api_id(comment)
159
- database_id = comment["databaseId"] || comment[:databaseId]
160
- return database_id.to_s if database_id.to_s.match?(/\A\d+\z/)
161
-
162
- url = comment["url"] || comment[:url]
163
- return Regexp.last_match(1) if url.to_s.match(/issuecomment-(\d+)/)
164
-
165
- (comment["id"] || comment[:id]).to_s
166
- end
167
-
168
- def self.ensure_label(issue_id, labels)
169
- return if labels.any? { |label| label["name"] == TRACKED_LABEL }
170
-
171
- result = CliExecutor.execute("issue", ["edit", issue_id.to_s, "--add-label", TRACKED_LABEL])
172
- raise "Failed to add #{TRACKED_LABEL} label to issue #{issue_id}: #{result[:stderr]}" unless result[:success]
173
- end
174
-
175
- def self.remove_label(issue_id, labels)
176
- return unless labels.any? { |label| label["name"] == TRACKED_LABEL }
177
-
178
- result = CliExecutor.execute("issue", ["edit", issue_id.to_s, "--remove-label", TRACKED_LABEL])
179
- raise "Failed to remove #{TRACKED_LABEL} label from issue #{issue_id}: #{result[:stderr]}" unless result[:success]
180
- end
181
-
182
- def self.validate_link!(issue_id:, task_id:, previous_task_id: nil, lines: nil)
183
- issue = fetch_issue(issue_id)
184
- sticky = find_sticky_comment(issue["comments"] || [])
185
- lines ||= tracked_lines(sticky&.dig("body"))
186
- owners = lines.map { |line| extract_task_id(line) }.compact.uniq
187
- return true if owners.empty?
188
- return true if owners == [task_id.to_s]
189
- return true if previous_task_id && owners == [previous_task_id.to_s]
190
-
191
- raise OwnershipConflict, "GitHub issue ##{issue_id} is already owned by task #{owners.first}"
192
- end
193
-
194
- def self.sync_lifecycle(issue_id:, issue_state:, task_status:)
195
- terminal = TERMINAL_STATUSES.include?(task_status.to_s.downcase)
196
- issue_open = issue_state.to_s.upcase == "OPEN"
197
-
198
- if terminal && issue_open
199
- close_result = CliExecutor.execute("issue", ["close", issue_id.to_s])
200
- raise "Failed to close issue #{issue_id}: #{close_result[:stderr]}" unless close_result[:success]
201
- elsif !terminal && !issue_open
202
- reopen_result = CliExecutor.execute("issue", ["reopen", issue_id.to_s])
203
- raise "Failed to reopen issue #{issue_id}: #{reopen_result[:stderr]}" unless reopen_result[:success]
204
- end
205
- end
206
-
207
- def self.task_url(task_path)
208
- return task_path.to_s if task_path.to_s.empty?
209
-
210
- repo_root = repo_root_path
211
- relative = if repo_root && task_path.start_with?(repo_root + "/")
212
- task_path.delete_prefix(repo_root + "/")
213
- else
214
- task_path.to_s
215
- end
216
-
217
- repo_slug = github_repo_slug
218
- return relative unless repo_slug
219
-
220
- "https://github.com/#{repo_slug}/blob/HEAD/#{relative}"
221
- end
222
-
223
- def self.repo_root_path
224
- stdout, _stderr, status = Open3.capture3("git", "rev-parse", "--show-toplevel")
225
- return nil unless status.success?
226
-
227
- stdout.strip
228
- rescue
229
- nil
230
- end
231
-
232
- def self.github_repo_slug
233
- stdout, _stderr, status = Open3.capture3("git", "remote", "get-url", "origin")
234
- return nil unless status.success?
235
-
236
- url = stdout.strip
237
- return Regexp.last_match(1) if url.match(%r{github\.com[:/](.+?)(?:\.git)?$})
238
-
239
- nil
240
- rescue
241
- nil
242
- end
243
-
244
- private_class_method :sync_issue, :fetch_issue, :find_sticky_comment, :tracked_lines, :remove_previous_line,
245
- :upsert_line, :tracked_line, :extract_task_id, :render_body, :create_comment, :update_comment, :delete_comment,
246
- :comment_api_id, :ensure_label, :remove_label, :cleanup_tracking_artifacts, :sync_lifecycle, :task_url, :repo_root_path,
247
- :github_repo_slug
248
- end
249
-
250
- end
251
- end
252
- end