ace-git-forgejo 0.6.0 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
4
- data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
3
+ metadata.gz: 670c1a5fa83a0de21364026001ad9b113742be293bf5930b9557ee56df019023
4
+ data.tar.gz: 975f91389a01fa855025479f091a397ba074f43eb535c22887014d410ae0a481
5
5
  SHA512:
6
- metadata.gz: c3d888812cbbec6e223ddfca72514bbee53f3281374825b4147c4f1ebc3fd415528f4fc5c08eafadb6cfed35df6acac4220955f629e15b90dc593d91ceee963c
7
- data.tar.gz: 9dd8b2e6fed777a0c038b111e448c536c0a960c7a7ff53e6d0d9ca1e200bd083e591cb35bf6d7943ec552e206b006021a7314b9bb615e93a9df1a34e179c1f98
6
+ metadata.gz: ea952944afab9658f89959bf5da1f059a4808073c46e77bd6dfcde73017b35021538b353f54cc4c646e6c55937700ca1bc1c47af1c659c6e1f4ff801a7f58693
7
+ data.tar.gz: f1228942a54b588673fa7ca90150fac10ad36af6334ce9b3d16a2fb2780d7cac4f836e414ccd87d6a1c7353506dec3317f6fc23828d5c3d103ed6f73b96ddc58
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.2] - 2026-10-08
11
+
12
+ ### Fixed
13
+
14
+ - Recheck ready/merge acceptance and selected repository provenance; retain the exact expected head and classify unconfirmed effects without replay.
15
+
16
+ ## [0.6.1] - 2026-10-04
17
+
18
+ ### Fixed
19
+ - Prove Forgejo's source repository selector before PR creation and bind accepted receipts to source/base repository IDs, refs, SHA and draft state. Incomplete accepted responses and failed verification remain unknown outcomes without replay.
20
+
10
21
  ## [0.6.0] - 2026-10-04
11
22
 
12
23
  ### Added
@@ -19,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
30
 
20
31
  ### Removed
21
32
  - The fj-era refusals for fork creation, draft-to-ready, and expected-head merging; the conservative `fj pr merge`/`pr edit` paths are replaced by the API lifecycle.
33
+ - The unused `expected_head` plumbing on the review-comment reconciliation helpers (internal refactor; no behavior change).
22
34
 
23
35
  ## [0.5.0] - 2026-10-03
24
36
 
data/README.md CHANGED
@@ -96,3 +96,5 @@ Forgejo-specific conventions the provider translates:
96
96
 
97
97
  Identical lifecycle assertions run against every provider through the
98
98
  shared `Ace::TestSupport::PullRequestLifecycleContract` parity suite.
99
+
100
+ Create preflight verifies the repository that Forgejo's `owner:branch` selector resolves, including direct forks and a fork's parent. Sharing an owner, branch or SHA does not establish source identity. An accepted create returns a receipt only when the response and authoritative read agree with the requested repository IDs, refs, SHA and draft state. A failed or contradictory verification stays `unknown_outcome`; reconcile by reading the exact identity before authorizing another mutation.
@@ -101,6 +101,8 @@ module Ace
101
101
  def exchange_path(suffix)
102
102
  return suffix if suffix.to_s.start_with?("/api/v1/")
103
103
 
104
+ return "/api/v1/repos/#{@target.repo}" if suffix.to_s.empty?
105
+
104
106
  "/api/v1/repos/#{@target.repo}/#{suffix}"
105
107
  end
106
108
 
@@ -33,6 +33,16 @@ module Ace
33
33
  WIP_PREFIXES = ["WIP:", "[WIP]"].freeze
34
34
 
35
35
  class << self
36
+ def pull_request_title(title:, draft:)
37
+ wip = WIP_PREFIXES.any? { |prefix| title.to_s.upcase.start_with?(prefix) }
38
+ return wip ? title : "WIP: #{title}" if draft
39
+ if wip
40
+ raise Ace::Git::ProviderConflictingMatchesError,
41
+ "Requested draft:false conflicts with the WIP-prefixed title"
42
+ end
43
+ title
44
+ end
45
+
36
46
  def display_name
37
47
  "Forgejo"
38
48
  end
@@ -496,7 +506,17 @@ module Ace
496
506
  head_arg = api_head_argument!(head_repository_url, head_ref)
497
507
  send_title = draft_title!(title, draft)
498
508
  pr_api.ensure_version_supported!
499
- outcome = send_lifecycle_mutation(identity_text(head_repository_url, head_ref, base_ref)) do
509
+ requested_head = head_repository_url.to_s.empty? ? server.url : head_repository_url
510
+ owner = Ace::Git::Atoms::ServerUrl.normalize(requested_head).split("/")[1]
511
+ base_identity, head_identity = pr_api.create_repository_identity(owner: owner)
512
+ unless Ace::Git::Atoms::ServerUrl.match?("#{server_host_root}/#{base_identity["full_name"]}", server.url) &&
513
+ Ace::Git::Atoms::ServerUrl.match?("#{server_host_root}/#{head_identity["full_name"]}", requested_head)
514
+ raise Ace::Git::ProviderIdentityMismatchError,
515
+ "Forgejo head selector does not select #{requested_head}; refusing create"
516
+ end
517
+ identity = "#{identity_text(requested_head, head_ref, base_ref)}, " \
518
+ "expected head #{expected_head}, draft #{draft.inspect}"
519
+ outcome = send_lifecycle_mutation(identity) do
500
520
  pr_api.create_pull_request(head: head_arg, base: base_ref, title: send_title, body: body)
501
521
  end
502
522
 
@@ -506,13 +526,31 @@ module Ace
506
526
  return reconcile_raced_create(head_repository_url: head_repository_url, head_ref: head_ref,
507
527
  base_ref: base_ref, expected_head: expected_head, draft: draft)
508
528
  end
509
- unless outcome.status == 201 && outcome.payload.is_a?(Hash) && outcome.payload["number"].is_a?(Integer)
529
+ unless (200..299).cover?(outcome.status)
510
530
  classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
511
531
  end
512
532
 
513
- created = verify_expected_head!(pull_request(number: outcome.payload["number"]), expected_head)
514
- ensure_draft_outcome!(created, draft)
515
- receipt(:create, created, :created)
533
+ begin
534
+ payload = outcome.payload
535
+ unless outcome.status == 201 && payload.is_a?(Hash) &&
536
+ payload["number"].is_a?(Integer) && payload["number"].positive?
537
+ raise Ace::Git::ProviderMalformedOutputError, "Accepted create lacks pull request identity"
538
+ end
539
+ accepted = pr_api.creation_evidence(payload)
540
+ verify_created_identity!(accepted, requested_head, head_ref, base_ref, expected_head,
541
+ draft, base_identity, head_identity)
542
+ after = pr_api.pull_request(payload["number"])
543
+ created = verify_created_identity!(after, requested_head, head_ref, base_ref, expected_head,
544
+ draft, base_identity, head_identity)
545
+ receipt(:create, created, :created)
546
+ rescue Ace::Git::Error => e
547
+ known_number = outcome.payload.is_a?(Hash) && outcome.payload["number"]
548
+ known_number = "unknown" unless known_number.is_a?(Integer) && known_number.positive?
549
+ raise Ace::Git::ProviderUnknownOutcomeError,
550
+ "Forgejo accepted create but verification failed (#{e.class.name.split("::").last}); " \
551
+ "reconcile by exact identity before repeating: #{identity_text(requested_head, head_ref, base_ref)}, " \
552
+ "expected head #{expected_head}, draft #{draft.inspect}, PR #{known_number}"
553
+ end
516
554
  end
517
555
 
518
556
  # Edit title/body after exact-head verification through the same
@@ -521,7 +559,10 @@ module Ace
521
559
  # @return [ProviderMutationReceipt] operation :update
522
560
  def update_pull_request(number:, expected_head:, title: nil, body: nil)
523
561
  number = request_number!(number)
524
- verify_expected_head!(pull_request(number: number), expected_head)
562
+ before = verify_expected_head!(pull_request(number: number), expected_head)
563
+ # Title edits are not a ready transition. Keep Forgejo's title-based
564
+ # draft state, just as providers with an explicit draft field do.
565
+ title = draft_title!(title, before.draft) if title
525
566
  if title || body
526
567
  pr_api.ensure_version_supported!
527
568
  outcome = send_lifecycle_mutation("PR ##{number} on #{server.name}, head #{expected_head}") do
@@ -532,9 +573,13 @@ module Ace
532
573
  "PR update refused for ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
533
574
  "reconcile before repeating"
534
575
  end
535
- verify_post_mutation_head!(number, expected_head, "update")
576
+ after = verify_post_mutation_head!(number, expected_head, "update")
577
+ unless after.draft == before.draft
578
+ raise Ace::Git::ProviderUnknownOutcomeError,
579
+ "PR update changed draft state for ##{number}; reconcile before repeating"
580
+ end
536
581
  end
537
- receipt(:update, pull_request(number: number), nil)
582
+ receipt(:update, after || before, nil)
538
583
  end
539
584
 
540
585
  # Mark a draft ready on the exact head. Forgejo derives draft state
@@ -650,6 +695,19 @@ module Ace
650
695
  "#{e.message}; reconcile by exact identity before repeating: #{identity}"
651
696
  end
652
697
 
698
+ def verify_created_identity!(payload, head_url, head_ref, base_ref, expected_head,
699
+ draft, base_identity, head_identity)
700
+ created = verify_expected_head!(normalize_api_pr(payload), expected_head)
701
+ unless created.state == :open && created.head_ref == head_ref && created.base_ref == base_ref &&
702
+ Ace::Git::Atoms::ServerUrl.match?(created.head_repository_url, head_url) &&
703
+ payload.dig("head", "repo", "id") == head_identity["id"] &&
704
+ payload.dig("base", "repo", "id") == base_identity["id"]
705
+ raise Ace::Git::ProviderIdentityMismatchError, "Created pull request provenance does not match request"
706
+ end
707
+ ensure_draft_outcome!(created, draft)
708
+ created
709
+ end
710
+
653
711
  # Server-derived draft state must agree with the request for an
654
712
  # existing exact match to be reusable.
655
713
  def ensure_draft_agreement!(existing, draft)
@@ -674,15 +732,7 @@ module Ace
674
732
  # defaults, compared case-insensitively without trimming); the API
675
733
  # create form has no draft field.
676
734
  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
735
+ self.class.pull_request_title(title: title, draft: draft)
686
736
  end
687
737
 
688
738
  def work_in_progress_title?(title)
@@ -709,6 +759,9 @@ module Ace
709
759
  # an owner path; cross-host sources are refused before any
710
760
  # mutation (the exact-match lookup may read first).
711
761
  def api_head_argument!(head_repository_url, head_ref)
762
+ if head_ref.to_s.empty? || head_ref.include?(":")
763
+ raise Ace::Git::ProviderIdentityMismatchError, "Head ref cannot encode a repository selector"
764
+ end
712
765
  return head_ref if head_repository_url.nil? ||
713
766
  Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
714
767
  unless same_server_authority?(head_repository_url)
@@ -717,7 +770,8 @@ module Ace
717
770
  "selected server #{server.url}; only canonical and same-server fork sources are supported"
718
771
  end
719
772
 
720
- owner = URI.parse(head_repository_url.to_s).path.to_s.split("/").reject(&:empty?).first
773
+ segments = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/")[1..]
774
+ owner = segments.first if segments.length == 2 && segments.none?(&:empty?)
721
775
  unless owner
722
776
  raise Ace::Git::ProviderIdentityMismatchError,
723
777
  "Head repository #{head_repository_url.inspect} has no owner path; " \
@@ -740,7 +794,11 @@ module Ace
740
794
  # a deleted fork repository reports none, and that absence stays
741
795
  # honest instead of being relabelled.
742
796
  def api_head_repository_url(payload)
743
- full_name = payload.dig("head", "repo", "full_name")
797
+ repo = payload.dig("head", "repo")
798
+ unless repo.nil? || repo.is_a?(Hash)
799
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo source repository evidence"
800
+ end
801
+ full_name = repo && repo["full_name"]
744
802
  return nil if !full_name.is_a?(String) || full_name.empty?
745
803
 
746
804
  "#{server_host_root}/#{full_name}"
@@ -750,7 +808,11 @@ module Ace
750
808
  # repository must be the selected one: a misrouted payload is never
751
809
  # relabelled.
752
810
  def normalize_api_pr(payload)
753
- base_full_name = payload.dig("base", "repo", "full_name").to_s
811
+ base_repo = payload.dig("base", "repo")
812
+ unless base_repo.nil? || base_repo.is_a?(Hash)
813
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo destination repository evidence"
814
+ end
815
+ base_full_name = base_repo.is_a?(Hash) ? base_repo["full_name"].to_s : ""
754
816
  unless !base_full_name.empty? && base_full_name.casecmp(repository_target.repo).zero?
755
817
  raise Ace::Git::ProviderIdentityMismatchError,
756
818
  "Forgejo returned pull request ##{payload["number"]} with base repository " \
@@ -97,6 +97,29 @@ module Ace
97
97
  @http.paginate("pulls?state=open").map { |payload| validate_pr_payload!(payload, payload["number"]) }
98
98
  end
99
99
 
100
+ # Prove the repository selected by Forgejo's owner:branch resolver.
101
+ # Forgejo uses the selected repository for its own owner, otherwise
102
+ # a direct fork owned by that user (the supported Forgejo 8 resolver).
103
+ def create_repository_identity(owner:)
104
+ base = repository_identity!(@http.request(:get, ""))
105
+ return [base, base] if base.dig("owner", "login").casecmp?(owner)
106
+
107
+ forks = @http.paginate("forks").map { |entry| repository_identity!(entry) }
108
+ matches = forks.select { |entry| entry.dig("owner", "login").casecmp?(owner) }
109
+ if matches.length > 1
110
+ raise Ace::Git::ProviderConflictingMatchesError, "Forgejo fork selector is ambiguous for #{owner}"
111
+ end
112
+ head = matches.first
113
+ unless head
114
+ raise Ace::Git::ProviderIdentityMismatchError, "Forgejo cannot prove fork selector for #{owner}"
115
+ end
116
+ [base, head]
117
+ end
118
+
119
+ def creation_evidence(payload)
120
+ validate_pr_payload!(payload, payload.is_a?(Hash) ? payload["number"] : nil)
121
+ end
122
+
100
123
  # Send the create mutation. The head argument must already be in the
101
124
  # documented API form (plain branch, or "owner:branch" for a
102
125
  # same-server fork).
@@ -154,6 +177,17 @@ module Ace
154
177
  raise error, "Forgejo #{operation} returned HTTP #{status}; reconcile before repeating"
155
178
  end
156
179
 
180
+ def repository_identity!(payload)
181
+ unless payload.is_a?(Hash) && payload["id"].is_a?(Integer) && payload["id"].positive? &&
182
+ payload["full_name"].is_a?(String) && payload["full_name"].match?(%r{\A[^/:\s?#]+/[^/:\s?#]+\z}) &&
183
+ payload["owner"].is_a?(Hash) && payload.dig("owner", "id").is_a?(Integer) && payload.dig("owner", "id").positive? &&
184
+ payload.dig("owner", "login").is_a?(String) && !payload.dig("owner", "login").empty? &&
185
+ payload["full_name"].split("/").first.casecmp?(payload.dig("owner", "login"))
186
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository selector evidence"
187
+ end
188
+ payload
189
+ end
190
+
157
191
  def validate_pr_payload!(payload, requested_number)
158
192
  unless payload.is_a?(Hash)
159
193
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo pull request evidence"
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.6.0"
6
+ VERSION = "0.6.2"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-forgejo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-04 00:00:00.000000000 Z
10
+ date: 2026-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.26'
18
+ version: '0.30'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.26'
25
+ version: '0.30'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement