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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +2 -0
- data/lib/ace/git/forgejo/http_client.rb +2 -0
- data/lib/ace/git/forgejo/provider.rb +82 -20
- data/lib/ace/git/forgejo/pull_request_api.rb +34 -0
- data/lib/ace/git/forgejo/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 670c1a5fa83a0de21364026001ad9b113742be293bf5930b9557ee56df019023
|
|
4
|
+
data.tar.gz: 975f91389a01fa855025479f091a397ba074f43eb535c22887014d410ae0a481
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
25
|
+
version: '0.30'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|