ace-git-forgejo 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f652620ca5f68a59c0809d789d57b13fbcebda51a12d93863f24359bd316ad2b
4
- data.tar.gz: 843e555799aac783ae018d8676314a6f3681f4836854a085a0c2d2232b10183c
3
+ metadata.gz: 7f71ec19c2631e884747c20ba07ad19b595fff3ad52c4505571d3f91da61f2ff
4
+ data.tar.gz: ca8d1ff36047ef554da1fa8b23285b7fb01d486b35bde3321b03d199ba92ffb0
5
5
  SHA512:
6
- metadata.gz: 58efabf1f68159415d2f0908cdb11f5a0bd15e32e3e299ada2049d12af79b0794d8090ca26098f0f6dedfc5b6f8da800a9071bfdac7afacb58038f44faeb5287
7
- data.tar.gz: 6ac53befa67d15633f0fc19c4d431a65d8357cdd96051b16295a927c5de09f434a1b667bfdfe2aa0148429d2205db5651a12569511171df07c78bfe19eeabfab
6
+ metadata.gz: 6cddccbd90c235fe84a052c58dfe3030a6c3b826527da34681d4316579c46e798bc1ca212ee438f5020be3d47ac77d51fc99729ca2805d1f7c3e3f0c0046b30a
7
+ data.tar.gz: 621fa2ebff13f4e344dba8500ad40b2e41604a97c39282979424d9eaf942c32c2f266be1f3aaa74037f1244924dd73518067f74d19a93162d0378f3781dcc487
data/CHANGELOG.md CHANGED
@@ -5,8 +5,39 @@ All notable changes to ace-git-forgejo will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
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
+
8
38
  ## [0.1.1] - 2026-09-28
9
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
- # @return [Hash, nil] {number:, title:, author:, state:, head_ref:, base_ref:}
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(/\AFrom\s+`(?:[^`:]+:)?(?<head>[^`]+)`\s+into\s+`(?<base>[^`]+)`\z/))
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
- view = fj(["--style", "minimal", "pr", "view", number.to_s])
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
- message = result[:stderr].to_s
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 (#{args.join(" ")}): #{message}"
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 (#{args.join(" ")}): #{message}"
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
 
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.1.1"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
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.1.1
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-28 00:00:00.000000000 Z
10
+ date: 2026-09-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git