ace-git-forgejo 0.5.0 → 0.6.1
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 +19 -13
- data/README.md +54 -10
- data/lib/ace/git/forgejo/http_client.rb +86 -20
- data/lib/ace/git/forgejo/provider.rb +462 -133
- data/lib/ace/git/forgejo/pull_request_api.rb +229 -0
- data/lib/ace/git/forgejo/repository_binding.rb +0 -11
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +31 -2
|
@@ -2,28 +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"
|
|
7
9
|
require_relative "issue_api"
|
|
10
|
+
require_relative "pull_request_api"
|
|
8
11
|
|
|
9
12
|
module Ace
|
|
10
13
|
module Git
|
|
11
14
|
module Forgejo
|
|
12
15
|
# Forgejo provider implementation of the shared provider contract.
|
|
13
16
|
#
|
|
14
|
-
# Owns Forgejo terminology and translates `fj`
|
|
15
|
-
# normalized evidence types defined by the ace-git
|
|
16
|
-
# repository-scoped call is bound to the selected server's
|
|
17
|
-
# host/owner/repository through the {CliExecutor}
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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.
|
|
23
27
|
class Provider < Ace::Git::Providers::Base
|
|
24
28
|
# Fill color for repo labels created on demand by issue linking.
|
|
25
29
|
TRACKED_LABEL_COLOR = "0e8a16"
|
|
26
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
|
+
|
|
27
35
|
class << self
|
|
28
36
|
def display_name
|
|
29
37
|
"Forgejo"
|
|
@@ -51,12 +59,18 @@ module Ace
|
|
|
51
59
|
CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
|
|
52
60
|
end
|
|
53
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
|
+
#
|
|
54
67
|
# @return [ProviderPullRequest] normalized pull request evidence
|
|
55
|
-
# @raise [ProviderObjectNotFoundError] when the PR does not exist
|
|
56
|
-
# @raise [ProviderMalformedOutputError] when
|
|
57
|
-
# @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
|
|
58
71
|
def pull_request(number:)
|
|
59
|
-
|
|
72
|
+
number = request_number!(number)
|
|
73
|
+
normalize_api_pr(pr_api.pull_request(number))
|
|
60
74
|
end
|
|
61
75
|
|
|
62
76
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
@@ -252,7 +266,7 @@ module Ace
|
|
|
252
266
|
number = request_number!(number)
|
|
253
267
|
verify_expected_head!(pull_request(number: number), expected_head)
|
|
254
268
|
comments = review_http.paginate("issues/#{number}/comments").map do |entry|
|
|
255
|
-
review_comment(entry, number
|
|
269
|
+
review_comment(entry, number)
|
|
256
270
|
end
|
|
257
271
|
reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
|
|
258
272
|
review_entry(entry, number, expected_head)
|
|
@@ -266,7 +280,7 @@ module Ace
|
|
|
266
280
|
unless entries.is_a?(Array)
|
|
267
281
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
268
282
|
end
|
|
269
|
-
entries.map { |entry| review_comment(entry, number
|
|
283
|
+
entries.map { |entry| review_comment(entry, number) }
|
|
270
284
|
end
|
|
271
285
|
verify_expected_head!(pull_request(number: number), expected_head)
|
|
272
286
|
Ace::Git::ProviderReviewEvidence.new(
|
|
@@ -322,7 +336,7 @@ module Ace
|
|
|
322
336
|
require_open_pr!(pr)
|
|
323
337
|
marker = comment_marker(correlation)
|
|
324
338
|
with_session_lock(correlation) do
|
|
325
|
-
existing = matching_review_comments(number, marker
|
|
339
|
+
existing = matching_review_comments(number, marker)
|
|
326
340
|
if existing.length > 1
|
|
327
341
|
raise Ace::Git::ProviderConflictingMatchesError,
|
|
328
342
|
"Multiple comments match review session #{correlation} on PR ##{number}"
|
|
@@ -352,7 +366,7 @@ module Ace
|
|
|
352
366
|
"session #{correlation}: #{e.message}; reconcile before repeating"
|
|
353
367
|
end
|
|
354
368
|
begin
|
|
355
|
-
matches = matching_review_comments(number, marker
|
|
369
|
+
matches = matching_review_comments(number, marker)
|
|
356
370
|
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
357
371
|
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
358
372
|
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
@@ -385,7 +399,7 @@ module Ace
|
|
|
385
399
|
raise Ace::Git::ProviderIdentityMismatchError,
|
|
386
400
|
"Comment #{comment_id} does not belong to selected PR ##{number}"
|
|
387
401
|
end
|
|
388
|
-
comment = review_comment(matches.first, number
|
|
402
|
+
comment = review_comment(matches.first, number)
|
|
389
403
|
if comment.body == body
|
|
390
404
|
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
391
405
|
return review_mutation(number, expected_head, comment, :existing)
|
|
@@ -423,7 +437,7 @@ module Ace
|
|
|
423
437
|
"Comment update sent but exact PR comment #{comment_id} could not be verified"
|
|
424
438
|
end
|
|
425
439
|
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
426
|
-
review_mutation(number, expected_head, review_comment(updated, number
|
|
440
|
+
review_mutation(number, expected_head, review_comment(updated, number), :updated)
|
|
427
441
|
end
|
|
428
442
|
|
|
429
443
|
def resolve_pull_request_thread(number:, expected_head:, thread_id:)
|
|
@@ -432,39 +446,38 @@ module Ace
|
|
|
432
446
|
end
|
|
433
447
|
|
|
434
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.
|
|
435
456
|
|
|
436
457
|
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
437
|
-
# base/head identity, with head SHA provenance
|
|
458
|
+
# base/head identity, with head SHA provenance from the API list
|
|
438
459
|
def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
439
460
|
require_base_on_server!(base_repository_url)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
next unless
|
|
445
|
-
next unless Ace::Git::Atoms::ServerUrl.match?(
|
|
446
|
-
|
|
447
|
-
|
|
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)
|
|
448
469
|
end
|
|
449
470
|
end
|
|
450
471
|
|
|
451
|
-
# Create
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
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.
|
|
455
478
|
#
|
|
456
479
|
# @return [ProviderMutationReceipt] idempotency :created/:existing
|
|
457
480
|
def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
|
|
458
|
-
# `fj pr create -r` binds the base repository, but no observed form
|
|
459
|
-
# encodes a cross-repository fork head; refusing before any command
|
|
460
|
-
# (even lookups) beats creating the wrong PR.
|
|
461
|
-
unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
462
|
-
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
463
|
-
"Forgejo CLI (fj) pr create cannot target head repository " \
|
|
464
|
-
"#{head_repository_url} (resolved server repository: #{server.url}); " \
|
|
465
|
-
"run from the fork checkout or use the forge web UI"
|
|
466
|
-
end
|
|
467
|
-
|
|
468
481
|
matches = find_open_pull_requests(
|
|
469
482
|
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
470
483
|
base_repository_url: server.url, base_ref: base_ref
|
|
@@ -475,53 +488,416 @@ module Ace
|
|
|
475
488
|
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
476
489
|
end
|
|
477
490
|
if matches.size == 1
|
|
478
|
-
|
|
491
|
+
existing = verify_expected_head!(matches.first, expected_head)
|
|
492
|
+
ensure_draft_agreement!(existing, draft)
|
|
493
|
+
return receipt(:create, existing, :existing)
|
|
494
|
+
end
|
|
495
|
+
|
|
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
|
+
requested_head = head_repository_url.to_s.empty? ? server.url : head_repository_url
|
|
500
|
+
owner = Ace::Git::Atoms::ServerUrl.normalize(requested_head).split("/")[1]
|
|
501
|
+
base_identity, head_identity = pr_api.create_repository_identity(owner: owner)
|
|
502
|
+
unless Ace::Git::Atoms::ServerUrl.match?("#{server_host_root}/#{base_identity["full_name"]}", server.url) &&
|
|
503
|
+
Ace::Git::Atoms::ServerUrl.match?("#{server_host_root}/#{head_identity["full_name"]}", requested_head)
|
|
504
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
505
|
+
"Forgejo head selector does not select #{requested_head}; refusing create"
|
|
506
|
+
end
|
|
507
|
+
identity = "#{identity_text(requested_head, head_ref, base_ref)}, " \
|
|
508
|
+
"expected head #{expected_head}, draft #{draft.inspect}"
|
|
509
|
+
outcome = send_lifecycle_mutation(identity) do
|
|
510
|
+
pr_api.create_pull_request(head: head_arg, base: base_ref, title: send_title, body: body)
|
|
479
511
|
end
|
|
480
512
|
|
|
481
|
-
|
|
482
|
-
|
|
513
|
+
if outcome.status == 409
|
|
514
|
+
# A concurrent create won the race: the exact match is
|
|
515
|
+
# authoritative, and repeating the send would never be ours.
|
|
516
|
+
return reconcile_raced_create(head_repository_url: head_repository_url, head_ref: head_ref,
|
|
517
|
+
base_ref: base_ref, expected_head: expected_head, draft: draft)
|
|
518
|
+
end
|
|
519
|
+
unless (200..299).cover?(outcome.status)
|
|
520
|
+
classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
|
|
521
|
+
end
|
|
483
522
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
523
|
+
begin
|
|
524
|
+
payload = outcome.payload
|
|
525
|
+
unless outcome.status == 201 && payload.is_a?(Hash) &&
|
|
526
|
+
payload["number"].is_a?(Integer) && payload["number"].positive?
|
|
527
|
+
raise Ace::Git::ProviderMalformedOutputError, "Accepted create lacks pull request identity"
|
|
528
|
+
end
|
|
529
|
+
accepted = pr_api.creation_evidence(payload)
|
|
530
|
+
verify_created_identity!(accepted, requested_head, head_ref, base_ref, expected_head,
|
|
531
|
+
draft, base_identity, head_identity)
|
|
532
|
+
after = pr_api.pull_request(payload["number"])
|
|
533
|
+
created = verify_created_identity!(after, requested_head, head_ref, base_ref, expected_head,
|
|
534
|
+
draft, base_identity, head_identity)
|
|
535
|
+
receipt(:create, created, :created)
|
|
536
|
+
rescue Ace::Git::Error => e
|
|
537
|
+
known_number = outcome.payload.is_a?(Hash) && outcome.payload["number"]
|
|
538
|
+
known_number = "unknown" unless known_number.is_a?(Integer) && known_number.positive?
|
|
539
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
540
|
+
"Forgejo accepted create but verification failed (#{e.class.name.split("::").last}); " \
|
|
541
|
+
"reconcile by exact identity before repeating: #{identity_text(requested_head, head_ref, base_ref)}, " \
|
|
542
|
+
"expected head #{expected_head}, draft #{draft.inspect}, PR #{known_number}"
|
|
543
|
+
end
|
|
489
544
|
end
|
|
490
545
|
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
# fetched only after every field update succeeded.
|
|
546
|
+
# Edit title/body after exact-head verification through the same
|
|
547
|
+
# API mutation path as the rest of the lifecycle.
|
|
494
548
|
#
|
|
495
549
|
# @return [ProviderMutationReceipt] operation :update
|
|
496
550
|
def update_pull_request(number:, expected_head:, title: nil, body: nil)
|
|
497
551
|
number = request_number!(number)
|
|
498
|
-
verify_expected_head!(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
552
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
553
|
+
if title || body
|
|
554
|
+
pr_api.ensure_version_supported!
|
|
555
|
+
outcome = send_lifecycle_mutation("PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
556
|
+
pr_api.edit_pull_request(number, title: title, body: body)
|
|
557
|
+
end
|
|
558
|
+
unless (200..299).cover?(outcome.status)
|
|
559
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
560
|
+
"PR update refused for ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
|
|
561
|
+
"reconcile before repeating"
|
|
562
|
+
end
|
|
563
|
+
verify_post_mutation_head!(number, expected_head, "update")
|
|
564
|
+
end
|
|
565
|
+
receipt(:update, pull_request(number: number), nil)
|
|
502
566
|
end
|
|
503
567
|
|
|
504
|
-
#
|
|
505
|
-
#
|
|
568
|
+
# Mark a draft ready on the exact head. Forgejo derives draft state
|
|
569
|
+
# from the WIP title prefix, so the transition is the documented
|
|
570
|
+
# title edit with the prefix removed; the resulting ready state and
|
|
571
|
+
# unchanged head are proven by an authoritative read-back.
|
|
572
|
+
#
|
|
573
|
+
# @return [ProviderMutationReceipt] operation :ready
|
|
506
574
|
def ready_pull_request(number:, expected_head:)
|
|
507
|
-
|
|
508
|
-
|
|
575
|
+
number = request_number!(number)
|
|
576
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
577
|
+
unless pr.state == :open
|
|
578
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
579
|
+
"Cannot mark PR ##{number} ready in #{pr.state} state"
|
|
580
|
+
end
|
|
581
|
+
# Already-ready at the same head is idempotent.
|
|
582
|
+
return receipt(:ready, pr, nil) unless pr.draft
|
|
583
|
+
|
|
584
|
+
# Refuse an impossible transition before any server traffic; the
|
|
585
|
+
# capability gate sits directly in front of the mutation.
|
|
586
|
+
stripped_title = strip_wip_prefix!(pr.title, number)
|
|
587
|
+
pr_api.ensure_version_supported!
|
|
588
|
+
outcome = send_lifecycle_mutation("ready transition for PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
589
|
+
pr_api.edit_pull_request(number, title: stripped_title)
|
|
590
|
+
end
|
|
591
|
+
unless (200..299).cover?(outcome.status)
|
|
592
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
593
|
+
"Ready transition refused for PR ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
|
|
594
|
+
"reconcile before repeating"
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
after = verify_post_mutation_head!(number, expected_head, "ready")
|
|
598
|
+
unless after.draft == false
|
|
599
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
600
|
+
"Ready title applied but PR ##{number} still reports draft on #{server.name}; " \
|
|
601
|
+
"reconcile before repeating"
|
|
602
|
+
end
|
|
603
|
+
receipt(:ready, after, nil)
|
|
509
604
|
end
|
|
510
605
|
|
|
511
|
-
#
|
|
512
|
-
#
|
|
606
|
+
# Merge with the server-enforced atomic precondition: Forgejo
|
|
607
|
+
# re-resolves the head ref inside the merge and refuses with 409
|
|
608
|
+
# when it is not exactly `head_commit_id`. There is deliberately no
|
|
609
|
+
# pre-read fallback guard. Success (and a refused race that turns
|
|
610
|
+
# out already merged) is proven by merged state plus the merge
|
|
611
|
+
# commit on the selected PR at the expected source SHA.
|
|
612
|
+
#
|
|
613
|
+
# @return [ProviderMutationReceipt] operation :merge
|
|
513
614
|
def merge_pull_request(number:, expected_head:, method:)
|
|
514
615
|
unless %i[squash merge rebase].include?(method)
|
|
515
616
|
raise ArgumentError, "Invalid merge method #{method.inspect}"
|
|
516
617
|
end
|
|
618
|
+
number = request_number!(number)
|
|
619
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
620
|
+
case pr.state
|
|
621
|
+
when :open
|
|
622
|
+
nil # proceed to the guarded merge
|
|
623
|
+
when :merged
|
|
624
|
+
# Already merged at the expected source SHA: authoritative
|
|
625
|
+
# evidence is reusable without another mutation attempt.
|
|
626
|
+
prove_merged!(pr, expected_head)
|
|
627
|
+
return receipt(:merge, pr, nil)
|
|
628
|
+
else
|
|
629
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
630
|
+
"Cannot merge PR ##{number} in #{pr.state} state"
|
|
631
|
+
end
|
|
517
632
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
633
|
+
pr_api.ensure_version_supported!
|
|
634
|
+
outcome = send_lifecycle_mutation("merge of PR ##{number} on #{server.name}, head #{expected_head}") do
|
|
635
|
+
pr_api.merge_pull_request(number, do_method: method.to_s, head_commit_id: expected_head)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
case outcome.status
|
|
639
|
+
when 200..299
|
|
640
|
+
after = verify_post_mutation_head!(number, expected_head, "merge")
|
|
641
|
+
prove_merged!(after, expected_head)
|
|
642
|
+
receipt(:merge, after, nil)
|
|
643
|
+
when 409
|
|
644
|
+
if outcome.message.to_s.match?(/head out of date/i)
|
|
645
|
+
raise Ace::Git::ProviderExpectedHeadConflictError,
|
|
646
|
+
"Forgejo refused the merge of PR ##{number}: the head moved past #{expected_head} " \
|
|
647
|
+
"before the server-side expected-head check (#{outcome.message})"
|
|
648
|
+
end
|
|
649
|
+
reconcile_refused_merge(number, expected_head, outcome)
|
|
650
|
+
when 405
|
|
651
|
+
if outcome.message.to_s.match?(/try again later/i)
|
|
652
|
+
# The server's mergeable state was still computing; the merge
|
|
653
|
+
# was not applied. The caller may retry the same command.
|
|
654
|
+
raise Ace::Git::ProviderUnreachableError,
|
|
655
|
+
"Forgejo mergeable state for PR ##{number} is still computing " \
|
|
656
|
+
"(HTTP 405: #{outcome.message}); the merge was not applied and the " \
|
|
657
|
+
"command may be repeated"
|
|
658
|
+
end
|
|
659
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
660
|
+
"Forgejo refused to merge PR ##{number} as #{method}: #{outcome.message || "merge style unavailable"}"
|
|
661
|
+
when 404
|
|
662
|
+
raise Ace::Git::ProviderObjectNotFoundError, "Forgejo pull request ##{number} not found"
|
|
663
|
+
when 422
|
|
664
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
665
|
+
"Forgejo rejected the merge request for PR ##{number} (#{outcome.message})"
|
|
666
|
+
end
|
|
521
667
|
end
|
|
522
668
|
|
|
523
669
|
private
|
|
524
670
|
|
|
671
|
+
# Send one lifecycle mutation: a transport-level failure after the
|
|
672
|
+
# send stays an unknown outcome and gains the exact reconciliation
|
|
673
|
+
# identity; the mutation is never retried inside the provider.
|
|
674
|
+
def send_lifecycle_mutation(identity)
|
|
675
|
+
yield
|
|
676
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
677
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
678
|
+
"#{e.message}; reconcile by exact identity before repeating: #{identity}"
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
def verify_created_identity!(payload, head_url, head_ref, base_ref, expected_head,
|
|
682
|
+
draft, base_identity, head_identity)
|
|
683
|
+
created = verify_expected_head!(normalize_api_pr(payload), expected_head)
|
|
684
|
+
unless created.state == :open && created.head_ref == head_ref && created.base_ref == base_ref &&
|
|
685
|
+
Ace::Git::Atoms::ServerUrl.match?(created.head_repository_url, head_url) &&
|
|
686
|
+
payload.dig("head", "repo", "id") == head_identity["id"] &&
|
|
687
|
+
payload.dig("base", "repo", "id") == base_identity["id"]
|
|
688
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Created pull request provenance does not match request"
|
|
689
|
+
end
|
|
690
|
+
ensure_draft_outcome!(created, draft)
|
|
691
|
+
created
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Server-derived draft state must agree with the request for an
|
|
695
|
+
# existing exact match to be reusable.
|
|
696
|
+
def ensure_draft_agreement!(existing, draft)
|
|
697
|
+
return if existing.draft == draft
|
|
698
|
+
|
|
699
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
700
|
+
"Open pull request ##{existing.number} matches the exact identity but reports " \
|
|
701
|
+
"draft #{existing.draft.inspect} while draft #{draft.inspect} was requested"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# The read-back of an accepted create must prove the requested draft
|
|
705
|
+
# state; anything else is a contradictory outcome, never a receipt.
|
|
706
|
+
def ensure_draft_outcome!(created, draft)
|
|
707
|
+
return if created.draft == draft
|
|
708
|
+
|
|
709
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
710
|
+
"PR ##{created.number} was created but reports draft #{created.draft.inspect} " \
|
|
711
|
+
"while draft #{draft.inspect} was requested; reconcile before repeating"
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Forgejo derives draft state from WIP title prefixes (server
|
|
715
|
+
# defaults, compared case-insensitively without trimming); the API
|
|
716
|
+
# create form has no draft field.
|
|
717
|
+
def draft_title!(title, draft)
|
|
718
|
+
if draft
|
|
719
|
+
work_in_progress_title?(title) ? title : "WIP: #{title}"
|
|
720
|
+
elsif work_in_progress_title?(title)
|
|
721
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
722
|
+
"Requested draft:false conflicts with the WIP-prefixed title #{title.inspect}; " \
|
|
723
|
+
"Forgejo would create the pull request as a draft"
|
|
724
|
+
else
|
|
725
|
+
title
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def work_in_progress_title?(title)
|
|
730
|
+
WIP_PREFIXES.any? { |prefix| title.to_s.upcase.start_with?(prefix) }
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# Remove one leading WIP prefix for the ready transition. A draft
|
|
734
|
+
# whose title carries none of the known prefixes means the server
|
|
735
|
+
# uses configured prefixes this provider cannot strip: refusing
|
|
736
|
+
# before the mutation keeps the draft state truthful.
|
|
737
|
+
def strip_wip_prefix!(title, number)
|
|
738
|
+
prefix = WIP_PREFIXES.find { |candidate| title.to_s.upcase.start_with?(candidate) }
|
|
739
|
+
unless prefix
|
|
740
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
741
|
+
"PR ##{number} reports draft but its title carries no known WIP prefix " \
|
|
742
|
+
"(#{WIP_PREFIXES.join(", ")}); the server likely uses configured prefixes " \
|
|
743
|
+
"this provider cannot strip safely"
|
|
744
|
+
end
|
|
745
|
+
title.to_s.slice(prefix.length..).to_s.sub(/\A +/, "")
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
# Same-server fork heads ride the documented "owner:branch" API
|
|
749
|
+
# form. The declared source must resolve to the selected host with
|
|
750
|
+
# an owner path; cross-host sources are refused before any
|
|
751
|
+
# mutation (the exact-match lookup may read first).
|
|
752
|
+
def api_head_argument!(head_repository_url, head_ref)
|
|
753
|
+
if head_ref.to_s.empty? || head_ref.include?(":")
|
|
754
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Head ref cannot encode a repository selector"
|
|
755
|
+
end
|
|
756
|
+
return head_ref if head_repository_url.nil? ||
|
|
757
|
+
Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
758
|
+
unless same_server_authority?(head_repository_url)
|
|
759
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
760
|
+
"PR create cannot target head repository #{head_repository_url} outside the " \
|
|
761
|
+
"selected server #{server.url}; only canonical and same-server fork sources are supported"
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
segments = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/")[1..]
|
|
765
|
+
owner = segments.first if segments.length == 2 && segments.none?(&:empty?)
|
|
766
|
+
unless owner
|
|
767
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
768
|
+
"Head repository #{head_repository_url.inspect} has no owner path; " \
|
|
769
|
+
"cannot encode the fork head for #{server.url}"
|
|
770
|
+
end
|
|
771
|
+
"#{owner}:#{head_ref}"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def same_server_authority?(url)
|
|
775
|
+
authority = Ace::Git::Atoms::ServerUrl.normalize(url).split("/", 2).first.to_s
|
|
776
|
+
!authority.empty? && authority == selected_server_authority
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def selected_server_authority
|
|
780
|
+
@selected_server_authority ||= Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first.to_s
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# Head repository URL provenance from an API payload: canonical
|
|
784
|
+
# pulls carry the selected repository; fork pulls carry their own;
|
|
785
|
+
# a deleted fork repository reports none, and that absence stays
|
|
786
|
+
# honest instead of being relabelled.
|
|
787
|
+
def api_head_repository_url(payload)
|
|
788
|
+
repo = payload.dig("head", "repo")
|
|
789
|
+
unless repo.nil? || repo.is_a?(Hash)
|
|
790
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo source repository evidence"
|
|
791
|
+
end
|
|
792
|
+
full_name = repo && repo["full_name"]
|
|
793
|
+
return nil if !full_name.is_a?(String) || full_name.empty?
|
|
794
|
+
|
|
795
|
+
"#{server_host_root}/#{full_name}"
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Authoritative API evidence for one pull request. The base
|
|
799
|
+
# repository must be the selected one: a misrouted payload is never
|
|
800
|
+
# relabelled.
|
|
801
|
+
def normalize_api_pr(payload)
|
|
802
|
+
base_repo = payload.dig("base", "repo")
|
|
803
|
+
unless base_repo.nil? || base_repo.is_a?(Hash)
|
|
804
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo destination repository evidence"
|
|
805
|
+
end
|
|
806
|
+
base_full_name = base_repo.is_a?(Hash) ? base_repo["full_name"].to_s : ""
|
|
807
|
+
unless !base_full_name.empty? && base_full_name.casecmp(repository_target.repo).zero?
|
|
808
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
809
|
+
"Forgejo returned pull request ##{payload["number"]} with base repository " \
|
|
810
|
+
"#{base_full_name.inspect} for selected #{repository_target.repo}; refusing misrouted evidence"
|
|
811
|
+
end
|
|
812
|
+
state = if payload["state"] == "open"
|
|
813
|
+
:open
|
|
814
|
+
elsif payload["merged"] == true
|
|
815
|
+
:merged
|
|
816
|
+
else
|
|
817
|
+
:closed
|
|
818
|
+
end
|
|
819
|
+
merge_commit = payload["merge_commit_sha"]
|
|
820
|
+
Ace::Git::ProviderPullRequest.new(
|
|
821
|
+
server_name: server.name,
|
|
822
|
+
number: payload["number"],
|
|
823
|
+
title: payload["title"],
|
|
824
|
+
body: payload["body"].is_a?(String) ? payload["body"] : nil,
|
|
825
|
+
state: state,
|
|
826
|
+
head_ref: payload.dig("head", "ref"),
|
|
827
|
+
base_ref: payload.dig("base", "ref"),
|
|
828
|
+
head_sha: payload.dig("head", "sha"),
|
|
829
|
+
author: payload.dig("user", "login"),
|
|
830
|
+
url: pr_url(payload["number"]),
|
|
831
|
+
draft: payload["draft"],
|
|
832
|
+
merged_at: payload["merged_at"].is_a?(String) ? payload["merged_at"] : nil,
|
|
833
|
+
head_repository_url: api_head_repository_url(payload),
|
|
834
|
+
base_repository_url: repository_target.url,
|
|
835
|
+
merge_commit_sha: merge_commit.is_a?(String) && !merge_commit.empty? ? merge_commit : nil
|
|
836
|
+
)
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# A merge receipt requires authoritative merged evidence: the merged
|
|
840
|
+
# state, the merge commit, and the exact source SHA that was merged.
|
|
841
|
+
def prove_merged!(pull_request, expected_head)
|
|
842
|
+
return if pull_request.state == :merged && pull_request.head_sha == expected_head &&
|
|
843
|
+
pull_request.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
844
|
+
|
|
845
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
846
|
+
"Merge accepted but PR ##{pull_request.number} does not prove merged state at " \
|
|
847
|
+
"#{expected_head} with a merge commit on #{server.name}; reconcile before repeating"
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
# A merge refusal that is not the stale-head case: reconcile by read
|
|
851
|
+
# before deciding — an authoritative already-merged result at the
|
|
852
|
+
# expected source SHA is reusable; a closed-unmerged pull request is
|
|
853
|
+
# a classified refusal; everything else stays an unknown outcome.
|
|
854
|
+
def reconcile_refused_merge(number, expected_head, outcome)
|
|
855
|
+
after = verify_post_mutation_head!(number, expected_head, "merge reconciliation")
|
|
856
|
+
if after.state == :merged && after.head_sha == expected_head &&
|
|
857
|
+
after.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
858
|
+
return receipt(:merge, after, nil)
|
|
859
|
+
end
|
|
860
|
+
if after.state == :closed
|
|
861
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
862
|
+
"PR ##{number} is closed without merge; Forgejo refused the merge " \
|
|
863
|
+
"(HTTP #{outcome.status}: #{outcome.message})"
|
|
864
|
+
end
|
|
865
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
866
|
+
"Merge outcome unknown for #{server.name}/##{number}, head #{expected_head}: " \
|
|
867
|
+
"server returned HTTP #{outcome.status} (#{outcome.message}) and the read-back does not " \
|
|
868
|
+
"prove a merge; reconcile before repeating"
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# A duplicate-create race: the exact match is the authoritative
|
|
872
|
+
# winner of the concurrent create.
|
|
873
|
+
def reconcile_raced_create(head_repository_url:, head_ref:, base_ref:, expected_head:, draft:)
|
|
874
|
+
matches = find_open_pull_requests(
|
|
875
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
876
|
+
base_repository_url: server.url, base_ref: base_ref
|
|
877
|
+
)
|
|
878
|
+
if matches.size == 1
|
|
879
|
+
existing = verify_expected_head!(matches.first, expected_head)
|
|
880
|
+
ensure_draft_agreement!(existing, draft)
|
|
881
|
+
return receipt(:create, existing, :existing)
|
|
882
|
+
end
|
|
883
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
884
|
+
"PR create refused as duplicate but #{matches.size} open pull requests match " \
|
|
885
|
+
"#{identity_text(head_repository_url, head_ref, base_ref)}; reconcile by exact identity before repeating"
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# A refused create (404/422) mutated nothing: classify the server's
|
|
889
|
+
# own explanation instead of racing a retry.
|
|
890
|
+
def classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
|
|
891
|
+
error = outcome.status == 404 ? Ace::Git::ProviderObjectNotFoundError : Ace::Git::ProviderIdentityMismatchError
|
|
892
|
+
raise error,
|
|
893
|
+
"Forgejo refused the create of #{identity_text(head_repository_url, head_ref, base_ref)} " \
|
|
894
|
+
"(HTTP #{outcome.status}: #{outcome.message})"
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def pr_api
|
|
898
|
+
@pr_api ||= PullRequestApi.new(server: server, timeout: timeout, runner: runner)
|
|
899
|
+
end
|
|
900
|
+
|
|
525
901
|
def require_open_pr!(pr)
|
|
526
902
|
return if pr.state == :open
|
|
527
903
|
|
|
@@ -533,24 +909,25 @@ module Ace
|
|
|
533
909
|
@review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
|
|
534
910
|
end
|
|
535
911
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
912
|
+
# A mutation receipt may only bind the expected head; if the PR head
|
|
913
|
+
# moved during the mutation the outcome stays uncertain. Returns the
|
|
914
|
+
# authoritative read-back when the head still matches.
|
|
915
|
+
def verify_post_mutation_head!(number, expected_head, correlation)
|
|
916
|
+
current = begin
|
|
917
|
+
pull_request(number: number)
|
|
918
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
|
|
919
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
920
|
+
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
921
|
+
end
|
|
922
|
+
return current if current.head_sha == expected_head
|
|
546
923
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
924
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
925
|
+
"PR head moved during mutation for #{server.name}/##{number} " \
|
|
926
|
+
"(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
|
|
927
|
+
end
|
|
551
928
|
|
|
552
929
|
# Serialize the read/post/reconcile sequence per session so two
|
|
553
|
-
|
|
930
|
+
# concurrent callers cannot both observe "no marker" and post.
|
|
554
931
|
def with_session_lock(correlation)
|
|
555
932
|
require "tmpdir"
|
|
556
933
|
lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
|
|
@@ -567,18 +944,18 @@ module Ace
|
|
|
567
944
|
"<!-- ace-review-session:#{correlation} -->"
|
|
568
945
|
end
|
|
569
946
|
|
|
570
|
-
def matching_review_comments(number, marker
|
|
947
|
+
def matching_review_comments(number, marker)
|
|
571
948
|
review_http.paginate("issues/#{number}/comments").filter_map do |entry|
|
|
572
949
|
unless entry.is_a?(Hash) && entry["body"].is_a?(String)
|
|
573
950
|
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment collection"
|
|
574
951
|
end
|
|
575
952
|
next unless entry["body"].include?(marker)
|
|
576
953
|
|
|
577
|
-
review_comment(entry, number
|
|
954
|
+
review_comment(entry, number)
|
|
578
955
|
end
|
|
579
956
|
end
|
|
580
957
|
|
|
581
|
-
def review_comment(entry, number
|
|
958
|
+
def review_comment(entry, number)
|
|
582
959
|
user = entry.is_a?(Hash) ? entry["user"] : nil
|
|
583
960
|
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
584
961
|
user.is_a?(Hash) && user["login"].is_a?(String)
|
|
@@ -675,23 +1052,7 @@ module Ace
|
|
|
675
1052
|
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
676
1053
|
end
|
|
677
1054
|
|
|
678
|
-
#
|
|
679
|
-
# (create), any transport-level failure becomes an unknown outcome:
|
|
680
|
-
# the request may have mutated the forge, so callers must reconcile
|
|
681
|
-
# by exact identity instead of retrying blindly.
|
|
682
|
-
def send_mutation(operation, *arguments, ambiguous:, identity: nil)
|
|
683
|
-
result = execute_repository(operation, arguments)
|
|
684
|
-
return result if result[:success]
|
|
685
|
-
|
|
686
|
-
classify_failure(result[:stderr], context: operation_context(operation, arguments))
|
|
687
|
-
rescue Ace::Git::ProviderUnreachableError => e
|
|
688
|
-
raise e unless ambiguous
|
|
689
|
-
|
|
690
|
-
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
691
|
-
"PR create outcome unknown after transport failure (#{e.message}); " \
|
|
692
|
-
"reconcile by exact identity before repeating: #{identity}"
|
|
693
|
-
end
|
|
694
|
-
|
|
1055
|
+
# Run one repository-scoped `fj` operation on the validated target.
|
|
695
1056
|
def execute_repository(operation, arguments)
|
|
696
1057
|
target = repository_target
|
|
697
1058
|
ensure_observed_version!
|
|
@@ -728,17 +1089,6 @@ module Ace
|
|
|
728
1089
|
end
|
|
729
1090
|
end
|
|
730
1091
|
|
|
731
|
-
# Fetch one PR as normalized evidence with exact head SHA.
|
|
732
|
-
def fetch_pr(number)
|
|
733
|
-
with_head_sha(view_pr(number))
|
|
734
|
-
end
|
|
735
|
-
|
|
736
|
-
# Complete a parsed PR view with its exact head SHA evidence.
|
|
737
|
-
def with_head_sha(parsed)
|
|
738
|
-
parsed[:head_sha] = head_sha_of(parsed[:number])
|
|
739
|
-
normalize_pr(parsed)
|
|
740
|
-
end
|
|
741
|
-
|
|
742
1092
|
# View one PR and parse its stable minimal-style output. The
|
|
743
1093
|
# qualified `owner/repo#N` reference binds the read to the selected
|
|
744
1094
|
# repository; a returned number that disagrees with the request is a
|
|
@@ -752,27 +1102,6 @@ module Ace
|
|
|
752
1102
|
parsed
|
|
753
1103
|
end
|
|
754
1104
|
|
|
755
|
-
# Re-fetch and verify the PR created by an accepted create request.
|
|
756
|
-
def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
|
|
757
|
-
matches = find_open_pull_requests(
|
|
758
|
-
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
759
|
-
base_repository_url: server.url, base_ref: base_ref
|
|
760
|
-
)
|
|
761
|
-
if matches.empty?
|
|
762
|
-
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
763
|
-
"PR create accepted but no open pull request matches " \
|
|
764
|
-
"#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
|
|
765
|
-
"reconcile by exact identity before repeating"
|
|
766
|
-
end
|
|
767
|
-
if matches.size > 1
|
|
768
|
-
raise Ace::Git::ProviderConflictingMatchesError,
|
|
769
|
-
"Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
|
|
770
|
-
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
771
|
-
end
|
|
772
|
-
|
|
773
|
-
verify_expected_head!(matches.first, expected_head)
|
|
774
|
-
end
|
|
775
|
-
|
|
776
1105
|
def require_base_on_server!(base_repository_url)
|
|
777
1106
|
return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
|
|
778
1107
|
|