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.
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Git
7
+ module Forgejo
8
+ # Repository-bound Forgejo API v1 operations for the PR delivery
9
+ # lifecycle (create, edit, ready, merge) plus the authoritative single
10
+ # pull request read.
11
+ #
12
+ # The fj v0.6 CLI cannot encode a fork head, an explicit draft state,
13
+ # a ready transition, or an atomic expected-head merge precondition;
14
+ # this molecule owns those endpoints on the same selected host and
15
+ # token transport as {HttpClient}. Every returned payload is validated
16
+ # against the documented Forgejo `PullRequest` schema before it may
17
+ # become provider evidence; contradictory or missing identity fields
18
+ # fail closed as malformed evidence.
19
+ #
20
+ # Mutations return a {MutationOutcome} for the statuses that carry
21
+ # endpoint-specific meaning (201/200 success, 405/409/422 refusal) so
22
+ # the provider can classify the forge's own semantics; authentication,
23
+ # absence, and transport failures raise the shared taxonomy directly.
24
+ class PullRequestApi
25
+ # Official capability evidence (see the task capability-evidence
26
+ # report): `head_commit_id` merge enforcement is documented from
27
+ # release branch v7.0, and the API `draft` field is only computed
28
+ # from the WIP title from v8.0 (`Draft: pr.IsWorkInProgress` and a
29
+ # non-omitempty bool; v7.0 leaves it unset). The lifecycle proves
30
+ # draft state, so the floor is the first line reporting both.
31
+ MINIMUM_SERVER_VERSION = Gem::Version.new("8.0.0")
32
+
33
+ # Outcome of one mutation send whose status the provider must
34
+ # interpret: the HTTP status, the sanitized server message (nil on
35
+ # success), and the parsed payload when the server returned one.
36
+ MutationOutcome = Data.define(:status, :message, :payload)
37
+
38
+ def initialize(server:, timeout:, runner: nil)
39
+ @http = HttpClient.new(server: server, timeout: timeout, runner: runner)
40
+ end
41
+
42
+ # Probe and validate the selected server's API version once per
43
+ # instance. Every lifecycle mutation gate goes through here and
44
+ # fails closed when the server cannot prove the documented
45
+ # capability floor. Transport and authentication failures classify
46
+ # as they would for any read; an unusable or too-old version
47
+ # reports is a capability refusal.
48
+ #
49
+ # @raise [Ace::Git::ProviderUnsupportedCapabilityError] when the
50
+ # server version cannot be verified or is below the floor
51
+ def ensure_version_supported!
52
+ return if @version_supported
53
+
54
+ status, payload = @http.exchange(:get, "/api/v1/version")
55
+ version_text = payload.is_a?(Hash) ? payload["version"] : nil
56
+ unless status == 200 && version_text.is_a?(String) && !version_text.empty?
57
+ if [401, 403].include?(status.to_i)
58
+ raise Ace::Git::ProviderAuthenticationError,
59
+ "Forgejo server version probe returned HTTP #{status}; authenticate with fj first"
60
+ end
61
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
62
+ "Forgejo server did not report a usable API version (HTTP #{status}); " \
63
+ "cannot prove the PR delivery capability floor " \
64
+ "#{MINIMUM_SERVER_VERSION} before mutating"
65
+ end
66
+
67
+ version = parse_version(version_text)
68
+ unless version >= MINIMUM_SERVER_VERSION
69
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
70
+ "Forgejo server reports #{version}; PR delivery requires #{MINIMUM_SERVER_VERSION} " \
71
+ "(server-enforced expected-head merge is not documented below it)"
72
+ end
73
+ @version_supported = true
74
+ end
75
+
76
+ # @return [Hash] the raw validated pull request payload
77
+ # @raise [Ace::Git::ProviderObjectNotFoundError] when absent
78
+ # @raise [Ace::Git::ProviderMalformedOutputError] on schema violation
79
+ # @raise [Ace::Git::ProviderIdentityMismatchError] when the payload
80
+ # reports another object number (misrouted evidence)
81
+ def pull_request(number)
82
+ status, payload = @http.exchange(:get, "pulls/#{number}")
83
+ case status
84
+ when 200 then validate_pr_payload!(payload, number)
85
+ when 404 then raise Ace::Git::ProviderObjectNotFoundError, "Forgejo pull request ##{number} not found"
86
+ when 401, 403
87
+ raise Ace::Git::ProviderAuthenticationError,
88
+ "Forgejo pull request ##{number} read failed with HTTP #{status}"
89
+ else
90
+ raise Ace::Git::ProviderUnreachableError,
91
+ "Forgejo pull request ##{number} read failed with HTTP #{status}"
92
+ end
93
+ end
94
+
95
+ # @return [Array<Hash>] validated payloads of all open pull requests
96
+ def open_pull_requests
97
+ @http.paginate("pulls?state=open").map { |payload| validate_pr_payload!(payload, payload["number"]) }
98
+ end
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
+
123
+ # Send the create mutation. The head argument must already be in the
124
+ # documented API form (plain branch, or "owner:branch" for a
125
+ # same-server fork).
126
+ #
127
+ # @return [MutationOutcome] 201 with the created payload, or a
128
+ # refusal outcome (409 duplicate, 404/422 unprovable source)
129
+ def create_pull_request(head:, base:, title:, body: nil)
130
+ form = {"title" => title, "base" => base, "head" => head}
131
+ form["body"] = body if body
132
+ status, payload, message = @http.exchange(:post, "pulls", body: form)
133
+ classify_send!(status, "create pull request")
134
+ MutationOutcome.new(status: status, message: message, payload: payload)
135
+ end
136
+
137
+ # Edit title/body of one pull request (also the ready transition:
138
+ # Forgejo marks a draft ready by removing its WIP title prefix).
139
+ #
140
+ # @return [MutationOutcome] 200, or a refusal outcome
141
+ def edit_pull_request(number, title: nil, body: nil)
142
+ form = {}
143
+ form["title"] = title if title
144
+ form["body"] = body if body
145
+ status, payload, message = @http.exchange(:patch, "pulls/#{number}", body: form)
146
+ classify_send!(status, "edit pull request #{number}")
147
+ MutationOutcome.new(status: status, message: message, payload: payload)
148
+ end
149
+
150
+ # Send the server-enforced merge: Forgejo re-resolves the head ref
151
+ # at merge time and refuses with 409 when it does not equal
152
+ # `head_commit_id` (documented `MergePullRequestOption`).
153
+ #
154
+ # @param do_method [String] "squash", "merge", or "rebase"
155
+ # @return [MutationOutcome] 200, or a refusal outcome (405 disabled
156
+ # style, 409 stale head / conflicts / already merged)
157
+ def merge_pull_request(number, do_method:, head_commit_id:)
158
+ form = {"Do" => do_method, "head_commit_id" => head_commit_id}
159
+ status, payload, message = @http.exchange(:post, "pulls/#{number}/merge", body: form)
160
+ classify_send!(status, "merge pull request #{number}")
161
+ MutationOutcome.new(status: status, message: message, payload: payload)
162
+ end
163
+
164
+ private
165
+
166
+ # Authentication, absence, and transport failures classify directly
167
+ # inside the transport; only statuses whose meaning is
168
+ # endpoint-specific reach the provider as an outcome.
169
+ def classify_send!(status, operation)
170
+ return if (200..299).cover?(status)
171
+ return if [404, 405, 409, 422].include?(status.to_i)
172
+
173
+ error = case status.to_i
174
+ when 401, 403 then Ace::Git::ProviderAuthenticationError
175
+ else Ace::Git::ProviderUnknownOutcomeError
176
+ end
177
+ raise error, "Forgejo #{operation} returned HTTP #{status}; reconcile before repeating"
178
+ end
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
+
191
+ def validate_pr_payload!(payload, requested_number)
192
+ unless payload.is_a?(Hash)
193
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo pull request evidence"
194
+ end
195
+ head = payload["head"]
196
+ base = payload["base"]
197
+ unless payload["number"].is_a?(Integer) && payload["number"].positive? &&
198
+ %w[open closed].include?(payload["state"]) &&
199
+ [true, false].include?(payload["draft"]) &&
200
+ [true, false].include?(payload["merged"]) &&
201
+ head.is_a?(Hash) && head["ref"].is_a?(String) && !head["ref"].empty? &&
202
+ head["sha"].to_s.match?(/\A[0-9a-f]{40}\z/) &&
203
+ base.is_a?(Hash) && base["ref"].is_a?(String) && !base["ref"].empty?
204
+ raise Ace::Git::ProviderMalformedOutputError,
205
+ "Malformed Forgejo pull request payload (missing number/state/draft/head/base identity)"
206
+ end
207
+ unless payload["number"] == requested_number
208
+ raise Ace::Git::ProviderIdentityMismatchError,
209
+ "Forgejo returned pull request ##{payload["number"]} for selected ##{requested_number}; " \
210
+ "refusing misrouted evidence"
211
+ end
212
+ payload
213
+ end
214
+
215
+ # Server version strings look like "7.0.5+gitea-..." or "v12.0.1";
216
+ # only the leading numeric release is comparable.
217
+ def parse_version(text)
218
+ match = text.strip.sub(/\Av/i, "").match(/\A(\d+\.\d+\.\d+)/)
219
+ unless match
220
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
221
+ "Forgejo server version #{text.inspect} is not a comparable release; " \
222
+ "cannot prove the PR delivery capability floor #{MINIMUM_SERVER_VERSION}"
223
+ end
224
+ Gem::Version.new(match[1])
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -100,17 +100,6 @@ module Ace
100
100
  pr_search: ->(target, state) do
101
101
  ["--style", "minimal", "pr", "search", "--state", state, "-r", target.repo]
102
102
  end,
103
- pr_create: ->(target, title, head_ref, base_ref, body) do
104
- argv = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
105
- argv += ["--body", body] if body
106
- argv + ["-r", target.repo]
107
- end,
108
- pr_edit_title: ->(target, number, title) do
109
- ["pr", "edit", target.qualified_ref(number), "title", title]
110
- end,
111
- pr_edit_body: ->(target, number, body) do
112
- ["pr", "edit", target.qualified_ref(number), "body", body]
113
- end,
114
103
  issue_view: ->(target, number) do
115
104
  ["--style", "minimal", "issue", "view", target.qualified_ref(number)]
116
105
  end,
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.5.0"
6
+ VERSION = "0.6.1"
7
7
  end
8
8
  end
9
9
  end
@@ -8,6 +8,7 @@ require_relative "forgejo/cli_executor"
8
8
  require_relative "forgejo/pr_identifier"
9
9
  require_relative "forgejo/parsers"
10
10
  require_relative "forgejo/http_client"
11
+ require_relative "forgejo/pull_request_api"
11
12
  require_relative "forgejo/provider"
12
13
 
13
14
  module Ace
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.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-03 00:00:00.000000000 Z
10
+ date: 2026-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git
@@ -23,6 +23,34 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.26'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.14'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.14'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-retry
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.4'
26
54
  description: 'Implements the shared ace-git provider contract for Forgejo: all `fj`
27
55
  CLI invocation, output parsing, authentication verification, and classified failure
28
56
  handling live behind one normalized evidence boundary. Ad-hoc curl fallbacks are
@@ -44,6 +72,7 @@ files:
44
72
  - lib/ace/git/forgejo/parsers.rb
45
73
  - lib/ace/git/forgejo/pr_identifier.rb
46
74
  - lib/ace/git/forgejo/provider.rb
75
+ - lib/ace/git/forgejo/pull_request_api.rb
47
76
  - lib/ace/git/forgejo/repository_binding.rb
48
77
  - lib/ace/git/forgejo/version.rb
49
78
  homepage: https://forgejo.tail6c0887.ts.net/cs3b/ace