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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +2 -0
- data/lib/ace/git/forgejo/http_client.rb +2 -0
- data/lib/ace/git/forgejo/provider.rb +61 -8
- data/lib/ace/git/forgejo/pull_request_api.rb +34 -0
- data/lib/ace/git/forgejo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b31bdebd863b7bee391783037e9aef4dab2a1ac4bede0b2b897e8442ac297b7f
|
|
4
|
+
data.tar.gz: db3a546c09effeda3781370316a691cb893af860cb0212ef982320a521ab9cc0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|