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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/ace/git/github/cli_executor.rb +14 -10
- data/lib/ace/git/github/provider.rb +817 -7
- data/lib/ace/git/github/version.rb +1 -1
- data/lib/ace/git/github.rb +0 -1
- metadata +4 -5
- data/lib/ace/git/github/issue_sync.rb +0 -252
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baa6f879ef34125b1099214ec6387d091ebbca80116f51c7ea4ea37fc9fc08a9
|
|
4
|
+
data.tar.gz: b7f37f202295ba924168e9b3e2f639c221bf93c63a8d2f6112a2fc243cdb8888
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
118
|
-
conclusion: BUCKET_MAP.fetch(bucket,
|
|
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
|
|
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"]
|
|
1160
|
+
state: normalize_state(data["state"]),
|
|
351
1161
|
author: normalize_author(data["author"]),
|
|
352
1162
|
url: data["url"],
|
|
353
1163
|
labels: nil
|
data/lib/ace/git/github.rb
CHANGED
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.4.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-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.
|
|
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.'
|
|
@@ -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
|