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.
@@ -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` responses into the
15
- # normalized evidence types defined by the ace-git core. Every
16
- # repository-scoped call is bound to the selected server's exact
17
- # host/owner/repository through the {CliExecutor} repository boundary:
18
- # the selected identity is mandatory, unobserved operations refuse
19
- # before launch, and returned evidence is checked for conflicting
20
- # identity. Every failure is classified with the shared taxonomy;
21
- # `fj` failures are never hidden behind ad-hoc curl calls or silent
22
- # fallbacks.
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 `fj` output is unexpected
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
- fetch_pr(number)
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, expected_head)
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, expected_head) }
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, expected_head)
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, expected_head)
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, expected_head)
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, expected_head), :updated)
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
- listing = fj(:pr_search, "open")
441
- Parsers.parse_search(listing).filter_map do |entry|
442
- parsed = view_pr(entry[:number])
443
- next unless parsed && parsed[:state] == :open
444
- next unless parsed[:head_ref] == head_ref && parsed[:base_ref] == base_ref
445
- next unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url_of(parsed), head_repository_url)
446
-
447
- with_head_sha(parsed)
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 a PR via `fj pr create`, reconciling to an exact open match
452
- # first and proving the resulting head against `expected_head`.
453
- # `fj` cannot encode an explicit fork head; the receipt reports the
454
- # provider's real draft state.
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
- return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
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
- send_mutation(:pr_create, title, head_ref, base_ref, body,
482
- ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
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
- created = reconcile_created(
485
- head_repository_url: head_repository_url, head_ref: head_ref,
486
- base_ref: base_ref, expected_head: expected_head
487
- )
488
- receipt(:create, created, :created)
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
- # `fj pr edit` takes one field subcommand per invocation (title|body),
492
- # so multi-field updates are separate commands with final evidence
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!(fetch_pr(number), expected_head)
499
- send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
500
- send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
501
- receipt(:update, fetch_pr(number), nil)
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
- # `fj` offers no draft-to-ready command; classified as an unsupported
505
- # capability instead of guessing at provider behavior.
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
- raise Ace::Git::ProviderUnsupportedCapabilityError,
508
- "Forgejo CLI (fj) cannot mark a draft pull request ready; use the forge web UI"
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
- # `fj pr merge` cannot enforce an expected-head precondition
512
- # atomically, so merging is refused rather than racing a stale head.
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
- raise Ace::Git::ProviderUnsupportedCapabilityError,
519
- "Forgejo CLI (fj) pr merge cannot enforce expected-head (#{expected_head}) " \
520
- "atomically; refusing unsafe merge"
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
- # A mutation receipt may only bind the expected head; if the PR head
537
- # moved during the mutation the outcome stays uncertain.
538
- def verify_post_mutation_head!(number, expected_head, correlation)
539
- current = begin
540
- pull_request(number: number)
541
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
542
- raise Ace::Git::ProviderUnknownOutcomeError,
543
- "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
544
- end
545
- return if current.head_sha == expected_head
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
- raise Ace::Git::ProviderUnknownOutcomeError,
548
- "PR head moved during mutation for #{server.name}/##{number} " \
549
- "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
550
- end
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
- # concurrent callers cannot both observe "no marker" and post.
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, expected_head)
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, expected_head)
954
+ review_comment(entry, number)
578
955
  end
579
956
  end
580
957
 
581
- def review_comment(entry, number, head)
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
- # Send one mutating repository operation. When `ambiguous` is true
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