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 +4 -4
- data/CHANGELOG.md +37 -0
- data/lib/ace/git/github/cli_executor.rb +14 -10
- data/lib/ace/git/github/pr_fetcher.rb +8 -4
- data/lib/ace/git/github/provider.rb +833 -8
- data/lib/ace/git/github/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 865ef273c3954eefcfd0742e3a2dc3b2e9a02aa2e93aef1f13888f02e128aea7
|
|
4
|
+
data.tar.gz: 0f6167a9930838246beafea1454ce56a1b68938c96c6be658308e3724130801d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0043d7965efda107ac27aa490b6e7e329b7dbc4a792c919fb3bca22e11aabcf406203cf8b8578d662c8aba582d045f197de3ad7a42dba45af24dafb008a3a001
|
|
7
|
+
data.tar.gz: c3c2f64ae9a1a463113bb49f6f453104d358161e9c94b35261327ea3368e787a795d7460f605769cf8f938d5b14569a8290cdb14617f5c309676fd34d5980839
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-github
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-10-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.26'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.26'
|
|
26
26
|
description: 'Implements the shared ace-git provider contract for GitHub: all `gh`
|
|
27
27
|
CLI invocation, output parsing, authentication verification, and classified failure
|
|
28
28
|
handling live behind one normalized evidence boundary.'
|