ace-git-forgejo 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/lib/ace/git/forgejo/parsers.rb +11 -3
- data/lib/ace/git/forgejo/provider.rb +198 -12
- data/lib/ace/git/forgejo/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f71ec19c2631e884747c20ba07ad19b595fff3ad52c4505571d3f91da61f2ff
|
|
4
|
+
data.tar.gz: ca8d1ff36047ef554da1fa8b23285b7fb01d486b35bde3321b03d199ba92ffb0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cddccbd90c235fe84a052c58dfe3030a6c3b826527da34681d4316579c46e798bc1ca212ee438f5020be3d47ac77d51fc99729ca2805d1f7c3e3f0c0046b30a
|
|
7
|
+
data.tar.gz: 621fa2ebff13f4e344dba8500ad40b2e41604a97c39282979424d9eaf942c32c2f266be1f3aaa74037f1244924dd73518067f74d19a93162d0378f3781dcc487
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- PR lifecycle mutations matching the shared provider contract: exact-match
|
|
14
|
+
lookup via `fj pr search`, idempotent create reconciliation, head-verified
|
|
15
|
+
title/body updates.
|
|
16
|
+
- Fork provenance: the `fj pr view` head-repository segment is parsed and
|
|
17
|
+
returned in normalized PR evidence.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
|
|
21
|
+
offers no draft-to-ready command and cannot enforce an expected-head merge
|
|
22
|
+
precondition atomically, so both refuse instead of racing.
|
|
23
|
+
- PR view parser captures the fork repository prefix of the `From` segment.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- PR lifecycle mutations matching the shared provider contract: exact-match
|
|
27
|
+
lookup via `fj pr search`, idempotent create reconciliation, head-verified
|
|
28
|
+
title/body updates.
|
|
29
|
+
- Fork provenance: the `fj pr view` head-repository segment is parsed and
|
|
30
|
+
returned in normalized PR evidence.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
|
|
34
|
+
offers no draft-to-ready command and cannot enforce an expected-head merge
|
|
35
|
+
precondition atomically, so both refuse instead of racing.
|
|
36
|
+
- PR view parser captures the fork repository prefix of the `From` segment.
|
|
37
|
+
|
|
38
|
+
## [0.1.1] - 2026-09-28
|
|
39
|
+
|
|
40
|
+
|
|
10
41
|
### Fixed
|
|
11
42
|
|
|
12
43
|
- Parse real `fj` v0.6.0 minimal-style output: strip Unicode bidi isolate/pop-directional marks (U+2066–U+2069, U+202A–U+202E, U+200E/U+200F, soft hyphen) that `[[:cntrl:]]` does not cover. Real `fj` output wraps dynamic fields (titles, numbers, URLs) in U+2068/U+2069, which made `parse_pr_view` return `nil` and leaked marks into repo-URL evidence.
|
|
@@ -29,7 +29,11 @@ module Ace
|
|
|
29
29
|
# By lab-builder - Merged - +252 -8
|
|
30
30
|
# From `owner/repo:head-branch` into `main`
|
|
31
31
|
#
|
|
32
|
-
#
|
|
32
|
+
# The `From` segment carries an optional `owner/repo:` prefix before
|
|
33
|
+
# the head branch; it is present only for fork (cross-repository)
|
|
34
|
+
# pull requests.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash, nil] {number:, title:, author:, state:, head_ref:, base_ref:, head_repository:}
|
|
33
37
|
def self.parse_pr_view(text)
|
|
34
38
|
lines = text.to_s.lines.map { |line| clean(line) }.reject(&:empty?)
|
|
35
39
|
return nil if lines.empty?
|
|
@@ -44,7 +48,8 @@ module Ace
|
|
|
44
48
|
author: nil,
|
|
45
49
|
state: nil,
|
|
46
50
|
head_ref: nil,
|
|
47
|
-
base_ref: nil
|
|
51
|
+
base_ref: nil,
|
|
52
|
+
head_repository: nil
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
byline = lines[1].to_s
|
|
@@ -54,9 +59,12 @@ module Ace
|
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
refs = lines.find { |line| line.start_with?("From ") }
|
|
57
|
-
if (match = refs.to_s.match(
|
|
62
|
+
if (match = refs.to_s.match(
|
|
63
|
+
/\AFrom\s+`(?:(?<repo>[^`:]+):)?(?<head>[^`]+)`\s+into\s+`(?<base>[^`]+)`\z/
|
|
64
|
+
))
|
|
58
65
|
info[:head_ref] = match[:head]
|
|
59
66
|
info[:base_ref] = match[:base]
|
|
67
|
+
info[:head_repository] = match[:repo]
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
info
|
|
@@ -46,13 +46,7 @@ module Ace
|
|
|
46
46
|
# @raise [ProviderMalformedOutputError] when `fj` output is unexpected
|
|
47
47
|
# @raise [ProviderUnreachableError] when the endpoint is unreachable
|
|
48
48
|
def pull_request(number:)
|
|
49
|
-
|
|
50
|
-
parsed = Parsers.parse_pr_view(view) ||
|
|
51
|
-
raise(Ace::Git::ProviderMalformedOutputError,
|
|
52
|
-
"Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
|
|
53
|
-
|
|
54
|
-
parsed[:head_sha] = head_sha_of(number)
|
|
55
|
-
normalize_pr(parsed)
|
|
49
|
+
fetch_pr(number)
|
|
56
50
|
end
|
|
57
51
|
|
|
58
52
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
@@ -137,6 +131,95 @@ module Ace
|
|
|
137
131
|
)
|
|
138
132
|
end
|
|
139
133
|
|
|
134
|
+
# ---- PR lifecycle mutations ----
|
|
135
|
+
|
|
136
|
+
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
137
|
+
# base/head identity, with head SHA provenance
|
|
138
|
+
def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
139
|
+
require_base_on_server!(base_repository_url)
|
|
140
|
+
listing = fj(["--style", "minimal", "pr", "search", "--state", "open"])
|
|
141
|
+
Parsers.parse_search(listing).filter_map do |entry|
|
|
142
|
+
parsed = view_pr(entry[:number])
|
|
143
|
+
next unless parsed && parsed[:state] == :open
|
|
144
|
+
next unless parsed[:head_ref] == head_ref && parsed[:base_ref] == base_ref
|
|
145
|
+
next unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url_of(parsed), head_repository_url)
|
|
146
|
+
|
|
147
|
+
with_head_sha(parsed)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Create a PR via `fj pr create`, reconciling to an exact open match
|
|
152
|
+
# first and proving the resulting head against `expected_head`.
|
|
153
|
+
# `fj` cannot create draft PRs; the receipt reports the provider's
|
|
154
|
+
# real draft state.
|
|
155
|
+
#
|
|
156
|
+
# @return [ProviderMutationReceipt] idempotency :created/:existing
|
|
157
|
+
def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
|
|
158
|
+
# `fj pr create` operates on the checkout's repository and cannot
|
|
159
|
+
# encode an explicit fork head; refusing before any command (even
|
|
160
|
+
# lookups) beats creating the wrong PR.
|
|
161
|
+
unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
162
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
163
|
+
"Forgejo CLI (fj) pr create cannot target head repository " \
|
|
164
|
+
"#{head_repository_url} (resolved server repository: #{server.url}); " \
|
|
165
|
+
"run from the fork checkout or use the forge web UI"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
matches = find_open_pull_requests(
|
|
169
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
170
|
+
base_repository_url: server.url, base_ref: base_ref
|
|
171
|
+
)
|
|
172
|
+
if matches.size > 1
|
|
173
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
174
|
+
"Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
|
|
175
|
+
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
176
|
+
end
|
|
177
|
+
if matches.size == 1
|
|
178
|
+
return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
args = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
|
|
182
|
+
args += ["--body", body] if body
|
|
183
|
+
send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
|
|
184
|
+
|
|
185
|
+
created = reconcile_created(
|
|
186
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
187
|
+
base_ref: base_ref, expected_head: expected_head
|
|
188
|
+
)
|
|
189
|
+
receipt(:create, created, :created)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# `fj pr edit` takes one field subcommand per invocation (title|body),
|
|
193
|
+
# so multi-field updates are separate commands with final evidence
|
|
194
|
+
# fetched only after every field update succeeded.
|
|
195
|
+
#
|
|
196
|
+
# @return [ProviderMutationReceipt] operation :update
|
|
197
|
+
def update_pull_request(number:, expected_head:, title: nil, body: nil)
|
|
198
|
+
verify_expected_head!(fetch_pr(number), expected_head)
|
|
199
|
+
send_mutation(["pr", "edit", number.to_s, "title", title], ambiguous: false) if title
|
|
200
|
+
send_mutation(["pr", "edit", number.to_s, "body", body], ambiguous: false) if body
|
|
201
|
+
receipt(:update, fetch_pr(number), nil)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# `fj` offers no draft-to-ready command; classified as an unsupported
|
|
205
|
+
# capability instead of guessing at provider behavior.
|
|
206
|
+
def ready_pull_request(number:, expected_head:)
|
|
207
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
208
|
+
"Forgejo CLI (fj) cannot mark a draft pull request ready; use the forge web UI"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# `fj pr merge` cannot enforce an expected-head precondition
|
|
212
|
+
# atomically, so merging is refused rather than racing a stale head.
|
|
213
|
+
def merge_pull_request(number:, expected_head:, method:)
|
|
214
|
+
unless %i[squash merge rebase].include?(method)
|
|
215
|
+
raise ArgumentError, "Invalid merge method #{method.inspect}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
219
|
+
"Forgejo CLI (fj) pr merge cannot enforce expected-head (#{expected_head}) " \
|
|
220
|
+
"atomically; refusing unsafe merge"
|
|
221
|
+
end
|
|
222
|
+
|
|
140
223
|
private
|
|
141
224
|
|
|
142
225
|
# Run one `fj` command, classifying every failure with the taxonomy.
|
|
@@ -144,15 +227,95 @@ module Ace
|
|
|
144
227
|
result = CliExecutor.execute(args, timeout: timeout, runner: runner)
|
|
145
228
|
return result[:stdout] if result[:success]
|
|
146
229
|
|
|
147
|
-
|
|
230
|
+
classify_failure(result[:stderr], context: args.join(" "))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Send one mutating `fj` command. When `ambiguous` is true (create),
|
|
234
|
+
# any transport-level failure becomes an unknown outcome: the request
|
|
235
|
+
# may have mutated the forge, so callers must reconcile by exact
|
|
236
|
+
# identity instead of retrying blindly.
|
|
237
|
+
def send_mutation(args, ambiguous:, identity: nil)
|
|
238
|
+
result = CliExecutor.execute(args, timeout: timeout, runner: runner)
|
|
239
|
+
return result if result[:success]
|
|
240
|
+
|
|
241
|
+
classify_failure(result[:stderr], context: args.join(" "))
|
|
242
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
243
|
+
raise e unless ambiguous
|
|
244
|
+
|
|
245
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
246
|
+
"PR create outcome unknown after transport failure (#{e.message}); " \
|
|
247
|
+
"reconcile by exact identity before repeating: #{identity}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def classify_failure(message, context:)
|
|
251
|
+
message = message.to_s
|
|
148
252
|
case message
|
|
149
253
|
when /not found|does not exist|no pull request|no issue/i
|
|
150
|
-
raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{
|
|
254
|
+
raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{context}): #{message}"
|
|
151
255
|
when /access denied|unauthorized|token/i
|
|
152
256
|
raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
|
|
153
257
|
else
|
|
154
|
-
raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{
|
|
258
|
+
raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{context}): #{message}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Fetch one PR as normalized evidence with exact head SHA.
|
|
263
|
+
def fetch_pr(number)
|
|
264
|
+
with_head_sha(view_pr(number))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Complete a parsed PR view with its exact head SHA evidence.
|
|
268
|
+
def with_head_sha(parsed)
|
|
269
|
+
parsed[:head_sha] = head_sha_of(parsed[:number])
|
|
270
|
+
normalize_pr(parsed)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# View one PR and parse its stable minimal-style output.
|
|
274
|
+
def view_pr(number)
|
|
275
|
+
view = fj(["--style", "minimal", "pr", "view", number.to_s])
|
|
276
|
+
Parsers.parse_pr_view(view) ||
|
|
277
|
+
raise(Ace::Git::ProviderMalformedOutputError,
|
|
278
|
+
"Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Re-fetch and verify the PR created by an accepted create request.
|
|
282
|
+
def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
|
|
283
|
+
matches = find_open_pull_requests(
|
|
284
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
285
|
+
base_repository_url: server.url, base_ref: base_ref
|
|
286
|
+
)
|
|
287
|
+
if matches.empty?
|
|
288
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
289
|
+
"PR create accepted but no open pull request matches " \
|
|
290
|
+
"#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
|
|
291
|
+
"reconcile by exact identity before repeating"
|
|
155
292
|
end
|
|
293
|
+
if matches.size > 1
|
|
294
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
295
|
+
"Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
|
|
296
|
+
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
verify_expected_head!(matches.first, expected_head)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def require_base_on_server!(base_repository_url)
|
|
303
|
+
return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
|
|
304
|
+
|
|
305
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
306
|
+
"Forgejo provider operates on the resolved server repository (#{server.url}); " \
|
|
307
|
+
"refusing base repository #{base_repository_url}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def identity_text(head_repository_url, head_ref, base_ref)
|
|
311
|
+
"#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def receipt(operation, pull_request, idempotency)
|
|
315
|
+
Ace::Git::ProviderMutationReceipt.new(
|
|
316
|
+
server_name: server.name, operation: operation,
|
|
317
|
+
pull_request: pull_request, idempotency: idempotency
|
|
318
|
+
)
|
|
156
319
|
end
|
|
157
320
|
|
|
158
321
|
def head_sha_of(number)
|
|
@@ -172,10 +335,30 @@ module Ace
|
|
|
172
335
|
author: parsed[:author],
|
|
173
336
|
url: pr_url(parsed[:number]),
|
|
174
337
|
draft: nil,
|
|
175
|
-
merged_at: nil
|
|
338
|
+
merged_at: nil,
|
|
339
|
+
head_repository_url: head_repository_url_of(parsed),
|
|
340
|
+
base_repository_url: server.url,
|
|
341
|
+
merge_commit_sha: nil
|
|
176
342
|
)
|
|
177
343
|
end
|
|
178
344
|
|
|
345
|
+
# Source repository URL for the PR head: forks are reported by the
|
|
346
|
+
# `From `owner/repo:branch`` segment and live on the same host as the
|
|
347
|
+
# base repository; canonical PRs use the configured repository.
|
|
348
|
+
def head_repository_url_of(parsed)
|
|
349
|
+
repo = parsed[:head_repository]
|
|
350
|
+
return server.url if repo.nil? || repo.empty?
|
|
351
|
+
|
|
352
|
+
"#{server_host_root}/#{repo}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def server_host_root
|
|
356
|
+
@server_host_root ||= begin
|
|
357
|
+
uri = URI.parse(server.url.to_s)
|
|
358
|
+
"#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
179
362
|
def normalize_search_entry(entry)
|
|
180
363
|
Ace::Git::ProviderPullRequest.new(
|
|
181
364
|
server_name: server.name,
|
|
@@ -188,7 +371,10 @@ module Ace
|
|
|
188
371
|
author: entry[:author],
|
|
189
372
|
url: pr_url(entry[:number]),
|
|
190
373
|
draft: nil,
|
|
191
|
-
merged_at: nil
|
|
374
|
+
merged_at: nil,
|
|
375
|
+
head_repository_url: nil,
|
|
376
|
+
base_repository_url: nil,
|
|
377
|
+
merge_commit_sha: nil
|
|
192
378
|
)
|
|
193
379
|
end
|
|
194
380
|
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|