ace-git-forgejo 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 +46 -0
- data/README.md +35 -2
- data/lib/ace/git/forgejo/cli_executor.rb +112 -31
- data/lib/ace/git/forgejo/http_client.rb +111 -0
- data/lib/ace/git/forgejo/parsers.rb +23 -4
- data/lib/ace/git/forgejo/provider.rb +451 -56
- data/lib/ace/git/forgejo/repository_binding.rb +206 -0
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +2 -0
- metadata +6 -4
|
@@ -11,9 +11,14 @@ module Ace
|
|
|
11
11
|
# Forgejo provider implementation of the shared provider contract.
|
|
12
12
|
#
|
|
13
13
|
# Owns Forgejo terminology and translates `fj` responses into the
|
|
14
|
-
# normalized evidence types defined by the ace-git core. Every
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# normalized evidence types defined by the ace-git core. Every
|
|
15
|
+
# repository-scoped call is bound to the selected server's exact
|
|
16
|
+
# host/owner/repository through the {CliExecutor} repository boundary:
|
|
17
|
+
# the selected identity is mandatory, unobserved operations refuse
|
|
18
|
+
# before launch, and returned evidence is checked for conflicting
|
|
19
|
+
# identity. Every failure is classified with the shared taxonomy;
|
|
20
|
+
# `fj` failures are never hidden behind ad-hoc curl calls or silent
|
|
21
|
+
# fallbacks.
|
|
17
22
|
class Provider < Ace::Git::Providers::Base
|
|
18
23
|
class << self
|
|
19
24
|
def display_name
|
|
@@ -31,14 +36,15 @@ module Ace
|
|
|
31
36
|
CliExecutor.check_installed!(runner: runner)
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
# @return [Boolean] true when the server host has a
|
|
39
|
+
# @return [Boolean] true when the selected server's host has a
|
|
40
|
+
# configured `fj` login
|
|
35
41
|
def authenticated?
|
|
36
|
-
CliExecutor.authenticated?(
|
|
42
|
+
CliExecutor.authenticated?(repository_target.authority, runner: runner)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
|
|
40
46
|
def check_authenticated!
|
|
41
|
-
CliExecutor.check_authenticated!(
|
|
47
|
+
CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
# @return [ProviderPullRequest] normalized pull request evidence
|
|
@@ -51,13 +57,13 @@ module Ace
|
|
|
51
57
|
|
|
52
58
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
53
59
|
def pull_request_for_branch(branch:)
|
|
54
|
-
# `fj pr search` has no --limit flag; fetch all
|
|
55
|
-
|
|
60
|
+
# `fj pr search` has no --limit flag (observed v0.6.0); fetch all
|
|
61
|
+
# and order newest-first.
|
|
62
|
+
listing = fj(:pr_search, "all")
|
|
56
63
|
entries = Parsers.parse_search(listing).sort_by { |entry| -entry[:number] }
|
|
57
64
|
|
|
58
65
|
entries.each do |entry|
|
|
59
|
-
|
|
60
|
-
parsed = Parsers.parse_pr_view(view)
|
|
66
|
+
parsed = view_pr(entry[:number])
|
|
61
67
|
next unless parsed && parsed[:head_ref] == branch
|
|
62
68
|
|
|
63
69
|
parsed[:head_sha] = head_sha_of(entry[:number])
|
|
@@ -69,7 +75,7 @@ module Ace
|
|
|
69
75
|
|
|
70
76
|
# @return [String] unified diff text for the pull request
|
|
71
77
|
def pull_request_diff(number:)
|
|
72
|
-
fj(
|
|
78
|
+
fj(:pr_diff, request_number!(number))
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
# `fj pr search` has no --limit flag; cap client-side after sorting
|
|
@@ -77,7 +83,7 @@ module Ace
|
|
|
77
83
|
#
|
|
78
84
|
# @return [Array<ProviderPullRequest>] recent PRs, newest first
|
|
79
85
|
def recent_pull_requests(limit:)
|
|
80
|
-
listing = fj(
|
|
86
|
+
listing = fj(:pr_search, "all")
|
|
81
87
|
Parsers.parse_search(listing)
|
|
82
88
|
.map { |entry| normalize_search_entry(entry) }
|
|
83
89
|
.sort_by { |pr| -pr.number }
|
|
@@ -86,10 +92,11 @@ module Ace
|
|
|
86
92
|
|
|
87
93
|
# @return [ProviderIssue] normalized issue evidence
|
|
88
94
|
def issue(number:)
|
|
89
|
-
view = fj(
|
|
95
|
+
view = fj(:issue_view, request_number!(number))
|
|
90
96
|
parsed = Parsers.parse_issue_view(view) ||
|
|
91
97
|
raise(Ace::Git::ProviderMalformedOutputError,
|
|
92
98
|
"Unrecognized `fj issue view #{number}` output; update the Forgejo provider parser")
|
|
99
|
+
verify_returned_number!(parsed, number, "issue")
|
|
93
100
|
|
|
94
101
|
Ace::Git::ProviderIssue.new(
|
|
95
102
|
server_name: server.name,
|
|
@@ -104,9 +111,11 @@ module Ace
|
|
|
104
111
|
|
|
105
112
|
# @return [Array<ProviderCheck>] normalized check evidence for a ref
|
|
106
113
|
def checks(ref:)
|
|
107
|
-
tasks = fj(
|
|
114
|
+
tasks = fj(:actions_tasks)
|
|
108
115
|
Parsers.parse_actions_tasks(tasks)
|
|
109
|
-
|
|
116
|
+
# Exact-head evidence requires the complete 40-character SHA
|
|
117
|
+
# equal to the reviewed head; prefix matches are rejected.
|
|
118
|
+
.select { |task| task[:sha] == ref.to_s }
|
|
110
119
|
.map do |task|
|
|
111
120
|
Ace::Git::ProviderCheck.new(
|
|
112
121
|
server_name: server.name,
|
|
@@ -119,25 +128,224 @@ module Ace
|
|
|
119
128
|
end
|
|
120
129
|
|
|
121
130
|
# @return [ProviderRepository] normalized repository evidence
|
|
131
|
+
# @raise [ProviderIdentityMismatchError] when the CLI reports a
|
|
132
|
+
# repository other than the selected one
|
|
122
133
|
def repository
|
|
123
|
-
|
|
134
|
+
target = repository_target
|
|
135
|
+
view = fj(:repo_view)
|
|
124
136
|
parsed = Parsers.parse_repo_view(view)
|
|
137
|
+
verify_returned_repository!(parsed, target)
|
|
125
138
|
|
|
126
139
|
Ace::Git::ProviderRepository.new(
|
|
127
140
|
server_name: server.name,
|
|
128
141
|
full_name: parsed[:full_name],
|
|
129
142
|
default_branch: nil,
|
|
130
|
-
url: parsed[:url]
|
|
143
|
+
url: parsed[:url] || target.url
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The fj CLI surface omits the PR body; the repository-bound API
|
|
148
|
+
# supplies it for review evidence. Malformed payloads fail closed.
|
|
149
|
+
def pull_request_body(number:)
|
|
150
|
+
number = request_number!(number)
|
|
151
|
+
data = review_http.request(:get, "pulls/#{number}")
|
|
152
|
+
unless data.is_a?(Hash) && data["number"] == number && data.key?("body") &&
|
|
153
|
+
(data["body"].nil? || data["body"].is_a?(String))
|
|
154
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR body evidence"
|
|
155
|
+
end
|
|
156
|
+
data["body"]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def pull_request_review_evidence(number:, expected_head:)
|
|
160
|
+
number = request_number!(number)
|
|
161
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
162
|
+
comments = review_http.paginate("issues/#{number}/comments").map do |entry|
|
|
163
|
+
review_comment(entry, number, expected_head)
|
|
164
|
+
end
|
|
165
|
+
reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
|
|
166
|
+
review_entry(entry, number, expected_head)
|
|
167
|
+
end
|
|
168
|
+
# Review comments live under each review, not at the PR level.
|
|
169
|
+
inline = reviews.flat_map do |review|
|
|
170
|
+
# The per-review endpoint returns the complete list (no
|
|
171
|
+
# pagination); a repeated nonempty page would trip the loop
|
|
172
|
+
# guard, so fetch once and require an array.
|
|
173
|
+
entries = review_http.request(:get, "pulls/#{number}/reviews/#{review.id}/comments")
|
|
174
|
+
unless entries.is_a?(Array)
|
|
175
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
176
|
+
end
|
|
177
|
+
entries.map { |entry| review_comment(entry, number, expected_head) }
|
|
178
|
+
end
|
|
179
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
180
|
+
Ace::Git::ProviderReviewEvidence.new(
|
|
181
|
+
server_name: server.name, repository_url: server.url,
|
|
182
|
+
pr_number: number, head_sha: expected_head,
|
|
183
|
+
comments: comments + inline, reviews: reviews
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def pull_request_review_details(number:)
|
|
188
|
+
number = request_number!(number)
|
|
189
|
+
data = review_http.request(:get, "pulls/#{number}")
|
|
190
|
+
files = review_http.paginate("pulls/#{number}/files").map do |entry|
|
|
191
|
+
entry.is_a?(Hash) && entry["filename"]
|
|
192
|
+
end
|
|
193
|
+
unless data.is_a?(Hash) && data["base"].is_a?(Hash) && data["number"] == number &&
|
|
194
|
+
data.dig("base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/) &&
|
|
195
|
+
data["changed_files"].is_a?(Integer) && data["changed_files"] == files.length &&
|
|
196
|
+
files.all? { |path| path.is_a?(String) && !path.empty? }
|
|
197
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed or incomplete Forgejo PR file evidence"
|
|
198
|
+
end
|
|
199
|
+
Ace::Git::ProviderReviewDetails.new(
|
|
200
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
201
|
+
base_sha: data.dig("base", "sha"), files: files
|
|
131
202
|
)
|
|
132
203
|
end
|
|
133
204
|
|
|
205
|
+
def pull_request_checks(number:, head_sha:)
|
|
206
|
+
verify_expected_head!(pull_request(number: number), head_sha)
|
|
207
|
+
checks(ref: head_sha)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def repository_file(path:, ref:)
|
|
211
|
+
require "base64"
|
|
212
|
+
# %20, not form encoding: "+" in a path segment is a literal plus.
|
|
213
|
+
# Explicit unreserved-character allowlist: percent-encode
|
|
214
|
+
# everything else (DEFAULT_PARSER leaves ? and + ambiguous).
|
|
215
|
+
escaped = path.split("/").map { |part|
|
|
216
|
+
part.gsub(/[^A-Za-z0-9._~!$&'()*+,;=@:-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
217
|
+
}.join("/")
|
|
218
|
+
data = review_http.request(:get, "contents/#{escaped}?ref=#{ref}")
|
|
219
|
+
unless data.is_a?(Hash) && data["encoding"] == "base64" && data["content"].is_a?(String)
|
|
220
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file evidence"
|
|
221
|
+
end
|
|
222
|
+
Base64.strict_decode64(data["content"].delete("\n"))
|
|
223
|
+
rescue ArgumentError => e
|
|
224
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file content: #{e.message}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def create_pull_request_comment(number:, expected_head:, body:, correlation:)
|
|
228
|
+
number = request_number!(number)
|
|
229
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
230
|
+
require_open_pr!(pr)
|
|
231
|
+
marker = comment_marker(correlation)
|
|
232
|
+
with_session_lock(correlation) do
|
|
233
|
+
existing = matching_review_comments(number, marker, expected_head)
|
|
234
|
+
if existing.length > 1
|
|
235
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
236
|
+
"Multiple comments match review session #{correlation} on PR ##{number}"
|
|
237
|
+
end
|
|
238
|
+
if existing.one?
|
|
239
|
+
# A repeat reconciles only the exact session comment; a marker
|
|
240
|
+
# match with different content is a conflict, never our post.
|
|
241
|
+
sent = "#{body}\n\n#{marker}"
|
|
242
|
+
if existing.first.body == sent
|
|
243
|
+
verify_post_mutation_head!(number, expected_head, correlation)
|
|
244
|
+
return review_mutation(number, expected_head, existing.first, :existing)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
248
|
+
"Review session #{correlation} comment exists on PR ##{number} with different content"
|
|
249
|
+
end
|
|
250
|
+
second = verify_expected_head!(pull_request(number: number), expected_head)
|
|
251
|
+
require_open_pr!(second)
|
|
252
|
+
begin
|
|
253
|
+
review_http.request(:post, "issues/#{number}/comments", body: {body: "#{body}\n\n#{marker}"})
|
|
254
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
255
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
256
|
+
"PR comment sent but response unreadable for #{server.name}/#{number}, head #{expected_head}, session #{correlation}: #{e.message}; reconcile before repeating"
|
|
257
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
258
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
259
|
+
"PR comment outcome unknown for #{server.name}/#{number}, head #{expected_head}, " \
|
|
260
|
+
"session #{correlation}: #{e.message}; reconcile before repeating"
|
|
261
|
+
end
|
|
262
|
+
begin
|
|
263
|
+
matches = matching_review_comments(number, marker, expected_head)
|
|
264
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
265
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
266
|
+
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
267
|
+
end
|
|
268
|
+
unless matches.one? && matches.first.body == "#{body}\n\n#{marker}"
|
|
269
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
270
|
+
"PR comment sent but reconciliation found #{matches.length} exact-content match(es) for session #{correlation}"
|
|
271
|
+
end
|
|
272
|
+
verify_post_mutation_head!(number, expected_head, correlation)
|
|
273
|
+
review_mutation(number, expected_head, matches.first, :created)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
|
|
278
|
+
number = request_number!(number)
|
|
279
|
+
comment_id = Integer(comment_id)
|
|
280
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
281
|
+
issue_matches = review_http.paginate("issues/#{number}/comments").select { |entry| entry["id"] == comment_id }
|
|
282
|
+
# Inline ids live under individual reviews; locate them there
|
|
283
|
+
# so an unsupported edit is classified explicitly instead of
|
|
284
|
+
# being misread as not-belonging to this PR.
|
|
285
|
+
inline_found = issue_matches.empty? && review_comment_ids(number).include?(comment_id)
|
|
286
|
+
if inline_found
|
|
287
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
288
|
+
"Editing inline review comments is unsupported by the observed fj v0.6.0 surface (comment #{comment_id})"
|
|
289
|
+
end
|
|
290
|
+
matches = issue_matches
|
|
291
|
+
update_route = "issues/comments/#{comment_id}"
|
|
292
|
+
unless matches.one?
|
|
293
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
294
|
+
"Comment #{comment_id} does not belong to selected PR ##{number}"
|
|
295
|
+
end
|
|
296
|
+
comment = review_comment(matches.first, number, expected_head)
|
|
297
|
+
if comment.body == body
|
|
298
|
+
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
299
|
+
return review_mutation(number, expected_head, comment, :existing)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
303
|
+
begin
|
|
304
|
+
review_http.request(:patch, update_route, body: {body: body})
|
|
305
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
306
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
307
|
+
"Comment update sent but response unreadable for #{server.name}/#{number}, comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
308
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
309
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
310
|
+
"Comment update outcome unknown for #{server.name}/#{number}, " \
|
|
311
|
+
"head #{expected_head}, comment #{comment_id}: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
begin
|
|
314
|
+
# Fetch individually and validate the Forgejo issue identity.
|
|
315
|
+
begin
|
|
316
|
+
updated = review_http.request(:get, update_route)
|
|
317
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
318
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
319
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
320
|
+
end
|
|
321
|
+
unless updated.is_a?(Hash) && updated["issue_url"].to_s.end_with?("/#{number}")
|
|
322
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
323
|
+
"Comment update sent but edited comment #{comment_id} does not verify against PR ##{number}; reconcile before repeating"
|
|
324
|
+
end
|
|
325
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
326
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
327
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
328
|
+
end
|
|
329
|
+
unless updated && updated["id"] == comment_id && updated["body"] == body
|
|
330
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
331
|
+
"Comment update sent but exact PR comment #{comment_id} could not be verified"
|
|
332
|
+
end
|
|
333
|
+
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
334
|
+
review_mutation(number, expected_head, review_comment(updated, number, expected_head), :updated)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def resolve_pull_request_thread(number:, expected_head:, thread_id:)
|
|
338
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
339
|
+
"Forgejo does not expose an observed repository-bound review thread resolution capability"
|
|
340
|
+
end
|
|
341
|
+
|
|
134
342
|
# ---- PR lifecycle mutations ----
|
|
135
343
|
|
|
136
344
|
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
137
345
|
# base/head identity, with head SHA provenance
|
|
138
346
|
def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
139
347
|
require_base_on_server!(base_repository_url)
|
|
140
|
-
listing = fj(
|
|
348
|
+
listing = fj(:pr_search, "open")
|
|
141
349
|
Parsers.parse_search(listing).filter_map do |entry|
|
|
142
350
|
parsed = view_pr(entry[:number])
|
|
143
351
|
next unless parsed && parsed[:state] == :open
|
|
@@ -150,14 +358,14 @@ module Ace
|
|
|
150
358
|
|
|
151
359
|
# Create a PR via `fj pr create`, reconciling to an exact open match
|
|
152
360
|
# first and proving the resulting head against `expected_head`.
|
|
153
|
-
# `fj` cannot
|
|
154
|
-
# real draft state.
|
|
361
|
+
# `fj` cannot encode an explicit fork head; the receipt reports the
|
|
362
|
+
# provider's real draft state.
|
|
155
363
|
#
|
|
156
364
|
# @return [ProviderMutationReceipt] idempotency :created/:existing
|
|
157
365
|
def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
|
|
158
|
-
# `fj pr create`
|
|
159
|
-
#
|
|
160
|
-
# lookups) beats creating the wrong PR.
|
|
366
|
+
# `fj pr create -r` binds the base repository, but no observed form
|
|
367
|
+
# encodes a cross-repository fork head; refusing before any command
|
|
368
|
+
# (even lookups) beats creating the wrong PR.
|
|
161
369
|
unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
162
370
|
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
163
371
|
"Forgejo CLI (fj) pr create cannot target head repository " \
|
|
@@ -178,9 +386,8 @@ module Ace
|
|
|
178
386
|
return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
|
|
179
387
|
end
|
|
180
388
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
|
|
389
|
+
send_mutation(:pr_create, title, head_ref, base_ref, body,
|
|
390
|
+
ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
|
|
184
391
|
|
|
185
392
|
created = reconcile_created(
|
|
186
393
|
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
@@ -195,9 +402,10 @@ module Ace
|
|
|
195
402
|
#
|
|
196
403
|
# @return [ProviderMutationReceipt] operation :update
|
|
197
404
|
def update_pull_request(number:, expected_head:, title: nil, body: nil)
|
|
405
|
+
number = request_number!(number)
|
|
198
406
|
verify_expected_head!(fetch_pr(number), expected_head)
|
|
199
|
-
send_mutation(
|
|
200
|
-
send_mutation(
|
|
407
|
+
send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
|
|
408
|
+
send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
|
|
201
409
|
receipt(:update, fetch_pr(number), nil)
|
|
202
410
|
end
|
|
203
411
|
|
|
@@ -222,23 +430,143 @@ module Ace
|
|
|
222
430
|
|
|
223
431
|
private
|
|
224
432
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
433
|
+
def require_open_pr!(pr)
|
|
434
|
+
return if pr.state == :open
|
|
435
|
+
|
|
436
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
437
|
+
"Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def review_http
|
|
441
|
+
@review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A mutation receipt may only bind the expected head; if the PR head
|
|
445
|
+
# moved during the mutation the outcome stays uncertain.
|
|
446
|
+
def verify_post_mutation_head!(number, expected_head, correlation)
|
|
447
|
+
current = begin
|
|
448
|
+
pull_request(number: number)
|
|
449
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
450
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
451
|
+
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
452
|
+
end
|
|
453
|
+
return if current.head_sha == expected_head
|
|
454
|
+
|
|
455
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
456
|
+
"PR head moved during mutation for #{server.name}/##{number} " \
|
|
457
|
+
"(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Serialize the read/post/reconcile sequence per session so two
|
|
461
|
+
# concurrent callers cannot both observe "no marker" and post.
|
|
462
|
+
def with_session_lock(correlation)
|
|
463
|
+
require "tmpdir"
|
|
464
|
+
lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
|
|
465
|
+
File.open(lock_path, File::CREAT | File::RDWR, 0600) do |lock|
|
|
466
|
+
lock.flock(File::LOCK_EX)
|
|
467
|
+
yield
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def comment_marker(correlation)
|
|
472
|
+
unless correlation.to_s.match?(/\A[a-zA-Z0-9._:-]+\z/)
|
|
473
|
+
raise Ace::Git::ConfigError, "Invalid review session correlation"
|
|
474
|
+
end
|
|
475
|
+
"<!-- ace-review-session:#{correlation} -->"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def matching_review_comments(number, marker, expected_head)
|
|
479
|
+
review_http.paginate("issues/#{number}/comments").filter_map do |entry|
|
|
480
|
+
next unless entry.is_a?(Hash) && entry["body"].is_a?(String) && entry["body"].include?(marker)
|
|
481
|
+
|
|
482
|
+
review_comment(entry, number, expected_head)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def review_comment(entry, number, head)
|
|
487
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
488
|
+
entry.dig("user", "login").is_a?(String)
|
|
489
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
|
|
490
|
+
end
|
|
491
|
+
Ace::Git::ProviderReviewComment.new(
|
|
492
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
493
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
494
|
+
url: entry["html_url"], path: entry["path"],
|
|
495
|
+
# Forgejo supplies position/original_position rather than
|
|
496
|
+
# line; the positive side position is the normalized line.
|
|
497
|
+
line: entry["line"] || [entry["position"], entry["original_position"]].compact.reject { |v| !v.is_a?(Integer) || v <= 0 }.first,
|
|
498
|
+
head_sha: entry["commit_id"] || head, resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
|
|
499
|
+
thread_id: nil
|
|
500
|
+
)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Collect inline (review) comment ids by walking each review.
|
|
504
|
+
def review_comment_ids(number)
|
|
505
|
+
review_http.paginate("pulls/#{number}/reviews").flat_map do |review|
|
|
506
|
+
entries = review_http.request(:get, "pulls/#{number}/reviews/#{review["id"]}/comments")
|
|
507
|
+
unless entries.is_a?(Array)
|
|
508
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
509
|
+
end
|
|
510
|
+
entries.filter_map { |entry| entry["id"] if entry.is_a?(Hash) && entry["id"].is_a?(Integer) }
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def review_entry(entry, number, head)
|
|
515
|
+
reviewer = entry.dig("user", "login") || entry.dig("team", "name")
|
|
516
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
|
|
517
|
+
entry["state"].is_a?(String) && !entry["state"].empty?
|
|
518
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
|
|
519
|
+
end
|
|
520
|
+
Ace::Git::ProviderReview.new(
|
|
521
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
522
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
523
|
+
# Forgejo reports REQUEST_CHANGES; consumers count only the
|
|
524
|
+
# canonical CHANGES_REQUESTED spelling.
|
|
525
|
+
state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
|
|
526
|
+
url: entry["html_url"], head_sha: entry["commit_id"] || head
|
|
527
|
+
)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def review_mutation(number, head, comment, idempotency)
|
|
531
|
+
Ace::Git::ProviderReviewMutation.new(
|
|
532
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
533
|
+
head_sha: head, comment: comment, idempotency: idempotency
|
|
534
|
+
)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Validated selected repository identity. Built once; malformed
|
|
538
|
+
# selections fail as configuration errors before any subprocess.
|
|
539
|
+
def repository_target
|
|
540
|
+
@repository_target ||= RepositoryBinding::Target.resolve(server.url)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Refuse before any repository subprocess when the installed fj was
|
|
544
|
+
# never observed; a different version must not inherit capabilities.
|
|
545
|
+
def ensure_observed_version!
|
|
546
|
+
return if @version_checked
|
|
547
|
+
|
|
548
|
+
CliExecutor.check_version_supported!(runner: runner)
|
|
549
|
+
@version_checked = true
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Run one repository-scoped `fj` command bound to the selected
|
|
553
|
+
# target, classifying every failure with the taxonomy.
|
|
554
|
+
def fj(operation, *arguments)
|
|
555
|
+
result = execute_repository(operation, arguments)
|
|
228
556
|
return result[:stdout] if result[:success]
|
|
229
557
|
|
|
230
|
-
classify_failure(result[:stderr], context:
|
|
558
|
+
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
231
559
|
end
|
|
232
560
|
|
|
233
|
-
# Send one mutating
|
|
234
|
-
# any transport-level failure becomes an unknown outcome:
|
|
235
|
-
# may have mutated the forge, so callers must reconcile
|
|
236
|
-
# identity instead of retrying blindly.
|
|
237
|
-
def send_mutation(
|
|
238
|
-
result =
|
|
561
|
+
# Send one mutating repository operation. When `ambiguous` is true
|
|
562
|
+
# (create), any transport-level failure becomes an unknown outcome:
|
|
563
|
+
# the request may have mutated the forge, so callers must reconcile
|
|
564
|
+
# by exact identity instead of retrying blindly.
|
|
565
|
+
def send_mutation(operation, *arguments, ambiguous:, identity: nil)
|
|
566
|
+
result = execute_repository(operation, arguments)
|
|
239
567
|
return result if result[:success]
|
|
240
568
|
|
|
241
|
-
classify_failure(result[:stderr], context:
|
|
569
|
+
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
242
570
|
rescue Ace::Git::ProviderUnreachableError => e
|
|
243
571
|
raise e unless ambiguous
|
|
244
572
|
|
|
@@ -247,12 +575,36 @@ module Ace
|
|
|
247
575
|
"reconcile by exact identity before repeating: #{identity}"
|
|
248
576
|
end
|
|
249
577
|
|
|
578
|
+
def execute_repository(operation, arguments)
|
|
579
|
+
target = repository_target
|
|
580
|
+
ensure_observed_version!
|
|
581
|
+
ensure_not_redirecting!(target)
|
|
582
|
+
CliExecutor.execute_repository(
|
|
583
|
+
target: target, operation: operation,
|
|
584
|
+
arguments: arguments, timeout: timeout, runner: runner
|
|
585
|
+
)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Refuse before any subprocess when the readable fj configuration
|
|
589
|
+
# aliases the selected host to a different endpoint: `-H` cannot
|
|
590
|
+
# prevent that redirect, and returned-identity checks cannot see it.
|
|
591
|
+
def ensure_not_redirecting!(target)
|
|
592
|
+
return if @redirect_checked
|
|
593
|
+
|
|
594
|
+
RepositoryBinding.reject_selected_host_redirect!(target.host_url)
|
|
595
|
+
@redirect_checked = true
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def operation_context(operation, arguments)
|
|
599
|
+
"#{operation} #{arguments.compact.join(" ")}".strip
|
|
600
|
+
end
|
|
601
|
+
|
|
250
602
|
def classify_failure(message, context:)
|
|
251
603
|
message = message.to_s
|
|
252
604
|
case message
|
|
253
605
|
when /not found|does not exist|no pull request|no issue/i
|
|
254
606
|
raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{context}): #{message}"
|
|
255
|
-
when /access denied|unauthorized|token/i
|
|
607
|
+
when /access denied|unauthorized|token|only signed in|not logged in|log ?in required/i
|
|
256
608
|
raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
|
|
257
609
|
else
|
|
258
610
|
raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{context}): #{message}"
|
|
@@ -270,12 +622,17 @@ module Ace
|
|
|
270
622
|
normalize_pr(parsed)
|
|
271
623
|
end
|
|
272
624
|
|
|
273
|
-
# View one PR and parse its stable minimal-style output.
|
|
625
|
+
# View one PR and parse its stable minimal-style output. The
|
|
626
|
+
# qualified `owner/repo#N` reference binds the read to the selected
|
|
627
|
+
# repository; a returned number that disagrees with the request is a
|
|
628
|
+
# routed mismatch and fails closed.
|
|
274
629
|
def view_pr(number)
|
|
275
|
-
view = fj(
|
|
276
|
-
Parsers.parse_pr_view(view) ||
|
|
630
|
+
view = fj(:pr_view, number)
|
|
631
|
+
parsed = Parsers.parse_pr_view(view) ||
|
|
277
632
|
raise(Ace::Git::ProviderMalformedOutputError,
|
|
278
633
|
"Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
|
|
634
|
+
verify_returned_number!(parsed, number, "pull request")
|
|
635
|
+
parsed
|
|
279
636
|
end
|
|
280
637
|
|
|
281
638
|
# Re-fetch and verify the PR created by an accepted create request.
|
|
@@ -319,7 +676,7 @@ module Ace
|
|
|
319
676
|
end
|
|
320
677
|
|
|
321
678
|
def head_sha_of(number)
|
|
322
|
-
commits = fj(
|
|
679
|
+
commits = fj(:pr_commits, number)
|
|
323
680
|
Parsers.parse_head_sha(commits)
|
|
324
681
|
end
|
|
325
682
|
|
|
@@ -328,6 +685,7 @@ module Ace
|
|
|
328
685
|
server_name: server.name,
|
|
329
686
|
number: parsed[:number],
|
|
330
687
|
title: parsed[:title],
|
|
688
|
+
body: parsed[:body],
|
|
331
689
|
state: parsed[:state],
|
|
332
690
|
head_ref: parsed[:head_ref],
|
|
333
691
|
base_ref: parsed[:base_ref],
|
|
@@ -337,7 +695,10 @@ module Ace
|
|
|
337
695
|
draft: nil,
|
|
338
696
|
merged_at: nil,
|
|
339
697
|
head_repository_url: head_repository_url_of(parsed),
|
|
340
|
-
base_repository_url:
|
|
698
|
+
base_repository_url: repository_target.url,
|
|
699
|
+
# fj v0.6.0 exposes no authoritative merge-commit field for a
|
|
700
|
+
# selected PR (observed; see the task capability evidence), so
|
|
701
|
+
# merge evidence stays empty and cleanup retains the checkout.
|
|
341
702
|
merge_commit_sha: nil
|
|
342
703
|
)
|
|
343
704
|
end
|
|
@@ -347,14 +708,14 @@ module Ace
|
|
|
347
708
|
# base repository; canonical PRs use the configured repository.
|
|
348
709
|
def head_repository_url_of(parsed)
|
|
349
710
|
repo = parsed[:head_repository]
|
|
350
|
-
return
|
|
711
|
+
return repository_target.url if repo.nil? || repo.empty?
|
|
351
712
|
|
|
352
713
|
"#{server_host_root}/#{repo}"
|
|
353
714
|
end
|
|
354
715
|
|
|
355
716
|
def server_host_root
|
|
356
717
|
@server_host_root ||= begin
|
|
357
|
-
uri = URI.parse(
|
|
718
|
+
uri = URI.parse(repository_target.url)
|
|
358
719
|
"#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
|
|
359
720
|
end
|
|
360
721
|
end
|
|
@@ -364,6 +725,7 @@ module Ace
|
|
|
364
725
|
server_name: server.name,
|
|
365
726
|
number: entry[:number],
|
|
366
727
|
title: entry[:title],
|
|
728
|
+
body: nil,
|
|
367
729
|
state: nil,
|
|
368
730
|
head_ref: nil,
|
|
369
731
|
base_ref: nil,
|
|
@@ -373,27 +735,60 @@ module Ace
|
|
|
373
735
|
draft: nil,
|
|
374
736
|
merged_at: nil,
|
|
375
737
|
head_repository_url: nil,
|
|
376
|
-
base_repository_url:
|
|
738
|
+
base_repository_url: repository_target.url,
|
|
377
739
|
merge_commit_sha: nil
|
|
378
740
|
)
|
|
379
741
|
end
|
|
380
742
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
743
|
+
# Reject non-numeric or non-positive PR/issue numbers as
|
|
744
|
+
# configuration failures before any subprocess.
|
|
745
|
+
def request_number!(number)
|
|
746
|
+
text = number.to_s
|
|
747
|
+
unless /\A\d+\z/.match?(text) && text.to_i.positive?
|
|
748
|
+
raise Ace::Git::ConfigError,
|
|
749
|
+
"Pull request and issue numbers must be positive integers, got #{number.inspect}"
|
|
387
750
|
end
|
|
751
|
+
|
|
752
|
+
text.to_i
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# PR and issue numbers are not globally unique: evidence whose
|
|
756
|
+
# number disagrees with the selected reference was not routed to the
|
|
757
|
+
# selected repository and must never be relabelled.
|
|
758
|
+
def verify_returned_number!(parsed, requested, kind)
|
|
759
|
+
return if parsed[:number] == requested.to_i
|
|
760
|
+
|
|
761
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
762
|
+
"Forgejo returned #{kind} ##{parsed[:number]} for selected #{kind} " \
|
|
763
|
+
"##{requested} on #{repository_target.repo}; refusing misrouted evidence"
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# `fj repo view` was bound to the selected owner/repository, so
|
|
767
|
+
# returned identity fields must agree with the selection.
|
|
768
|
+
def verify_returned_repository!(parsed, target)
|
|
769
|
+
full_name = parsed[:full_name].to_s.sub(%r{\.git\z}i, "")
|
|
770
|
+
unless !full_name.empty? && full_name.casecmp(target.repo).zero?
|
|
771
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
772
|
+
"Forgejo repo view returned #{full_name.inspect} for selected repository " \
|
|
773
|
+
"#{target.repo}; refusing misrouted evidence"
|
|
774
|
+
end
|
|
775
|
+
return if parsed[:url].nil? || parsed[:url].empty? ||
|
|
776
|
+
Ace::Git::Atoms::ServerUrl.match?(parsed[:url], target.url)
|
|
777
|
+
|
|
778
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
779
|
+
"Forgejo repo view returned URL #{parsed[:url].inspect} for selected repository " \
|
|
780
|
+
"#{target.url}; refusing misrouted evidence"
|
|
388
781
|
end
|
|
389
782
|
|
|
390
783
|
def pr_url(number)
|
|
391
|
-
|
|
784
|
+
# A configured URL may end in .git (clone form); the web URL
|
|
785
|
+
# must be built from the repository page base instead.
|
|
786
|
+
base = repository_target.url.to_s.chomp("/").sub(/\.git\z/i, "")
|
|
392
787
|
"#{base}/pulls/#{number}"
|
|
393
788
|
end
|
|
394
789
|
|
|
395
790
|
def issue_url(number)
|
|
396
|
-
base =
|
|
791
|
+
base = repository_target.url.to_s.chomp("/")
|
|
397
792
|
"#{base}/issues/#{number}"
|
|
398
793
|
end
|
|
399
794
|
end
|