ace-git-forgejo 0.4.0 → 0.6.0

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,195 @@
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
+ # Send the create mutation. The head argument must already be in the
101
+ # documented API form (plain branch, or "owner:branch" for a
102
+ # same-server fork).
103
+ #
104
+ # @return [MutationOutcome] 201 with the created payload, or a
105
+ # refusal outcome (409 duplicate, 404/422 unprovable source)
106
+ def create_pull_request(head:, base:, title:, body: nil)
107
+ form = {"title" => title, "base" => base, "head" => head}
108
+ form["body"] = body if body
109
+ status, payload, message = @http.exchange(:post, "pulls", body: form)
110
+ classify_send!(status, "create pull request")
111
+ MutationOutcome.new(status: status, message: message, payload: payload)
112
+ end
113
+
114
+ # Edit title/body of one pull request (also the ready transition:
115
+ # Forgejo marks a draft ready by removing its WIP title prefix).
116
+ #
117
+ # @return [MutationOutcome] 200, or a refusal outcome
118
+ def edit_pull_request(number, title: nil, body: nil)
119
+ form = {}
120
+ form["title"] = title if title
121
+ form["body"] = body if body
122
+ status, payload, message = @http.exchange(:patch, "pulls/#{number}", body: form)
123
+ classify_send!(status, "edit pull request #{number}")
124
+ MutationOutcome.new(status: status, message: message, payload: payload)
125
+ end
126
+
127
+ # Send the server-enforced merge: Forgejo re-resolves the head ref
128
+ # at merge time and refuses with 409 when it does not equal
129
+ # `head_commit_id` (documented `MergePullRequestOption`).
130
+ #
131
+ # @param do_method [String] "squash", "merge", or "rebase"
132
+ # @return [MutationOutcome] 200, or a refusal outcome (405 disabled
133
+ # style, 409 stale head / conflicts / already merged)
134
+ def merge_pull_request(number, do_method:, head_commit_id:)
135
+ form = {"Do" => do_method, "head_commit_id" => head_commit_id}
136
+ status, payload, message = @http.exchange(:post, "pulls/#{number}/merge", body: form)
137
+ classify_send!(status, "merge pull request #{number}")
138
+ MutationOutcome.new(status: status, message: message, payload: payload)
139
+ end
140
+
141
+ private
142
+
143
+ # Authentication, absence, and transport failures classify directly
144
+ # inside the transport; only statuses whose meaning is
145
+ # endpoint-specific reach the provider as an outcome.
146
+ def classify_send!(status, operation)
147
+ return if (200..299).cover?(status)
148
+ return if [404, 405, 409, 422].include?(status.to_i)
149
+
150
+ error = case status.to_i
151
+ when 401, 403 then Ace::Git::ProviderAuthenticationError
152
+ else Ace::Git::ProviderUnknownOutcomeError
153
+ end
154
+ raise error, "Forgejo #{operation} returned HTTP #{status}; reconcile before repeating"
155
+ end
156
+
157
+ def validate_pr_payload!(payload, requested_number)
158
+ unless payload.is_a?(Hash)
159
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo pull request evidence"
160
+ end
161
+ head = payload["head"]
162
+ base = payload["base"]
163
+ unless payload["number"].is_a?(Integer) && payload["number"].positive? &&
164
+ %w[open closed].include?(payload["state"]) &&
165
+ [true, false].include?(payload["draft"]) &&
166
+ [true, false].include?(payload["merged"]) &&
167
+ head.is_a?(Hash) && head["ref"].is_a?(String) && !head["ref"].empty? &&
168
+ head["sha"].to_s.match?(/\A[0-9a-f]{40}\z/) &&
169
+ base.is_a?(Hash) && base["ref"].is_a?(String) && !base["ref"].empty?
170
+ raise Ace::Git::ProviderMalformedOutputError,
171
+ "Malformed Forgejo pull request payload (missing number/state/draft/head/base identity)"
172
+ end
173
+ unless payload["number"] == requested_number
174
+ raise Ace::Git::ProviderIdentityMismatchError,
175
+ "Forgejo returned pull request ##{payload["number"]} for selected ##{requested_number}; " \
176
+ "refusing misrouted evidence"
177
+ end
178
+ payload
179
+ end
180
+
181
+ # Server version strings look like "7.0.5+gitea-..." or "v12.0.1";
182
+ # only the leading numeric release is comparable.
183
+ def parse_version(text)
184
+ match = text.strip.sub(/\Av/i, "").match(/\A(\d+\.\d+\.\d+)/)
185
+ unless match
186
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
187
+ "Forgejo server version #{text.inspect} is not a comparable release; " \
188
+ "cannot prove the PR delivery capability floor #{MINIMUM_SERVER_VERSION}"
189
+ end
190
+ Gem::Version.new(match[1])
191
+ end
192
+ end
193
+ end
194
+ end
195
+ 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,
@@ -175,8 +164,10 @@ module Ace
175
164
  nil
176
165
  end
177
166
 
178
- # Observed forgejo-cli v0.6.0 keys-file locations (data_dir of the
179
- # `directories` crate, plus the legacy Cyborus org path).
167
+ # Observed forgejo-cli keys-file locations (data_dir of the
168
+ # `directories` crate, plus the legacy Cyborus org path). On macOS
169
+ # the real CLI uses bundle-dir names: forgejo-cli.forgejo-cli
170
+ # (current) and Cyborus.forgejo-cli (legacy).
180
171
  def default_keys_path
181
172
  candidates = []
182
173
  xdg = ENV["XDG_DATA_HOME"]
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.4.0"
6
+ VERSION = "0.6.0"
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.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 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
@@ -40,9 +68,11 @@ files:
40
68
  - lib/ace/git/forgejo.rb
41
69
  - lib/ace/git/forgejo/cli_executor.rb
42
70
  - lib/ace/git/forgejo/http_client.rb
71
+ - lib/ace/git/forgejo/issue_api.rb
43
72
  - lib/ace/git/forgejo/parsers.rb
44
73
  - lib/ace/git/forgejo/pr_identifier.rb
45
74
  - lib/ace/git/forgejo/provider.rb
75
+ - lib/ace/git/forgejo/pull_request_api.rb
46
76
  - lib/ace/git/forgejo/repository_binding.rb
47
77
  - lib/ace/git/forgejo/version.rb
48
78
  homepage: https://forgejo.tail6c0887.ts.net/cs3b/ace