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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -13
- data/README.md +54 -10
- data/lib/ace/git/forgejo/http_client.rb +86 -20
- data/lib/ace/git/forgejo/provider.rb +462 -133
- data/lib/ace/git/forgejo/pull_request_api.rb +229 -0
- data/lib/ace/git/forgejo/repository_binding.rb +0 -11
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +31 -2
|
@@ -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,
|
data/lib/ace/git/forgejo.rb
CHANGED
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
|
+
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-
|
|
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
|