ace-git-forgejo 0.4.0 → 0.6.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 +21 -14
- data/README.md +52 -10
- data/lib/ace/git/forgejo/http_client.rb +84 -20
- data/lib/ace/git/forgejo/issue_api.rb +171 -0
- data/lib/ace/git/forgejo/parsers.rb +20 -3
- data/lib/ace/git/forgejo/provider.rb +541 -147
- data/lib/ace/git/forgejo/pull_request_api.rb +195 -0
- data/lib/ace/git/forgejo/repository_binding.rb +4 -13
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +32 -2
|
@@ -2,24 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
|
+
require "ace/git/atoms/server_url"
|
|
5
6
|
require_relative "cli_executor"
|
|
7
|
+
require_relative "repository_binding"
|
|
6
8
|
require_relative "parsers"
|
|
9
|
+
require_relative "issue_api"
|
|
10
|
+
require_relative "pull_request_api"
|
|
7
11
|
|
|
8
12
|
module Ace
|
|
9
13
|
module Git
|
|
10
14
|
module Forgejo
|
|
11
15
|
# Forgejo provider implementation of the shared provider contract.
|
|
12
16
|
#
|
|
13
|
-
# Owns Forgejo terminology and translates `fj`
|
|
14
|
-
# normalized evidence types defined by the ace-git
|
|
15
|
-
# repository-scoped call is bound to the selected server's
|
|
16
|
-
# host/owner/repository through the {CliExecutor}
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
17
|
+
# Owns Forgejo terminology and translates `fj` and Forgejo API v1
|
|
18
|
+
# responses into the normalized evidence types defined by the ace-git
|
|
19
|
+
# core. Every repository-scoped call is bound to the selected server's
|
|
20
|
+
# exact host/owner/repository: `fj` commands through the {CliExecutor}
|
|
21
|
+
# repository boundary, API calls through the repository-bound
|
|
22
|
+
# {HttpClient}/{PullRequestApi} routes. The selected identity is
|
|
23
|
+
# mandatory, unobserved operations refuse before launch, and returned
|
|
24
|
+
# evidence is checked for conflicting identity. Every failure is
|
|
25
|
+
# classified with the shared taxonomy; provider failures are never
|
|
26
|
+
# hidden behind ad-hoc curl calls or silent fallbacks.
|
|
22
27
|
class Provider < Ace::Git::Providers::Base
|
|
28
|
+
# Fill color for repo labels created on demand by issue linking.
|
|
29
|
+
TRACKED_LABEL_COLOR = "0e8a16"
|
|
30
|
+
|
|
31
|
+
# Forgejo's default work-in-progress title prefixes; the server
|
|
32
|
+
# derives its API `draft` field from them (case-insensitive).
|
|
33
|
+
WIP_PREFIXES = ["WIP:", "[WIP]"].freeze
|
|
34
|
+
|
|
23
35
|
class << self
|
|
24
36
|
def display_name
|
|
25
37
|
"Forgejo"
|
|
@@ -47,12 +59,18 @@ module Ace
|
|
|
47
59
|
CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
|
|
48
60
|
end
|
|
49
61
|
|
|
62
|
+
# Authoritative pull request read. One API payload binds the exact
|
|
63
|
+
# head repository/ref/SHA, draft state, and merge evidence that the
|
|
64
|
+
# fj surface cannot report (no draft field, head SHA only via a
|
|
65
|
+
# second command); contradictory or missing identity fails closed.
|
|
66
|
+
#
|
|
50
67
|
# @return [ProviderPullRequest] normalized pull request evidence
|
|
51
|
-
# @raise [ProviderObjectNotFoundError] when the PR does not exist
|
|
52
|
-
# @raise [ProviderMalformedOutputError] when
|
|
53
|
-
# @raise [ProviderUnreachableError] when the endpoint is unreachable
|
|
68
|
+
# @raise [Ace::Git::ProviderObjectNotFoundError] when the PR does not exist
|
|
69
|
+
# @raise [Ace::Git::ProviderMalformedOutputError] when the payload is unexpected
|
|
70
|
+
# @raise [Ace::Git::ProviderUnreachableError] when the endpoint is unreachable
|
|
54
71
|
def pull_request(number:)
|
|
55
|
-
|
|
72
|
+
number = request_number!(number)
|
|
73
|
+
normalize_api_pr(pr_api.pull_request(number))
|
|
56
74
|
end
|
|
57
75
|
|
|
58
76
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
@@ -109,13 +127,94 @@ module Ace
|
|
|
109
127
|
)
|
|
110
128
|
end
|
|
111
129
|
|
|
130
|
+
def issue_tracking(number:)
|
|
131
|
+
data = issue_api.issue(request_number!(number))
|
|
132
|
+
unless data.is_a?(Hash) && data["number"].to_i == number.to_i &&
|
|
133
|
+
data["html_url"].to_s == issue_url(number.to_i)
|
|
134
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Issue ##{number} is not in #{server.url}"
|
|
135
|
+
end
|
|
136
|
+
evidence = Ace::Git::ProviderIssue.new(
|
|
137
|
+
server_name: server.name, number: data["number"], title: data["title"],
|
|
138
|
+
state: normalize_state(data["state"]),
|
|
139
|
+
author: data.dig("user", "login"), url: data["html_url"],
|
|
140
|
+
labels: Array(data["labels"]).map { |label| label["name"] }
|
|
141
|
+
)
|
|
142
|
+
{issue: evidence,
|
|
143
|
+
comments: Array(issue_api.comments(number)).map { |c| {id: c.fetch("id"), body: c["body"].to_s} },
|
|
144
|
+
labels: evidence.labels}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Unrecognized or missing state is malformed evidence: mapping it to
|
|
148
|
+
# :closed would let sync close an issue without valid state proof.
|
|
149
|
+
def normalize_state(value)
|
|
150
|
+
case value.to_s
|
|
151
|
+
when "open" then :open
|
|
152
|
+
when "closed" then :closed
|
|
153
|
+
else raise Ace::Git::ProviderMalformedOutputError, "Unknown issue state #{value.inspect}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def create_issue_comment(number:, body:)
|
|
158
|
+
issue_api.create_comment(request_number!(number), body)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def update_issue_comment(number:, comment_id:, body:)
|
|
162
|
+
assert_issue_comment_owned!(number, comment_id)
|
|
163
|
+
issue_api.update_comment(Integer(comment_id), body)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def delete_issue_comment(number:, comment_id:)
|
|
167
|
+
assert_issue_comment_owned!(number, comment_id)
|
|
168
|
+
issue_api.delete_comment(Integer(comment_id))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def add_issue_label(number:, label:)
|
|
172
|
+
issue_api.add_label(request_number!(number), ensure_repo_label_id(label))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def remove_issue_label(number:, label:)
|
|
176
|
+
match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
|
|
177
|
+
return unless match
|
|
178
|
+
|
|
179
|
+
issue_api.remove_label(request_number!(number), match.fetch("id"))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Fresh repositories lack the tracking label; creating it on demand
|
|
183
|
+
# keeps issue linking from committing a tracking comment it could
|
|
184
|
+
# never label. A concurrent creator surfaces as an uncertain mutation
|
|
185
|
+
# outcome, which only means re-list and attach what appeared.
|
|
186
|
+
def ensure_repo_label_id(label)
|
|
187
|
+
match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
|
|
188
|
+
return match["id"] if match && match["id"].to_i.positive?
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
issue_api.create_repo_label(name: label, color: TRACKED_LABEL_COLOR)
|
|
192
|
+
rescue Ace::Git::ProviderUnknownOutcomeError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
|
|
196
|
+
unless match && match["id"].to_i.positive?
|
|
197
|
+
raise Ace::Git::ProviderObjectNotFoundError,
|
|
198
|
+
"Forgejo label #{label.inspect} could not be created or found on #{server.url}"
|
|
199
|
+
end
|
|
200
|
+
match["id"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def set_issue_state(number:, state:)
|
|
204
|
+
raise ArgumentError, "Invalid issue state #{state.inspect}" unless %i[open closed].include?(state)
|
|
205
|
+
|
|
206
|
+
issue_api.set_state(request_number!(number), state)
|
|
207
|
+
end
|
|
208
|
+
|
|
112
209
|
# @return [Array<ProviderCheck>] normalized check evidence for a ref
|
|
113
210
|
def checks(ref:)
|
|
114
211
|
tasks = fj(:actions_tasks)
|
|
115
212
|
Parsers.parse_actions_tasks(tasks)
|
|
116
|
-
# Exact-head evidence
|
|
117
|
-
#
|
|
118
|
-
|
|
213
|
+
# Exact-head evidence binds each task to the reviewed head by SHA
|
|
214
|
+
# prefix: a full or abbreviated SHA that prefixes the reviewed
|
|
215
|
+
# head identifies that commit, and anything else is another
|
|
216
|
+
# head's task. No matching evidence is silently dropped.
|
|
217
|
+
.select { |task| ref.to_s.downcase.start_with?(task[:sha].downcase) }
|
|
119
218
|
.map do |task|
|
|
120
219
|
Ace::Git::ProviderCheck.new(
|
|
121
220
|
server_name: server.name,
|
|
@@ -125,6 +224,13 @@ module Ace
|
|
|
125
224
|
url: nil
|
|
126
225
|
)
|
|
127
226
|
end
|
|
227
|
+
rescue Ace::Git::ProviderObjectNotFoundError
|
|
228
|
+
# A repository with Actions disabled serves no tasks endpoint. The
|
|
229
|
+
# review flows resolve the repository binding and the PR before
|
|
230
|
+
# collecting checks, so a not-found here means CI is unavailable,
|
|
231
|
+
# not a missing repository; empty check evidence keeps the review
|
|
232
|
+
# collectable. Parse and transport failures still fail closed.
|
|
233
|
+
[]
|
|
128
234
|
end
|
|
129
235
|
|
|
130
236
|
# @return [ProviderRepository] normalized repository evidence
|
|
@@ -160,7 +266,7 @@ module Ace
|
|
|
160
266
|
number = request_number!(number)
|
|
161
267
|
verify_expected_head!(pull_request(number: number), expected_head)
|
|
162
268
|
comments = review_http.paginate("issues/#{number}/comments").map do |entry|
|
|
163
|
-
review_comment(entry, number
|
|
269
|
+
review_comment(entry, number)
|
|
164
270
|
end
|
|
165
271
|
reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
|
|
166
272
|
review_entry(entry, number, expected_head)
|
|
@@ -174,7 +280,7 @@ module Ace
|
|
|
174
280
|
unless entries.is_a?(Array)
|
|
175
281
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
176
282
|
end
|
|
177
|
-
entries.map { |entry| review_comment(entry, number
|
|
283
|
+
entries.map { |entry| review_comment(entry, number) }
|
|
178
284
|
end
|
|
179
285
|
verify_expected_head!(pull_request(number: number), expected_head)
|
|
180
286
|
Ace::Git::ProviderReviewEvidence.new(
|
|
@@ -230,7 +336,7 @@ module Ace
|
|
|
230
336
|
require_open_pr!(pr)
|
|
231
337
|
marker = comment_marker(correlation)
|
|
232
338
|
with_session_lock(correlation) do
|
|
233
|
-
existing = matching_review_comments(number, marker
|
|
339
|
+
existing = matching_review_comments(number, marker)
|
|
234
340
|
if existing.length > 1
|
|
235
341
|
raise Ace::Git::ProviderConflictingMatchesError,
|
|
236
342
|
"Multiple comments match review session #{correlation} on PR ##{number}"
|
|
@@ -260,8 +366,8 @@ module Ace
|
|
|
260
366
|
"session #{correlation}: #{e.message}; reconcile before repeating"
|
|
261
367
|
end
|
|
262
368
|
begin
|
|
263
|
-
matches = matching_review_comments(number, marker
|
|
264
|
-
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
369
|
+
matches = matching_review_comments(number, marker)
|
|
370
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
265
371
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
266
372
|
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
267
373
|
end
|
|
@@ -293,7 +399,7 @@ module Ace
|
|
|
293
399
|
raise Ace::Git::ProviderIdentityMismatchError,
|
|
294
400
|
"Comment #{comment_id} does not belong to selected PR ##{number}"
|
|
295
401
|
end
|
|
296
|
-
comment = review_comment(matches.first, number
|
|
402
|
+
comment = review_comment(matches.first, number)
|
|
297
403
|
if comment.body == body
|
|
298
404
|
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
299
405
|
return review_mutation(number, expected_head, comment, :existing)
|
|
@@ -314,7 +420,7 @@ module Ace
|
|
|
314
420
|
# Fetch individually and validate the Forgejo issue identity.
|
|
315
421
|
begin
|
|
316
422
|
updated = review_http.request(:get, update_route)
|
|
317
|
-
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
423
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
318
424
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
319
425
|
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
320
426
|
end
|
|
@@ -331,7 +437,7 @@ module Ace
|
|
|
331
437
|
"Comment update sent but exact PR comment #{comment_id} could not be verified"
|
|
332
438
|
end
|
|
333
439
|
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
334
|
-
review_mutation(number, expected_head, review_comment(updated, number
|
|
440
|
+
review_mutation(number, expected_head, review_comment(updated, number), :updated)
|
|
335
441
|
end
|
|
336
442
|
|
|
337
443
|
def resolve_pull_request_thread(number:, expected_head:, thread_id:)
|
|
@@ -340,39 +446,38 @@ module Ace
|
|
|
340
446
|
end
|
|
341
447
|
|
|
342
448
|
# ---- PR lifecycle mutations ----
|
|
449
|
+
#
|
|
450
|
+
# Delivery-capable lifecycle on the documented Forgejo API v1
|
|
451
|
+
# (capability evidence in the task report): canonical and
|
|
452
|
+
# same-server fork heads via the "owner:branch" head form, draft
|
|
453
|
+
# state via the WIP title convention the server itself derives
|
|
454
|
+
# draft from, and merges guarded by the server-enforced
|
|
455
|
+
# `head_commit_id` precondition.
|
|
343
456
|
|
|
344
457
|
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
345
|
-
# base/head identity, with head SHA provenance
|
|
458
|
+
# base/head identity, with head SHA provenance from the API list
|
|
346
459
|
def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
347
460
|
require_base_on_server!(base_repository_url)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
next unless
|
|
353
|
-
next unless Ace::Git::Atoms::ServerUrl.match?(
|
|
354
|
-
|
|
355
|
-
|
|
461
|
+
# An absent head repository names the base repository itself,
|
|
462
|
+
# mirroring the lifecycle organism's default.
|
|
463
|
+
head_repository_url = server.url if head_repository_url.to_s.empty?
|
|
464
|
+
pr_api.open_pull_requests.filter_map do |payload|
|
|
465
|
+
next unless payload.dig("head", "ref") == head_ref && payload.dig("base", "ref") == base_ref
|
|
466
|
+
next unless Ace::Git::Atoms::ServerUrl.match?(api_head_repository_url(payload), head_repository_url)
|
|
467
|
+
|
|
468
|
+
normalize_api_pr(payload)
|
|
356
469
|
end
|
|
357
470
|
end
|
|
358
471
|
|
|
359
|
-
# Create
|
|
360
|
-
#
|
|
361
|
-
#
|
|
362
|
-
#
|
|
472
|
+
# Create (or reconcile to) a pull request for the exact identity.
|
|
473
|
+
# Same-server fork sources are encoded with the documented
|
|
474
|
+
# "owner:branch" API form; cross-host sources are refused before
|
|
475
|
+
# any request. The requested draft state is preserved exactly, and
|
|
476
|
+
# an existing exact match is reusable only when its draft state
|
|
477
|
+
# agrees; a duplicate-create race reconciles to the winner.
|
|
363
478
|
#
|
|
364
479
|
# @return [ProviderMutationReceipt] idempotency :created/:existing
|
|
365
480
|
def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
|
|
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.
|
|
369
|
-
unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
370
|
-
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
371
|
-
"Forgejo CLI (fj) pr create cannot target head repository " \
|
|
372
|
-
"#{head_repository_url} (resolved server repository: #{server.url}); " \
|
|
373
|
-
"run from the fork checkout or use the forge web UI"
|
|
374
|
-
end
|
|
375
|
-
|
|
376
481
|
matches = find_open_pull_requests(
|
|
377
482
|
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
378
483
|
base_repository_url: server.url, base_ref: base_ref
|
|
@@ -383,53 +488,363 @@ module Ace
|
|
|
383
488
|
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
384
489
|
end
|
|
385
490
|
if matches.size == 1
|
|
386
|
-
|
|
491
|
+
existing = verify_expected_head!(matches.first, expected_head)
|
|
492
|
+
ensure_draft_agreement!(existing, draft)
|
|
493
|
+
return receipt(:create, existing, :existing)
|
|
387
494
|
end
|
|
388
495
|
|
|
389
|
-
|
|
390
|
-
|
|
496
|
+
head_arg = api_head_argument!(head_repository_url, head_ref)
|
|
497
|
+
send_title = draft_title!(title, draft)
|
|
498
|
+
pr_api.ensure_version_supported!
|
|
499
|
+
outcome = send_lifecycle_mutation(identity_text(head_repository_url, head_ref, base_ref)) do
|
|
500
|
+
pr_api.create_pull_request(head: head_arg, base: base_ref, title: send_title, body: body)
|
|
501
|
+
end
|
|
391
502
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
503
|
+
if outcome.status == 409
|
|
504
|
+
# A concurrent create won the race: the exact match is
|
|
505
|
+
# authoritative, and repeating the send would never be ours.
|
|
506
|
+
return reconcile_raced_create(head_repository_url: head_repository_url, head_ref: head_ref,
|
|
507
|
+
base_ref: base_ref, expected_head: expected_head, draft: draft)
|
|
508
|
+
end
|
|
509
|
+
unless outcome.status == 201 && outcome.payload.is_a?(Hash) && outcome.payload["number"].is_a?(Integer)
|
|
510
|
+
classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
created = verify_expected_head!(pull_request(number: outcome.payload["number"]), expected_head)
|
|
514
|
+
ensure_draft_outcome!(created, draft)
|
|
396
515
|
receipt(:create, created, :created)
|
|
397
516
|
end
|
|
398
517
|
|
|
399
|
-
#
|
|
400
|
-
#
|
|
401
|
-
# fetched only after every field update succeeded.
|
|
518
|
+
# Edit title/body after exact-head verification through the same
|
|
519
|
+
# API mutation path as the rest of the lifecycle.
|
|
402
520
|
#
|
|
403
521
|
# @return [ProviderMutationReceipt] operation :update
|
|
404
522
|
def update_pull_request(number:, expected_head:, title: nil, body: nil)
|
|
405
523
|
number = request_number!(number)
|
|
406
|
-
verify_expected_head!(
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
524
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
525
|
+
if title || body
|
|
526
|
+
pr_api.ensure_version_supported!
|
|
527
|
+
outcome = send_lifecycle_mutation("PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
528
|
+
pr_api.edit_pull_request(number, title: title, body: body)
|
|
529
|
+
end
|
|
530
|
+
unless (200..299).cover?(outcome.status)
|
|
531
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
532
|
+
"PR update refused for ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
|
|
533
|
+
"reconcile before repeating"
|
|
534
|
+
end
|
|
535
|
+
verify_post_mutation_head!(number, expected_head, "update")
|
|
536
|
+
end
|
|
537
|
+
receipt(:update, pull_request(number: number), nil)
|
|
410
538
|
end
|
|
411
539
|
|
|
412
|
-
#
|
|
413
|
-
#
|
|
540
|
+
# Mark a draft ready on the exact head. Forgejo derives draft state
|
|
541
|
+
# from the WIP title prefix, so the transition is the documented
|
|
542
|
+
# title edit with the prefix removed; the resulting ready state and
|
|
543
|
+
# unchanged head are proven by an authoritative read-back.
|
|
544
|
+
#
|
|
545
|
+
# @return [ProviderMutationReceipt] operation :ready
|
|
414
546
|
def ready_pull_request(number:, expected_head:)
|
|
415
|
-
|
|
416
|
-
|
|
547
|
+
number = request_number!(number)
|
|
548
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
549
|
+
unless pr.state == :open
|
|
550
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
551
|
+
"Cannot mark PR ##{number} ready in #{pr.state} state"
|
|
552
|
+
end
|
|
553
|
+
# Already-ready at the same head is idempotent.
|
|
554
|
+
return receipt(:ready, pr, nil) unless pr.draft
|
|
555
|
+
|
|
556
|
+
# Refuse an impossible transition before any server traffic; the
|
|
557
|
+
# capability gate sits directly in front of the mutation.
|
|
558
|
+
stripped_title = strip_wip_prefix!(pr.title, number)
|
|
559
|
+
pr_api.ensure_version_supported!
|
|
560
|
+
outcome = send_lifecycle_mutation("ready transition for PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
561
|
+
pr_api.edit_pull_request(number, title: stripped_title)
|
|
562
|
+
end
|
|
563
|
+
unless (200..299).cover?(outcome.status)
|
|
564
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
565
|
+
"Ready transition refused for PR ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
|
|
566
|
+
"reconcile before repeating"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
after = verify_post_mutation_head!(number, expected_head, "ready")
|
|
570
|
+
unless after.draft == false
|
|
571
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
572
|
+
"Ready title applied but PR ##{number} still reports draft on #{server.name}; " \
|
|
573
|
+
"reconcile before repeating"
|
|
574
|
+
end
|
|
575
|
+
receipt(:ready, after, nil)
|
|
417
576
|
end
|
|
418
577
|
|
|
419
|
-
#
|
|
420
|
-
#
|
|
578
|
+
# Merge with the server-enforced atomic precondition: Forgejo
|
|
579
|
+
# re-resolves the head ref inside the merge and refuses with 409
|
|
580
|
+
# when it is not exactly `head_commit_id`. There is deliberately no
|
|
581
|
+
# pre-read fallback guard. Success (and a refused race that turns
|
|
582
|
+
# out already merged) is proven by merged state plus the merge
|
|
583
|
+
# commit on the selected PR at the expected source SHA.
|
|
584
|
+
#
|
|
585
|
+
# @return [ProviderMutationReceipt] operation :merge
|
|
421
586
|
def merge_pull_request(number:, expected_head:, method:)
|
|
422
587
|
unless %i[squash merge rebase].include?(method)
|
|
423
588
|
raise ArgumentError, "Invalid merge method #{method.inspect}"
|
|
424
589
|
end
|
|
590
|
+
number = request_number!(number)
|
|
591
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
592
|
+
case pr.state
|
|
593
|
+
when :open
|
|
594
|
+
nil # proceed to the guarded merge
|
|
595
|
+
when :merged
|
|
596
|
+
# Already merged at the expected source SHA: authoritative
|
|
597
|
+
# evidence is reusable without another mutation attempt.
|
|
598
|
+
prove_merged!(pr, expected_head)
|
|
599
|
+
return receipt(:merge, pr, nil)
|
|
600
|
+
else
|
|
601
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
602
|
+
"Cannot merge PR ##{number} in #{pr.state} state"
|
|
603
|
+
end
|
|
425
604
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
605
|
+
pr_api.ensure_version_supported!
|
|
606
|
+
outcome = send_lifecycle_mutation("merge of PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
607
|
+
pr_api.merge_pull_request(number, do_method: method.to_s, head_commit_id: expected_head)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
case outcome.status
|
|
611
|
+
when 200..299
|
|
612
|
+
after = verify_post_mutation_head!(number, expected_head, "merge")
|
|
613
|
+
prove_merged!(after, expected_head)
|
|
614
|
+
receipt(:merge, after, nil)
|
|
615
|
+
when 409
|
|
616
|
+
if outcome.message.to_s.match?(/head out of date/i)
|
|
617
|
+
raise Ace::Git::ProviderExpectedHeadConflictError,
|
|
618
|
+
"Forgejo refused the merge of PR ##{number}: the head moved past #{expected_head} " \
|
|
619
|
+
"before the server-side expected-head check (#{outcome.message})"
|
|
620
|
+
end
|
|
621
|
+
reconcile_refused_merge(number, expected_head, outcome)
|
|
622
|
+
when 405
|
|
623
|
+
if outcome.message.to_s.match?(/try again later/i)
|
|
624
|
+
# The server's mergeable state was still computing; the merge
|
|
625
|
+
# was not applied. The caller may retry the same command.
|
|
626
|
+
raise Ace::Git::ProviderUnreachableError,
|
|
627
|
+
"Forgejo mergeable state for PR ##{number} is still computing " \
|
|
628
|
+
"(HTTP 405: #{outcome.message}); the merge was not applied and the " \
|
|
629
|
+
"command may be repeated"
|
|
630
|
+
end
|
|
631
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
632
|
+
"Forgejo refused to merge PR ##{number} as #{method}: #{outcome.message || "merge style unavailable"}"
|
|
633
|
+
when 404
|
|
634
|
+
raise Ace::Git::ProviderObjectNotFoundError, "Forgejo pull request ##{number} not found"
|
|
635
|
+
when 422
|
|
636
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
637
|
+
"Forgejo rejected the merge request for PR ##{number} (#{outcome.message})"
|
|
638
|
+
end
|
|
429
639
|
end
|
|
430
640
|
|
|
431
641
|
private
|
|
432
642
|
|
|
643
|
+
# Send one lifecycle mutation: a transport-level failure after the
|
|
644
|
+
# send stays an unknown outcome and gains the exact reconciliation
|
|
645
|
+
# identity; the mutation is never retried inside the provider.
|
|
646
|
+
def send_lifecycle_mutation(identity)
|
|
647
|
+
yield
|
|
648
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
649
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
650
|
+
"#{e.message}; reconcile by exact identity before repeating: #{identity}"
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Server-derived draft state must agree with the request for an
|
|
654
|
+
# existing exact match to be reusable.
|
|
655
|
+
def ensure_draft_agreement!(existing, draft)
|
|
656
|
+
return if existing.draft == draft
|
|
657
|
+
|
|
658
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
659
|
+
"Open pull request ##{existing.number} matches the exact identity but reports " \
|
|
660
|
+
"draft #{existing.draft.inspect} while draft #{draft.inspect} was requested"
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# The read-back of an accepted create must prove the requested draft
|
|
664
|
+
# state; anything else is a contradictory outcome, never a receipt.
|
|
665
|
+
def ensure_draft_outcome!(created, draft)
|
|
666
|
+
return if created.draft == draft
|
|
667
|
+
|
|
668
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
669
|
+
"PR ##{created.number} was created but reports draft #{created.draft.inspect} " \
|
|
670
|
+
"while draft #{draft.inspect} was requested; reconcile before repeating"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Forgejo derives draft state from WIP title prefixes (server
|
|
674
|
+
# defaults, compared case-insensitively without trimming); the API
|
|
675
|
+
# create form has no draft field.
|
|
676
|
+
def draft_title!(title, draft)
|
|
677
|
+
if draft
|
|
678
|
+
work_in_progress_title?(title) ? title : "WIP: #{title}"
|
|
679
|
+
elsif work_in_progress_title?(title)
|
|
680
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
681
|
+
"Requested draft:false conflicts with the WIP-prefixed title #{title.inspect}; " \
|
|
682
|
+
"Forgejo would create the pull request as a draft"
|
|
683
|
+
else
|
|
684
|
+
title
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def work_in_progress_title?(title)
|
|
689
|
+
WIP_PREFIXES.any? { |prefix| title.to_s.upcase.start_with?(prefix) }
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Remove one leading WIP prefix for the ready transition. A draft
|
|
693
|
+
# whose title carries none of the known prefixes means the server
|
|
694
|
+
# uses configured prefixes this provider cannot strip: refusing
|
|
695
|
+
# before the mutation keeps the draft state truthful.
|
|
696
|
+
def strip_wip_prefix!(title, number)
|
|
697
|
+
prefix = WIP_PREFIXES.find { |candidate| title.to_s.upcase.start_with?(candidate) }
|
|
698
|
+
unless prefix
|
|
699
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
700
|
+
"PR ##{number} reports draft but its title carries no known WIP prefix " \
|
|
701
|
+
"(#{WIP_PREFIXES.join(", ")}); the server likely uses configured prefixes " \
|
|
702
|
+
"this provider cannot strip safely"
|
|
703
|
+
end
|
|
704
|
+
title.to_s.slice(prefix.length..).to_s.sub(/\A +/, "")
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
# Same-server fork heads ride the documented "owner:branch" API
|
|
708
|
+
# form. The declared source must resolve to the selected host with
|
|
709
|
+
# an owner path; cross-host sources are refused before any
|
|
710
|
+
# mutation (the exact-match lookup may read first).
|
|
711
|
+
def api_head_argument!(head_repository_url, head_ref)
|
|
712
|
+
return head_ref if head_repository_url.nil? ||
|
|
713
|
+
Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
714
|
+
unless same_server_authority?(head_repository_url)
|
|
715
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
716
|
+
"PR create cannot target head repository #{head_repository_url} outside the " \
|
|
717
|
+
"selected server #{server.url}; only canonical and same-server fork sources are supported"
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
owner = URI.parse(head_repository_url.to_s).path.to_s.split("/").reject(&:empty?).first
|
|
721
|
+
unless owner
|
|
722
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
723
|
+
"Head repository #{head_repository_url.inspect} has no owner path; " \
|
|
724
|
+
"cannot encode the fork head for #{server.url}"
|
|
725
|
+
end
|
|
726
|
+
"#{owner}:#{head_ref}"
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def same_server_authority?(url)
|
|
730
|
+
authority = Ace::Git::Atoms::ServerUrl.normalize(url).split("/", 2).first.to_s
|
|
731
|
+
!authority.empty? && authority == selected_server_authority
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def selected_server_authority
|
|
735
|
+
@selected_server_authority ||= Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first.to_s
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Head repository URL provenance from an API payload: canonical
|
|
739
|
+
# pulls carry the selected repository; fork pulls carry their own;
|
|
740
|
+
# a deleted fork repository reports none, and that absence stays
|
|
741
|
+
# honest instead of being relabelled.
|
|
742
|
+
def api_head_repository_url(payload)
|
|
743
|
+
full_name = payload.dig("head", "repo", "full_name")
|
|
744
|
+
return nil if !full_name.is_a?(String) || full_name.empty?
|
|
745
|
+
|
|
746
|
+
"#{server_host_root}/#{full_name}"
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Authoritative API evidence for one pull request. The base
|
|
750
|
+
# repository must be the selected one: a misrouted payload is never
|
|
751
|
+
# relabelled.
|
|
752
|
+
def normalize_api_pr(payload)
|
|
753
|
+
base_full_name = payload.dig("base", "repo", "full_name").to_s
|
|
754
|
+
unless !base_full_name.empty? && base_full_name.casecmp(repository_target.repo).zero?
|
|
755
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
756
|
+
"Forgejo returned pull request ##{payload["number"]} with base repository " \
|
|
757
|
+
"#{base_full_name.inspect} for selected #{repository_target.repo}; refusing misrouted evidence"
|
|
758
|
+
end
|
|
759
|
+
state = if payload["state"] == "open"
|
|
760
|
+
:open
|
|
761
|
+
elsif payload["merged"] == true
|
|
762
|
+
:merged
|
|
763
|
+
else
|
|
764
|
+
:closed
|
|
765
|
+
end
|
|
766
|
+
merge_commit = payload["merge_commit_sha"]
|
|
767
|
+
Ace::Git::ProviderPullRequest.new(
|
|
768
|
+
server_name: server.name,
|
|
769
|
+
number: payload["number"],
|
|
770
|
+
title: payload["title"],
|
|
771
|
+
body: payload["body"].is_a?(String) ? payload["body"] : nil,
|
|
772
|
+
state: state,
|
|
773
|
+
head_ref: payload.dig("head", "ref"),
|
|
774
|
+
base_ref: payload.dig("base", "ref"),
|
|
775
|
+
head_sha: payload.dig("head", "sha"),
|
|
776
|
+
author: payload.dig("user", "login"),
|
|
777
|
+
url: pr_url(payload["number"]),
|
|
778
|
+
draft: payload["draft"],
|
|
779
|
+
merged_at: payload["merged_at"].is_a?(String) ? payload["merged_at"] : nil,
|
|
780
|
+
head_repository_url: api_head_repository_url(payload),
|
|
781
|
+
base_repository_url: repository_target.url,
|
|
782
|
+
merge_commit_sha: merge_commit.is_a?(String) && !merge_commit.empty? ? merge_commit : nil
|
|
783
|
+
)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# A merge receipt requires authoritative merged evidence: the merged
|
|
787
|
+
# state, the merge commit, and the exact source SHA that was merged.
|
|
788
|
+
def prove_merged!(pull_request, expected_head)
|
|
789
|
+
return if pull_request.state == :merged && pull_request.head_sha == expected_head &&
|
|
790
|
+
pull_request.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
791
|
+
|
|
792
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
793
|
+
"Merge accepted but PR ##{pull_request.number} does not prove merged state at " \
|
|
794
|
+
"#{expected_head} with a merge commit on #{server.name}; reconcile before repeating"
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# A merge refusal that is not the stale-head case: reconcile by read
|
|
798
|
+
# before deciding — an authoritative already-merged result at the
|
|
799
|
+
# expected source SHA is reusable; a closed-unmerged pull request is
|
|
800
|
+
# a classified refusal; everything else stays an unknown outcome.
|
|
801
|
+
def reconcile_refused_merge(number, expected_head, outcome)
|
|
802
|
+
after = verify_post_mutation_head!(number, expected_head, "merge reconciliation")
|
|
803
|
+
if after.state == :merged && after.head_sha == expected_head &&
|
|
804
|
+
after.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
805
|
+
return receipt(:merge, after, nil)
|
|
806
|
+
end
|
|
807
|
+
if after.state == :closed
|
|
808
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
809
|
+
"PR ##{number} is closed without merge; Forgejo refused the merge " \
|
|
810
|
+
"(HTTP #{outcome.status}: #{outcome.message})"
|
|
811
|
+
end
|
|
812
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
813
|
+
"Merge outcome unknown for #{server.name}/##{number}, head #{expected_head}: " \
|
|
814
|
+
"server returned HTTP #{outcome.status} (#{outcome.message}) and the read-back does not " \
|
|
815
|
+
"prove a merge; reconcile before repeating"
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# A duplicate-create race: the exact match is the authoritative
|
|
819
|
+
# winner of the concurrent create.
|
|
820
|
+
def reconcile_raced_create(head_repository_url:, head_ref:, base_ref:, expected_head:, draft:)
|
|
821
|
+
matches = find_open_pull_requests(
|
|
822
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
823
|
+
base_repository_url: server.url, base_ref: base_ref
|
|
824
|
+
)
|
|
825
|
+
if matches.size == 1
|
|
826
|
+
existing = verify_expected_head!(matches.first, expected_head)
|
|
827
|
+
ensure_draft_agreement!(existing, draft)
|
|
828
|
+
return receipt(:create, existing, :existing)
|
|
829
|
+
end
|
|
830
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
831
|
+
"PR create refused as duplicate but #{matches.size} open pull requests match " \
|
|
832
|
+
"#{identity_text(head_repository_url, head_ref, base_ref)}; reconcile by exact identity before repeating"
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# A refused create (404/422) mutated nothing: classify the server's
|
|
836
|
+
# own explanation instead of racing a retry.
|
|
837
|
+
def classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
|
|
838
|
+
error = outcome.status == 404 ? Ace::Git::ProviderObjectNotFoundError : Ace::Git::ProviderIdentityMismatchError
|
|
839
|
+
raise error,
|
|
840
|
+
"Forgejo refused the create of #{identity_text(head_repository_url, head_ref, base_ref)} " \
|
|
841
|
+
"(HTTP #{outcome.status}: #{outcome.message})"
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def pr_api
|
|
845
|
+
@pr_api ||= PullRequestApi.new(server: server, timeout: timeout, runner: runner)
|
|
846
|
+
end
|
|
847
|
+
|
|
433
848
|
def require_open_pr!(pr)
|
|
434
849
|
return if pr.state == :open
|
|
435
850
|
|
|
@@ -441,24 +856,25 @@ module Ace
|
|
|
441
856
|
@review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
|
|
442
857
|
end
|
|
443
858
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
859
|
+
# A mutation receipt may only bind the expected head; if the PR head
|
|
860
|
+
# moved during the mutation the outcome stays uncertain. Returns the
|
|
861
|
+
# authoritative read-back when the head still matches.
|
|
862
|
+
def verify_post_mutation_head!(number, expected_head, correlation)
|
|
863
|
+
current = begin
|
|
864
|
+
pull_request(number: number)
|
|
865
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
866
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
867
|
+
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
868
|
+
end
|
|
869
|
+
return current if current.head_sha == expected_head
|
|
454
870
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
871
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
872
|
+
"PR head moved during mutation for #{server.name}/##{number} " \
|
|
873
|
+
"(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
|
|
874
|
+
end
|
|
459
875
|
|
|
460
876
|
# Serialize the read/post/reconcile sequence per session so two
|
|
461
|
-
|
|
877
|
+
# concurrent callers cannot both observe "no marker" and post.
|
|
462
878
|
def with_session_lock(correlation)
|
|
463
879
|
require "tmpdir"
|
|
464
880
|
lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
|
|
@@ -475,27 +891,31 @@ module Ace
|
|
|
475
891
|
"<!-- ace-review-session:#{correlation} -->"
|
|
476
892
|
end
|
|
477
893
|
|
|
478
|
-
def matching_review_comments(number, marker
|
|
894
|
+
def matching_review_comments(number, marker)
|
|
479
895
|
review_http.paginate("issues/#{number}/comments").filter_map do |entry|
|
|
480
|
-
|
|
896
|
+
unless entry.is_a?(Hash) && entry["body"].is_a?(String)
|
|
897
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment collection"
|
|
898
|
+
end
|
|
899
|
+
next unless entry["body"].include?(marker)
|
|
481
900
|
|
|
482
|
-
review_comment(entry, number
|
|
901
|
+
review_comment(entry, number)
|
|
483
902
|
end
|
|
484
903
|
end
|
|
485
904
|
|
|
486
|
-
def review_comment(entry, number
|
|
905
|
+
def review_comment(entry, number)
|
|
906
|
+
user = entry.is_a?(Hash) ? entry["user"] : nil
|
|
487
907
|
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
488
|
-
|
|
908
|
+
user.is_a?(Hash) && user["login"].is_a?(String)
|
|
489
909
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
|
|
490
910
|
end
|
|
491
911
|
Ace::Git::ProviderReviewComment.new(
|
|
492
912
|
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
493
|
-
id: entry["id"], author:
|
|
913
|
+
id: entry["id"], author: user["login"], body: entry["body"],
|
|
494
914
|
url: entry["html_url"], path: entry["path"],
|
|
495
915
|
# Forgejo supplies position/original_position rather than
|
|
496
916
|
# line; the positive side position is the normalized line.
|
|
497
917
|
line: entry["line"] || [entry["position"], entry["original_position"]].compact.reject { |v| !v.is_a?(Integer) || v <= 0 }.first,
|
|
498
|
-
head_sha: entry["commit_id"]
|
|
918
|
+
head_sha: entry["commit_id"], resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
|
|
499
919
|
thread_id: nil
|
|
500
920
|
)
|
|
501
921
|
end
|
|
@@ -512,18 +932,28 @@ module Ace
|
|
|
512
932
|
end
|
|
513
933
|
|
|
514
934
|
def review_entry(entry, number, head)
|
|
515
|
-
|
|
516
|
-
|
|
935
|
+
unless entry.is_a?(Hash)
|
|
936
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
|
|
937
|
+
end
|
|
938
|
+
user, team = entry["user"], entry["team"]
|
|
939
|
+
unless user.nil? || user.is_a?(Hash)
|
|
940
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: user must be an object"
|
|
941
|
+
end
|
|
942
|
+
unless team.nil? || team.is_a?(Hash)
|
|
943
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: team must be an object"
|
|
944
|
+
end
|
|
945
|
+
reviewer = user&.[]("login") || team&.[]("name")
|
|
946
|
+
unless entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
|
|
517
947
|
entry["state"].is_a?(String) && !entry["state"].empty?
|
|
518
948
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
|
|
519
949
|
end
|
|
520
950
|
Ace::Git::ProviderReview.new(
|
|
521
951
|
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
522
|
-
id: entry["id"], author:
|
|
952
|
+
id: entry["id"], author: reviewer, body: entry["body"],
|
|
523
953
|
# Forgejo reports REQUEST_CHANGES; consumers count only the
|
|
524
954
|
# canonical CHANGES_REQUESTED spelling.
|
|
525
955
|
state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
|
|
526
|
-
url: entry["html_url"], head_sha: entry["commit_id"]
|
|
956
|
+
url: entry["html_url"], head_sha: entry["commit_id"]
|
|
527
957
|
)
|
|
528
958
|
end
|
|
529
959
|
|
|
@@ -534,6 +964,17 @@ module Ace
|
|
|
534
964
|
)
|
|
535
965
|
end
|
|
536
966
|
|
|
967
|
+
def assert_issue_comment_owned!(number, comment_id)
|
|
968
|
+
return if issue_tracking(number: number)[:comments].any? { |comment| comment[:id].to_i == comment_id.to_i }
|
|
969
|
+
|
|
970
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
971
|
+
"Comment #{comment_id} is not on selected issue ##{number} in #{server.url}"
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def issue_api
|
|
975
|
+
@issue_api ||= IssueApi.new(server: server, timeout: timeout, runner: runner)
|
|
976
|
+
end
|
|
977
|
+
|
|
537
978
|
# Validated selected repository identity. Built once; malformed
|
|
538
979
|
# selections fail as configuration errors before any subprocess.
|
|
539
980
|
def repository_target
|
|
@@ -558,23 +999,7 @@ module Ace
|
|
|
558
999
|
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
559
1000
|
end
|
|
560
1001
|
|
|
561
|
-
#
|
|
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)
|
|
567
|
-
return result if result[:success]
|
|
568
|
-
|
|
569
|
-
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
570
|
-
rescue Ace::Git::ProviderUnreachableError => e
|
|
571
|
-
raise e unless ambiguous
|
|
572
|
-
|
|
573
|
-
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
574
|
-
"PR create outcome unknown after transport failure (#{e.message}); " \
|
|
575
|
-
"reconcile by exact identity before repeating: #{identity}"
|
|
576
|
-
end
|
|
577
|
-
|
|
1002
|
+
# Run one repository-scoped `fj` operation on the validated target.
|
|
578
1003
|
def execute_repository(operation, arguments)
|
|
579
1004
|
target = repository_target
|
|
580
1005
|
ensure_observed_version!
|
|
@@ -611,17 +1036,6 @@ module Ace
|
|
|
611
1036
|
end
|
|
612
1037
|
end
|
|
613
1038
|
|
|
614
|
-
# Fetch one PR as normalized evidence with exact head SHA.
|
|
615
|
-
def fetch_pr(number)
|
|
616
|
-
with_head_sha(view_pr(number))
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
# Complete a parsed PR view with its exact head SHA evidence.
|
|
620
|
-
def with_head_sha(parsed)
|
|
621
|
-
parsed[:head_sha] = head_sha_of(parsed[:number])
|
|
622
|
-
normalize_pr(parsed)
|
|
623
|
-
end
|
|
624
|
-
|
|
625
1039
|
# View one PR and parse its stable minimal-style output. The
|
|
626
1040
|
# qualified `owner/repo#N` reference binds the read to the selected
|
|
627
1041
|
# repository; a returned number that disagrees with the request is a
|
|
@@ -635,27 +1049,6 @@ module Ace
|
|
|
635
1049
|
parsed
|
|
636
1050
|
end
|
|
637
1051
|
|
|
638
|
-
# Re-fetch and verify the PR created by an accepted create request.
|
|
639
|
-
def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
|
|
640
|
-
matches = find_open_pull_requests(
|
|
641
|
-
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
642
|
-
base_repository_url: server.url, base_ref: base_ref
|
|
643
|
-
)
|
|
644
|
-
if matches.empty?
|
|
645
|
-
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
646
|
-
"PR create accepted but no open pull request matches " \
|
|
647
|
-
"#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
|
|
648
|
-
"reconcile by exact identity before repeating"
|
|
649
|
-
end
|
|
650
|
-
if matches.size > 1
|
|
651
|
-
raise Ace::Git::ProviderConflictingMatchesError,
|
|
652
|
-
"Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
|
|
653
|
-
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
verify_expected_head!(matches.first, expected_head)
|
|
657
|
-
end
|
|
658
|
-
|
|
659
1052
|
def require_base_on_server!(base_repository_url)
|
|
660
1053
|
return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
|
|
661
1054
|
|
|
@@ -788,8 +1181,9 @@ module Ace
|
|
|
788
1181
|
end
|
|
789
1182
|
|
|
790
1183
|
def issue_url(number)
|
|
791
|
-
|
|
792
|
-
|
|
1184
|
+
# Identity evidence comes from the web endpoint, not the clone URL
|
|
1185
|
+
# (which may be SSH-style and is not a parseable web URL).
|
|
1186
|
+
"#{Ace::Git::Atoms::ServerUrl.web_base(server.url)}/issues/#{number}"
|
|
793
1187
|
end
|
|
794
1188
|
end
|
|
795
1189
|
end
|