ace-git-forgejo 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
4
- data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
3
+ metadata.gz: b31bdebd863b7bee391783037e9aef4dab2a1ac4bede0b2b897e8442ac297b7f
4
+ data.tar.gz: db3a546c09effeda3781370316a691cb893af860cb0212ef982320a521ab9cc0
5
5
  SHA512:
6
- metadata.gz: c3d888812cbbec6e223ddfca72514bbee53f3281374825b4147c4f1ebc3fd415528f4fc5c08eafadb6cfed35df6acac4220955f629e15b90dc593d91ceee963c
7
- data.tar.gz: 9dd8b2e6fed777a0c038b111e448c536c0a960c7a7ff53e6d0d9ca1e200bd083e591cb35bf6d7943ec552e206b006021a7314b9bb615e93a9df1a34e179c1f98
6
+ metadata.gz: b4323793a3d0b8b0f217bf7acca41d31d89dc9316b14c51509352edc903760d6811e7412497d1189bf9339cd774154ac6fbc70b506d4c0587a017a3bc8dce2f9
7
+ data.tar.gz: e501d7e7d7ddf48e3194eb6a110c29df8984ba5f873295c0d6bbbbaa621987dcd814f50ede1fb594eb7f18c605dd1b8bc98edb2ca95a3cb6837db8f7a829d305
data/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.1] - 2026-10-04
11
+
12
+ ### Fixed
13
+ - 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.
14
+
10
15
  ## [0.6.0] - 2026-10-04
11
16
 
12
17
  ### Added
@@ -19,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
24
 
20
25
  ### Removed
21
26
  - 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.
27
+ - The unused `expected_head` plumbing on the review-comment reconciliation helpers (internal refactor; no behavior change).
22
28
 
23
29
  ## [0.5.0] - 2026-10-03
24
30
 
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
 
@@ -496,7 +496,17 @@ module Ace
496
496
  head_arg = api_head_argument!(head_repository_url, head_ref)
497
497
  send_title = draft_title!(title, draft)
498
498
  pr_api.ensure_version_supported!
499
- outcome = send_lifecycle_mutation(identity_text(head_repository_url, head_ref, base_ref)) do
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
500
510
  pr_api.create_pull_request(head: head_arg, base: base_ref, title: send_title, body: body)
501
511
  end
502
512
 
@@ -506,13 +516,31 @@ module Ace
506
516
  return reconcile_raced_create(head_repository_url: head_repository_url, head_ref: head_ref,
507
517
  base_ref: base_ref, expected_head: expected_head, draft: draft)
508
518
  end
509
- unless outcome.status == 201 && outcome.payload.is_a?(Hash) && outcome.payload["number"].is_a?(Integer)
519
+ unless (200..299).cover?(outcome.status)
510
520
  classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
511
521
  end
512
522
 
513
- created = verify_expected_head!(pull_request(number: outcome.payload["number"]), expected_head)
514
- ensure_draft_outcome!(created, draft)
515
- 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
516
544
  end
517
545
 
518
546
  # Edit title/body after exact-head verification through the same
@@ -650,6 +678,19 @@ module Ace
650
678
  "#{e.message}; reconcile by exact identity before repeating: #{identity}"
651
679
  end
652
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
+
653
694
  # Server-derived draft state must agree with the request for an
654
695
  # existing exact match to be reusable.
655
696
  def ensure_draft_agreement!(existing, draft)
@@ -709,6 +750,9 @@ module Ace
709
750
  # an owner path; cross-host sources are refused before any
710
751
  # mutation (the exact-match lookup may read first).
711
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
712
756
  return head_ref if head_repository_url.nil? ||
713
757
  Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
714
758
  unless same_server_authority?(head_repository_url)
@@ -717,7 +761,8 @@ module Ace
717
761
  "selected server #{server.url}; only canonical and same-server fork sources are supported"
718
762
  end
719
763
 
720
- owner = URI.parse(head_repository_url.to_s).path.to_s.split("/").reject(&:empty?).first
764
+ segments = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/")[1..]
765
+ owner = segments.first if segments.length == 2 && segments.none?(&:empty?)
721
766
  unless owner
722
767
  raise Ace::Git::ProviderIdentityMismatchError,
723
768
  "Head repository #{head_repository_url.inspect} has no owner path; " \
@@ -740,7 +785,11 @@ module Ace
740
785
  # a deleted fork repository reports none, and that absence stays
741
786
  # honest instead of being relabelled.
742
787
  def api_head_repository_url(payload)
743
- full_name = payload.dig("head", "repo", "full_name")
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"]
744
793
  return nil if !full_name.is_a?(String) || full_name.empty?
745
794
 
746
795
  "#{server_host_root}/#{full_name}"
@@ -750,7 +799,11 @@ module Ace
750
799
  # repository must be the selected one: a misrouted payload is never
751
800
  # relabelled.
752
801
  def normalize_api_pr(payload)
753
- base_full_name = payload.dig("base", "repo", "full_name").to_s
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 : ""
754
807
  unless !base_full_name.empty? && base_full_name.casecmp(repository_target.repo).zero?
755
808
  raise Ace::Git::ProviderIdentityMismatchError,
756
809
  "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.1"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz