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.
@@ -2,24 +2,36 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require "ace/git/atoms/server_url"
5
6
  require_relative "cli_executor"
7
+ require_relative "repository_binding"
6
8
  require_relative "parsers"
9
+ require_relative "issue_api"
10
+ require_relative "pull_request_api"
7
11
 
8
12
  module Ace
9
13
  module Git
10
14
  module Forgejo
11
15
  # Forgejo provider implementation of the shared provider contract.
12
16
  #
13
- # Owns Forgejo terminology and translates `fj` responses into the
14
- # normalized evidence types defined by the ace-git core. Every
15
- # repository-scoped call is bound to the selected server's exact
16
- # host/owner/repository through the {CliExecutor} repository boundary:
17
- # the selected identity is mandatory, unobserved operations refuse
18
- # before launch, and returned evidence is checked for conflicting
19
- # identity. Every failure is classified with the shared taxonomy;
20
- # `fj` failures are never hidden behind ad-hoc curl calls or silent
21
- # fallbacks.
17
+ # Owns Forgejo terminology and translates `fj` and Forgejo API v1
18
+ # responses into the normalized evidence types defined by the ace-git
19
+ # core. Every repository-scoped call is bound to the selected server's
20
+ # exact host/owner/repository: `fj` commands through the {CliExecutor}
21
+ # repository boundary, API calls through the repository-bound
22
+ # {HttpClient}/{PullRequestApi} routes. The selected identity is
23
+ # mandatory, unobserved operations refuse before launch, and returned
24
+ # evidence is checked for conflicting identity. Every failure is
25
+ # classified with the shared taxonomy; provider failures are never
26
+ # hidden behind ad-hoc curl calls or silent fallbacks.
22
27
  class Provider < Ace::Git::Providers::Base
28
+ # Fill color for repo labels created on demand by issue linking.
29
+ TRACKED_LABEL_COLOR = "0e8a16"
30
+
31
+ # Forgejo's default work-in-progress title prefixes; the server
32
+ # derives its API `draft` field from them (case-insensitive).
33
+ WIP_PREFIXES = ["WIP:", "[WIP]"].freeze
34
+
23
35
  class << self
24
36
  def display_name
25
37
  "Forgejo"
@@ -47,12 +59,18 @@ module Ace
47
59
  CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
48
60
  end
49
61
 
62
+ # Authoritative pull request read. One API payload binds the exact
63
+ # head repository/ref/SHA, draft state, and merge evidence that the
64
+ # fj surface cannot report (no draft field, head SHA only via a
65
+ # second command); contradictory or missing identity fails closed.
66
+ #
50
67
  # @return [ProviderPullRequest] normalized pull request evidence
51
- # @raise [ProviderObjectNotFoundError] when the PR does not exist
52
- # @raise [ProviderMalformedOutputError] when `fj` output is unexpected
53
- # @raise [ProviderUnreachableError] when the endpoint is unreachable
68
+ # @raise [Ace::Git::ProviderObjectNotFoundError] when the PR does not exist
69
+ # @raise [Ace::Git::ProviderMalformedOutputError] when the payload is unexpected
70
+ # @raise [Ace::Git::ProviderUnreachableError] when the endpoint is unreachable
54
71
  def pull_request(number:)
55
- fetch_pr(number)
72
+ number = request_number!(number)
73
+ normalize_api_pr(pr_api.pull_request(number))
56
74
  end
57
75
 
58
76
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
@@ -109,13 +127,94 @@ module Ace
109
127
  )
110
128
  end
111
129
 
130
+ def issue_tracking(number:)
131
+ data = issue_api.issue(request_number!(number))
132
+ unless data.is_a?(Hash) && data["number"].to_i == number.to_i &&
133
+ data["html_url"].to_s == issue_url(number.to_i)
134
+ raise Ace::Git::ProviderIdentityMismatchError, "Issue ##{number} is not in #{server.url}"
135
+ end
136
+ evidence = Ace::Git::ProviderIssue.new(
137
+ server_name: server.name, number: data["number"], title: data["title"],
138
+ state: normalize_state(data["state"]),
139
+ author: data.dig("user", "login"), url: data["html_url"],
140
+ labels: Array(data["labels"]).map { |label| label["name"] }
141
+ )
142
+ {issue: evidence,
143
+ comments: Array(issue_api.comments(number)).map { |c| {id: c.fetch("id"), body: c["body"].to_s} },
144
+ labels: evidence.labels}
145
+ end
146
+
147
+ # Unrecognized or missing state is malformed evidence: mapping it to
148
+ # :closed would let sync close an issue without valid state proof.
149
+ def normalize_state(value)
150
+ case value.to_s
151
+ when "open" then :open
152
+ when "closed" then :closed
153
+ else raise Ace::Git::ProviderMalformedOutputError, "Unknown issue state #{value.inspect}"
154
+ end
155
+ end
156
+
157
+ def create_issue_comment(number:, body:)
158
+ issue_api.create_comment(request_number!(number), body)
159
+ end
160
+
161
+ def update_issue_comment(number:, comment_id:, body:)
162
+ assert_issue_comment_owned!(number, comment_id)
163
+ issue_api.update_comment(Integer(comment_id), body)
164
+ end
165
+
166
+ def delete_issue_comment(number:, comment_id:)
167
+ assert_issue_comment_owned!(number, comment_id)
168
+ issue_api.delete_comment(Integer(comment_id))
169
+ end
170
+
171
+ def add_issue_label(number:, label:)
172
+ issue_api.add_label(request_number!(number), ensure_repo_label_id(label))
173
+ end
174
+
175
+ def remove_issue_label(number:, label:)
176
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
177
+ return unless match
178
+
179
+ issue_api.remove_label(request_number!(number), match.fetch("id"))
180
+ end
181
+
182
+ # Fresh repositories lack the tracking label; creating it on demand
183
+ # keeps issue linking from committing a tracking comment it could
184
+ # never label. A concurrent creator surfaces as an uncertain mutation
185
+ # outcome, which only means re-list and attach what appeared.
186
+ def ensure_repo_label_id(label)
187
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
188
+ return match["id"] if match && match["id"].to_i.positive?
189
+
190
+ begin
191
+ issue_api.create_repo_label(name: label, color: TRACKED_LABEL_COLOR)
192
+ rescue Ace::Git::ProviderUnknownOutcomeError
193
+ nil
194
+ end
195
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
196
+ unless match && match["id"].to_i.positive?
197
+ raise Ace::Git::ProviderObjectNotFoundError,
198
+ "Forgejo label #{label.inspect} could not be created or found on #{server.url}"
199
+ end
200
+ match["id"]
201
+ end
202
+
203
+ def set_issue_state(number:, state:)
204
+ raise ArgumentError, "Invalid issue state #{state.inspect}" unless %i[open closed].include?(state)
205
+
206
+ issue_api.set_state(request_number!(number), state)
207
+ end
208
+
112
209
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
113
210
  def checks(ref:)
114
211
  tasks = fj(:actions_tasks)
115
212
  Parsers.parse_actions_tasks(tasks)
116
- # Exact-head evidence requires the complete 40-character SHA
117
- # equal to the reviewed head; prefix matches are rejected.
118
- .select { |task| task[:sha] == ref.to_s }
213
+ # Exact-head evidence binds each task to the reviewed head by SHA
214
+ # prefix: a full or abbreviated SHA that prefixes the reviewed
215
+ # head identifies that commit, and anything else is another
216
+ # head's task. No matching evidence is silently dropped.
217
+ .select { |task| ref.to_s.downcase.start_with?(task[:sha].downcase) }
119
218
  .map do |task|
120
219
  Ace::Git::ProviderCheck.new(
121
220
  server_name: server.name,
@@ -125,6 +224,13 @@ module Ace
125
224
  url: nil
126
225
  )
127
226
  end
227
+ rescue Ace::Git::ProviderObjectNotFoundError
228
+ # A repository with Actions disabled serves no tasks endpoint. The
229
+ # review flows resolve the repository binding and the PR before
230
+ # collecting checks, so a not-found here means CI is unavailable,
231
+ # not a missing repository; empty check evidence keeps the review
232
+ # collectable. Parse and transport failures still fail closed.
233
+ []
128
234
  end
129
235
 
130
236
  # @return [ProviderRepository] normalized repository evidence
@@ -160,7 +266,7 @@ module Ace
160
266
  number = request_number!(number)
161
267
  verify_expected_head!(pull_request(number: number), expected_head)
162
268
  comments = review_http.paginate("issues/#{number}/comments").map do |entry|
163
- review_comment(entry, number, expected_head)
269
+ review_comment(entry, number)
164
270
  end
165
271
  reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
166
272
  review_entry(entry, number, expected_head)
@@ -174,7 +280,7 @@ module Ace
174
280
  unless entries.is_a?(Array)
175
281
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
176
282
  end
177
- entries.map { |entry| review_comment(entry, number, expected_head) }
283
+ entries.map { |entry| review_comment(entry, number) }
178
284
  end
179
285
  verify_expected_head!(pull_request(number: number), expected_head)
180
286
  Ace::Git::ProviderReviewEvidence.new(
@@ -230,7 +336,7 @@ module Ace
230
336
  require_open_pr!(pr)
231
337
  marker = comment_marker(correlation)
232
338
  with_session_lock(correlation) do
233
- existing = matching_review_comments(number, marker, expected_head)
339
+ existing = matching_review_comments(number, marker)
234
340
  if existing.length > 1
235
341
  raise Ace::Git::ProviderConflictingMatchesError,
236
342
  "Multiple comments match review session #{correlation} on PR ##{number}"
@@ -260,8 +366,8 @@ module Ace
260
366
  "session #{correlation}: #{e.message}; reconcile before repeating"
261
367
  end
262
368
  begin
263
- matches = matching_review_comments(number, marker, expected_head)
264
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
369
+ matches = matching_review_comments(number, marker)
370
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
265
371
  raise Ace::Git::ProviderUnknownOutcomeError,
266
372
  "PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
267
373
  end
@@ -293,7 +399,7 @@ module Ace
293
399
  raise Ace::Git::ProviderIdentityMismatchError,
294
400
  "Comment #{comment_id} does not belong to selected PR ##{number}"
295
401
  end
296
- comment = review_comment(matches.first, number, expected_head)
402
+ comment = review_comment(matches.first, number)
297
403
  if comment.body == body
298
404
  verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
299
405
  return review_mutation(number, expected_head, comment, :existing)
@@ -314,7 +420,7 @@ module Ace
314
420
  # Fetch individually and validate the Forgejo issue identity.
315
421
  begin
316
422
  updated = review_http.request(:get, update_route)
317
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
423
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
318
424
  raise Ace::Git::ProviderUnknownOutcomeError,
319
425
  "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
320
426
  end
@@ -331,7 +437,7 @@ module Ace
331
437
  "Comment update sent but exact PR comment #{comment_id} could not be verified"
332
438
  end
333
439
  verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
334
- review_mutation(number, expected_head, review_comment(updated, number, expected_head), :updated)
440
+ review_mutation(number, expected_head, review_comment(updated, number), :updated)
335
441
  end
336
442
 
337
443
  def resolve_pull_request_thread(number:, expected_head:, thread_id:)
@@ -340,39 +446,38 @@ module Ace
340
446
  end
341
447
 
342
448
  # ---- PR lifecycle mutations ----
449
+ #
450
+ # Delivery-capable lifecycle on the documented Forgejo API v1
451
+ # (capability evidence in the task report): canonical and
452
+ # same-server fork heads via the "owner:branch" head form, draft
453
+ # state via the WIP title convention the server itself derives
454
+ # draft from, and merges guarded by the server-enforced
455
+ # `head_commit_id` precondition.
343
456
 
344
457
  # @return [Array<ProviderPullRequest>] open PRs matching the exact
345
- # base/head identity, with head SHA provenance
458
+ # base/head identity, with head SHA provenance from the API list
346
459
  def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
347
460
  require_base_on_server!(base_repository_url)
348
- listing = fj(:pr_search, "open")
349
- Parsers.parse_search(listing).filter_map do |entry|
350
- parsed = view_pr(entry[:number])
351
- next unless parsed && parsed[:state] == :open
352
- next unless parsed[:head_ref] == head_ref && parsed[:base_ref] == base_ref
353
- next unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url_of(parsed), head_repository_url)
354
-
355
- with_head_sha(parsed)
461
+ # An absent head repository names the base repository itself,
462
+ # mirroring the lifecycle organism's default.
463
+ head_repository_url = server.url if head_repository_url.to_s.empty?
464
+ pr_api.open_pull_requests.filter_map do |payload|
465
+ next unless payload.dig("head", "ref") == head_ref && payload.dig("base", "ref") == base_ref
466
+ next unless Ace::Git::Atoms::ServerUrl.match?(api_head_repository_url(payload), head_repository_url)
467
+
468
+ normalize_api_pr(payload)
356
469
  end
357
470
  end
358
471
 
359
- # Create a PR via `fj pr create`, reconciling to an exact open match
360
- # first and proving the resulting head against `expected_head`.
361
- # `fj` cannot encode an explicit fork head; the receipt reports the
362
- # provider's real draft state.
472
+ # Create (or reconcile to) a pull request for the exact identity.
473
+ # Same-server fork sources are encoded with the documented
474
+ # "owner:branch" API form; cross-host sources are refused before
475
+ # any request. The requested draft state is preserved exactly, and
476
+ # an existing exact match is reusable only when its draft state
477
+ # agrees; a duplicate-create race reconciles to the winner.
363
478
  #
364
479
  # @return [ProviderMutationReceipt] idempotency :created/:existing
365
480
  def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
366
- # `fj pr create -r` binds the base repository, but no observed form
367
- # encodes a cross-repository fork head; refusing before any command
368
- # (even lookups) beats creating the wrong PR.
369
- unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
370
- raise Ace::Git::ProviderUnsupportedCapabilityError,
371
- "Forgejo CLI (fj) pr create cannot target head repository " \
372
- "#{head_repository_url} (resolved server repository: #{server.url}); " \
373
- "run from the fork checkout or use the forge web UI"
374
- end
375
-
376
481
  matches = find_open_pull_requests(
377
482
  head_repository_url: head_repository_url, head_ref: head_ref,
378
483
  base_repository_url: server.url, base_ref: base_ref
@@ -383,53 +488,363 @@ module Ace
383
488
  "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
384
489
  end
385
490
  if matches.size == 1
386
- return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
491
+ existing = verify_expected_head!(matches.first, expected_head)
492
+ ensure_draft_agreement!(existing, draft)
493
+ return receipt(:create, existing, :existing)
387
494
  end
388
495
 
389
- send_mutation(:pr_create, title, head_ref, base_ref, body,
390
- ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
496
+ head_arg = api_head_argument!(head_repository_url, head_ref)
497
+ send_title = draft_title!(title, draft)
498
+ pr_api.ensure_version_supported!
499
+ outcome = send_lifecycle_mutation(identity_text(head_repository_url, head_ref, base_ref)) do
500
+ pr_api.create_pull_request(head: head_arg, base: base_ref, title: send_title, body: body)
501
+ end
391
502
 
392
- created = reconcile_created(
393
- head_repository_url: head_repository_url, head_ref: head_ref,
394
- base_ref: base_ref, expected_head: expected_head
395
- )
503
+ if outcome.status == 409
504
+ # A concurrent create won the race: the exact match is
505
+ # authoritative, and repeating the send would never be ours.
506
+ return reconcile_raced_create(head_repository_url: head_repository_url, head_ref: head_ref,
507
+ base_ref: base_ref, expected_head: expected_head, draft: draft)
508
+ end
509
+ unless outcome.status == 201 && outcome.payload.is_a?(Hash) && outcome.payload["number"].is_a?(Integer)
510
+ classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
511
+ end
512
+
513
+ created = verify_expected_head!(pull_request(number: outcome.payload["number"]), expected_head)
514
+ ensure_draft_outcome!(created, draft)
396
515
  receipt(:create, created, :created)
397
516
  end
398
517
 
399
- # `fj pr edit` takes one field subcommand per invocation (title|body),
400
- # so multi-field updates are separate commands with final evidence
401
- # fetched only after every field update succeeded.
518
+ # Edit title/body after exact-head verification through the same
519
+ # API mutation path as the rest of the lifecycle.
402
520
  #
403
521
  # @return [ProviderMutationReceipt] operation :update
404
522
  def update_pull_request(number:, expected_head:, title: nil, body: nil)
405
523
  number = request_number!(number)
406
- verify_expected_head!(fetch_pr(number), expected_head)
407
- send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
408
- send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
409
- receipt(:update, fetch_pr(number), nil)
524
+ verify_expected_head!(pull_request(number: number), expected_head)
525
+ if title || body
526
+ pr_api.ensure_version_supported!
527
+ outcome = send_lifecycle_mutation("PR ##{number} on #{server.name}, head #{expected_head}") do
528
+ pr_api.edit_pull_request(number, title: title, body: body)
529
+ end
530
+ unless (200..299).cover?(outcome.status)
531
+ raise Ace::Git::ProviderUnknownOutcomeError,
532
+ "PR update refused for ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
533
+ "reconcile before repeating"
534
+ end
535
+ verify_post_mutation_head!(number, expected_head, "update")
536
+ end
537
+ receipt(:update, pull_request(number: number), nil)
410
538
  end
411
539
 
412
- # `fj` offers no draft-to-ready command; classified as an unsupported
413
- # capability instead of guessing at provider behavior.
540
+ # Mark a draft ready on the exact head. Forgejo derives draft state
541
+ # from the WIP title prefix, so the transition is the documented
542
+ # title edit with the prefix removed; the resulting ready state and
543
+ # unchanged head are proven by an authoritative read-back.
544
+ #
545
+ # @return [ProviderMutationReceipt] operation :ready
414
546
  def ready_pull_request(number:, expected_head:)
415
- raise Ace::Git::ProviderUnsupportedCapabilityError,
416
- "Forgejo CLI (fj) cannot mark a draft pull request ready; use the forge web UI"
547
+ number = request_number!(number)
548
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
549
+ unless pr.state == :open
550
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
551
+ "Cannot mark PR ##{number} ready in #{pr.state} state"
552
+ end
553
+ # Already-ready at the same head is idempotent.
554
+ return receipt(:ready, pr, nil) unless pr.draft
555
+
556
+ # Refuse an impossible transition before any server traffic; the
557
+ # capability gate sits directly in front of the mutation.
558
+ stripped_title = strip_wip_prefix!(pr.title, number)
559
+ pr_api.ensure_version_supported!
560
+ outcome = send_lifecycle_mutation("ready transition for PR ##{number} on #{server.name}, head #{expected_head}") do
561
+ pr_api.edit_pull_request(number, title: stripped_title)
562
+ end
563
+ unless (200..299).cover?(outcome.status)
564
+ raise Ace::Git::ProviderUnknownOutcomeError,
565
+ "Ready transition refused for PR ##{number} (HTTP #{outcome.status}: #{outcome.message}); " \
566
+ "reconcile before repeating"
567
+ end
568
+
569
+ after = verify_post_mutation_head!(number, expected_head, "ready")
570
+ unless after.draft == false
571
+ raise Ace::Git::ProviderUnknownOutcomeError,
572
+ "Ready title applied but PR ##{number} still reports draft on #{server.name}; " \
573
+ "reconcile before repeating"
574
+ end
575
+ receipt(:ready, after, nil)
417
576
  end
418
577
 
419
- # `fj pr merge` cannot enforce an expected-head precondition
420
- # atomically, so merging is refused rather than racing a stale head.
578
+ # Merge with the server-enforced atomic precondition: Forgejo
579
+ # re-resolves the head ref inside the merge and refuses with 409
580
+ # when it is not exactly `head_commit_id`. There is deliberately no
581
+ # pre-read fallback guard. Success (and a refused race that turns
582
+ # out already merged) is proven by merged state plus the merge
583
+ # commit on the selected PR at the expected source SHA.
584
+ #
585
+ # @return [ProviderMutationReceipt] operation :merge
421
586
  def merge_pull_request(number:, expected_head:, method:)
422
587
  unless %i[squash merge rebase].include?(method)
423
588
  raise ArgumentError, "Invalid merge method #{method.inspect}"
424
589
  end
590
+ number = request_number!(number)
591
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
592
+ case pr.state
593
+ when :open
594
+ nil # proceed to the guarded merge
595
+ when :merged
596
+ # Already merged at the expected source SHA: authoritative
597
+ # evidence is reusable without another mutation attempt.
598
+ prove_merged!(pr, expected_head)
599
+ return receipt(:merge, pr, nil)
600
+ else
601
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
602
+ "Cannot merge PR ##{number} in #{pr.state} state"
603
+ end
425
604
 
426
- raise Ace::Git::ProviderUnsupportedCapabilityError,
427
- "Forgejo CLI (fj) pr merge cannot enforce expected-head (#{expected_head}) " \
428
- "atomically; refusing unsafe merge"
605
+ pr_api.ensure_version_supported!
606
+ outcome = send_lifecycle_mutation("merge of PR ##{number} on #{server.name}, head #{expected_head}") do
607
+ pr_api.merge_pull_request(number, do_method: method.to_s, head_commit_id: expected_head)
608
+ end
609
+
610
+ case outcome.status
611
+ when 200..299
612
+ after = verify_post_mutation_head!(number, expected_head, "merge")
613
+ prove_merged!(after, expected_head)
614
+ receipt(:merge, after, nil)
615
+ when 409
616
+ if outcome.message.to_s.match?(/head out of date/i)
617
+ raise Ace::Git::ProviderExpectedHeadConflictError,
618
+ "Forgejo refused the merge of PR ##{number}: the head moved past #{expected_head} " \
619
+ "before the server-side expected-head check (#{outcome.message})"
620
+ end
621
+ reconcile_refused_merge(number, expected_head, outcome)
622
+ when 405
623
+ if outcome.message.to_s.match?(/try again later/i)
624
+ # The server's mergeable state was still computing; the merge
625
+ # was not applied. The caller may retry the same command.
626
+ raise Ace::Git::ProviderUnreachableError,
627
+ "Forgejo mergeable state for PR ##{number} is still computing " \
628
+ "(HTTP 405: #{outcome.message}); the merge was not applied and the " \
629
+ "command may be repeated"
630
+ end
631
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
632
+ "Forgejo refused to merge PR ##{number} as #{method}: #{outcome.message || "merge style unavailable"}"
633
+ when 404
634
+ raise Ace::Git::ProviderObjectNotFoundError, "Forgejo pull request ##{number} not found"
635
+ when 422
636
+ raise Ace::Git::ProviderIdentityMismatchError,
637
+ "Forgejo rejected the merge request for PR ##{number} (#{outcome.message})"
638
+ end
429
639
  end
430
640
 
431
641
  private
432
642
 
643
+ # Send one lifecycle mutation: a transport-level failure after the
644
+ # send stays an unknown outcome and gains the exact reconciliation
645
+ # identity; the mutation is never retried inside the provider.
646
+ def send_lifecycle_mutation(identity)
647
+ yield
648
+ rescue Ace::Git::ProviderUnknownOutcomeError => e
649
+ raise Ace::Git::ProviderUnknownOutcomeError,
650
+ "#{e.message}; reconcile by exact identity before repeating: #{identity}"
651
+ end
652
+
653
+ # Server-derived draft state must agree with the request for an
654
+ # existing exact match to be reusable.
655
+ def ensure_draft_agreement!(existing, draft)
656
+ return if existing.draft == draft
657
+
658
+ raise Ace::Git::ProviderConflictingMatchesError,
659
+ "Open pull request ##{existing.number} matches the exact identity but reports " \
660
+ "draft #{existing.draft.inspect} while draft #{draft.inspect} was requested"
661
+ end
662
+
663
+ # The read-back of an accepted create must prove the requested draft
664
+ # state; anything else is a contradictory outcome, never a receipt.
665
+ def ensure_draft_outcome!(created, draft)
666
+ return if created.draft == draft
667
+
668
+ raise Ace::Git::ProviderUnknownOutcomeError,
669
+ "PR ##{created.number} was created but reports draft #{created.draft.inspect} " \
670
+ "while draft #{draft.inspect} was requested; reconcile before repeating"
671
+ end
672
+
673
+ # Forgejo derives draft state from WIP title prefixes (server
674
+ # defaults, compared case-insensitively without trimming); the API
675
+ # create form has no draft field.
676
+ def draft_title!(title, draft)
677
+ if draft
678
+ work_in_progress_title?(title) ? title : "WIP: #{title}"
679
+ elsif work_in_progress_title?(title)
680
+ raise Ace::Git::ProviderConflictingMatchesError,
681
+ "Requested draft:false conflicts with the WIP-prefixed title #{title.inspect}; " \
682
+ "Forgejo would create the pull request as a draft"
683
+ else
684
+ title
685
+ end
686
+ end
687
+
688
+ def work_in_progress_title?(title)
689
+ WIP_PREFIXES.any? { |prefix| title.to_s.upcase.start_with?(prefix) }
690
+ end
691
+
692
+ # Remove one leading WIP prefix for the ready transition. A draft
693
+ # whose title carries none of the known prefixes means the server
694
+ # uses configured prefixes this provider cannot strip: refusing
695
+ # before the mutation keeps the draft state truthful.
696
+ def strip_wip_prefix!(title, number)
697
+ prefix = WIP_PREFIXES.find { |candidate| title.to_s.upcase.start_with?(candidate) }
698
+ unless prefix
699
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
700
+ "PR ##{number} reports draft but its title carries no known WIP prefix " \
701
+ "(#{WIP_PREFIXES.join(", ")}); the server likely uses configured prefixes " \
702
+ "this provider cannot strip safely"
703
+ end
704
+ title.to_s.slice(prefix.length..).to_s.sub(/\A +/, "")
705
+ end
706
+
707
+ # Same-server fork heads ride the documented "owner:branch" API
708
+ # form. The declared source must resolve to the selected host with
709
+ # an owner path; cross-host sources are refused before any
710
+ # mutation (the exact-match lookup may read first).
711
+ def api_head_argument!(head_repository_url, head_ref)
712
+ return head_ref if head_repository_url.nil? ||
713
+ Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
714
+ unless same_server_authority?(head_repository_url)
715
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
716
+ "PR create cannot target head repository #{head_repository_url} outside the " \
717
+ "selected server #{server.url}; only canonical and same-server fork sources are supported"
718
+ end
719
+
720
+ owner = URI.parse(head_repository_url.to_s).path.to_s.split("/").reject(&:empty?).first
721
+ unless owner
722
+ raise Ace::Git::ProviderIdentityMismatchError,
723
+ "Head repository #{head_repository_url.inspect} has no owner path; " \
724
+ "cannot encode the fork head for #{server.url}"
725
+ end
726
+ "#{owner}:#{head_ref}"
727
+ end
728
+
729
+ def same_server_authority?(url)
730
+ authority = Ace::Git::Atoms::ServerUrl.normalize(url).split("/", 2).first.to_s
731
+ !authority.empty? && authority == selected_server_authority
732
+ end
733
+
734
+ def selected_server_authority
735
+ @selected_server_authority ||= Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2).first.to_s
736
+ end
737
+
738
+ # Head repository URL provenance from an API payload: canonical
739
+ # pulls carry the selected repository; fork pulls carry their own;
740
+ # a deleted fork repository reports none, and that absence stays
741
+ # honest instead of being relabelled.
742
+ def api_head_repository_url(payload)
743
+ full_name = payload.dig("head", "repo", "full_name")
744
+ return nil if !full_name.is_a?(String) || full_name.empty?
745
+
746
+ "#{server_host_root}/#{full_name}"
747
+ end
748
+
749
+ # Authoritative API evidence for one pull request. The base
750
+ # repository must be the selected one: a misrouted payload is never
751
+ # relabelled.
752
+ def normalize_api_pr(payload)
753
+ base_full_name = payload.dig("base", "repo", "full_name").to_s
754
+ unless !base_full_name.empty? && base_full_name.casecmp(repository_target.repo).zero?
755
+ raise Ace::Git::ProviderIdentityMismatchError,
756
+ "Forgejo returned pull request ##{payload["number"]} with base repository " \
757
+ "#{base_full_name.inspect} for selected #{repository_target.repo}; refusing misrouted evidence"
758
+ end
759
+ state = if payload["state"] == "open"
760
+ :open
761
+ elsif payload["merged"] == true
762
+ :merged
763
+ else
764
+ :closed
765
+ end
766
+ merge_commit = payload["merge_commit_sha"]
767
+ Ace::Git::ProviderPullRequest.new(
768
+ server_name: server.name,
769
+ number: payload["number"],
770
+ title: payload["title"],
771
+ body: payload["body"].is_a?(String) ? payload["body"] : nil,
772
+ state: state,
773
+ head_ref: payload.dig("head", "ref"),
774
+ base_ref: payload.dig("base", "ref"),
775
+ head_sha: payload.dig("head", "sha"),
776
+ author: payload.dig("user", "login"),
777
+ url: pr_url(payload["number"]),
778
+ draft: payload["draft"],
779
+ merged_at: payload["merged_at"].is_a?(String) ? payload["merged_at"] : nil,
780
+ head_repository_url: api_head_repository_url(payload),
781
+ base_repository_url: repository_target.url,
782
+ merge_commit_sha: merge_commit.is_a?(String) && !merge_commit.empty? ? merge_commit : nil
783
+ )
784
+ end
785
+
786
+ # A merge receipt requires authoritative merged evidence: the merged
787
+ # state, the merge commit, and the exact source SHA that was merged.
788
+ def prove_merged!(pull_request, expected_head)
789
+ return if pull_request.state == :merged && pull_request.head_sha == expected_head &&
790
+ pull_request.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
791
+
792
+ raise Ace::Git::ProviderUnknownOutcomeError,
793
+ "Merge accepted but PR ##{pull_request.number} does not prove merged state at " \
794
+ "#{expected_head} with a merge commit on #{server.name}; reconcile before repeating"
795
+ end
796
+
797
+ # A merge refusal that is not the stale-head case: reconcile by read
798
+ # before deciding — an authoritative already-merged result at the
799
+ # expected source SHA is reusable; a closed-unmerged pull request is
800
+ # a classified refusal; everything else stays an unknown outcome.
801
+ def reconcile_refused_merge(number, expected_head, outcome)
802
+ after = verify_post_mutation_head!(number, expected_head, "merge reconciliation")
803
+ if after.state == :merged && after.head_sha == expected_head &&
804
+ after.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
805
+ return receipt(:merge, after, nil)
806
+ end
807
+ if after.state == :closed
808
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
809
+ "PR ##{number} is closed without merge; Forgejo refused the merge " \
810
+ "(HTTP #{outcome.status}: #{outcome.message})"
811
+ end
812
+ raise Ace::Git::ProviderUnknownOutcomeError,
813
+ "Merge outcome unknown for #{server.name}/##{number}, head #{expected_head}: " \
814
+ "server returned HTTP #{outcome.status} (#{outcome.message}) and the read-back does not " \
815
+ "prove a merge; reconcile before repeating"
816
+ end
817
+
818
+ # A duplicate-create race: the exact match is the authoritative
819
+ # winner of the concurrent create.
820
+ def reconcile_raced_create(head_repository_url:, head_ref:, base_ref:, expected_head:, draft:)
821
+ matches = find_open_pull_requests(
822
+ head_repository_url: head_repository_url, head_ref: head_ref,
823
+ base_repository_url: server.url, base_ref: base_ref
824
+ )
825
+ if matches.size == 1
826
+ existing = verify_expected_head!(matches.first, expected_head)
827
+ ensure_draft_agreement!(existing, draft)
828
+ return receipt(:create, existing, :existing)
829
+ end
830
+ raise Ace::Git::ProviderUnknownOutcomeError,
831
+ "PR create refused as duplicate but #{matches.size} open pull requests match " \
832
+ "#{identity_text(head_repository_url, head_ref, base_ref)}; reconcile by exact identity before repeating"
833
+ end
834
+
835
+ # A refused create (404/422) mutated nothing: classify the server's
836
+ # own explanation instead of racing a retry.
837
+ def classify_create_refusal(outcome, head_repository_url, head_ref, base_ref)
838
+ error = outcome.status == 404 ? Ace::Git::ProviderObjectNotFoundError : Ace::Git::ProviderIdentityMismatchError
839
+ raise error,
840
+ "Forgejo refused the create of #{identity_text(head_repository_url, head_ref, base_ref)} " \
841
+ "(HTTP #{outcome.status}: #{outcome.message})"
842
+ end
843
+
844
+ def pr_api
845
+ @pr_api ||= PullRequestApi.new(server: server, timeout: timeout, runner: runner)
846
+ end
847
+
433
848
  def require_open_pr!(pr)
434
849
  return if pr.state == :open
435
850
 
@@ -441,24 +856,25 @@ module Ace
441
856
  @review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
442
857
  end
443
858
 
444
- # A mutation receipt may only bind the expected head; if the PR head
445
- # moved during the mutation the outcome stays uncertain.
446
- def verify_post_mutation_head!(number, expected_head, correlation)
447
- current = begin
448
- pull_request(number: number)
449
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
450
- raise Ace::Git::ProviderUnknownOutcomeError,
451
- "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
452
- end
453
- return if current.head_sha == expected_head
859
+ # A mutation receipt may only bind the expected head; if the PR head
860
+ # moved during the mutation the outcome stays uncertain. Returns the
861
+ # authoritative read-back when the head still matches.
862
+ def verify_post_mutation_head!(number, expected_head, correlation)
863
+ current = begin
864
+ pull_request(number: number)
865
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
866
+ raise Ace::Git::ProviderUnknownOutcomeError,
867
+ "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
868
+ end
869
+ return current if current.head_sha == expected_head
454
870
 
455
- raise Ace::Git::ProviderUnknownOutcomeError,
456
- "PR head moved during mutation for #{server.name}/##{number} " \
457
- "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
458
- end
871
+ raise Ace::Git::ProviderUnknownOutcomeError,
872
+ "PR head moved during mutation for #{server.name}/##{number} " \
873
+ "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
874
+ end
459
875
 
460
876
  # Serialize the read/post/reconcile sequence per session so two
461
- # concurrent callers cannot both observe "no marker" and post.
877
+ # concurrent callers cannot both observe "no marker" and post.
462
878
  def with_session_lock(correlation)
463
879
  require "tmpdir"
464
880
  lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
@@ -475,27 +891,31 @@ module Ace
475
891
  "<!-- ace-review-session:#{correlation} -->"
476
892
  end
477
893
 
478
- def matching_review_comments(number, marker, expected_head)
894
+ def matching_review_comments(number, marker)
479
895
  review_http.paginate("issues/#{number}/comments").filter_map do |entry|
480
- next unless entry.is_a?(Hash) && entry["body"].is_a?(String) && entry["body"].include?(marker)
896
+ unless entry.is_a?(Hash) && entry["body"].is_a?(String)
897
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment collection"
898
+ end
899
+ next unless entry["body"].include?(marker)
481
900
 
482
- review_comment(entry, number, expected_head)
901
+ review_comment(entry, number)
483
902
  end
484
903
  end
485
904
 
486
- def review_comment(entry, number, head)
905
+ def review_comment(entry, number)
906
+ user = entry.is_a?(Hash) ? entry["user"] : nil
487
907
  unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
488
- entry.dig("user", "login").is_a?(String)
908
+ user.is_a?(Hash) && user["login"].is_a?(String)
489
909
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
490
910
  end
491
911
  Ace::Git::ProviderReviewComment.new(
492
912
  server_name: server.name, repository_url: server.url, pr_number: number,
493
- id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
913
+ id: entry["id"], author: user["login"], body: entry["body"],
494
914
  url: entry["html_url"], path: entry["path"],
495
915
  # Forgejo supplies position/original_position rather than
496
916
  # line; the positive side position is the normalized line.
497
917
  line: entry["line"] || [entry["position"], entry["original_position"]].compact.reject { |v| !v.is_a?(Integer) || v <= 0 }.first,
498
- head_sha: entry["commit_id"] || head, resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
918
+ head_sha: entry["commit_id"], resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
499
919
  thread_id: nil
500
920
  )
501
921
  end
@@ -512,18 +932,28 @@ module Ace
512
932
  end
513
933
 
514
934
  def review_entry(entry, number, head)
515
- reviewer = entry.dig("user", "login") || entry.dig("team", "name")
516
- unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
935
+ unless entry.is_a?(Hash)
936
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
937
+ end
938
+ user, team = entry["user"], entry["team"]
939
+ unless user.nil? || user.is_a?(Hash)
940
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: user must be an object"
941
+ end
942
+ unless team.nil? || team.is_a?(Hash)
943
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: team must be an object"
944
+ end
945
+ reviewer = user&.[]("login") || team&.[]("name")
946
+ unless entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
517
947
  entry["state"].is_a?(String) && !entry["state"].empty?
518
948
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
519
949
  end
520
950
  Ace::Git::ProviderReview.new(
521
951
  server_name: server.name, repository_url: server.url, pr_number: number,
522
- id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
952
+ id: entry["id"], author: reviewer, body: entry["body"],
523
953
  # Forgejo reports REQUEST_CHANGES; consumers count only the
524
954
  # canonical CHANGES_REQUESTED spelling.
525
955
  state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
526
- url: entry["html_url"], head_sha: entry["commit_id"] || head
956
+ url: entry["html_url"], head_sha: entry["commit_id"]
527
957
  )
528
958
  end
529
959
 
@@ -534,6 +964,17 @@ module Ace
534
964
  )
535
965
  end
536
966
 
967
+ def assert_issue_comment_owned!(number, comment_id)
968
+ return if issue_tracking(number: number)[:comments].any? { |comment| comment[:id].to_i == comment_id.to_i }
969
+
970
+ raise Ace::Git::ProviderIdentityMismatchError,
971
+ "Comment #{comment_id} is not on selected issue ##{number} in #{server.url}"
972
+ end
973
+
974
+ def issue_api
975
+ @issue_api ||= IssueApi.new(server: server, timeout: timeout, runner: runner)
976
+ end
977
+
537
978
  # Validated selected repository identity. Built once; malformed
538
979
  # selections fail as configuration errors before any subprocess.
539
980
  def repository_target
@@ -558,23 +999,7 @@ module Ace
558
999
  classify_failure(result[:stderr], context: operation_context(operation, arguments))
559
1000
  end
560
1001
 
561
- # Send one mutating repository operation. When `ambiguous` is true
562
- # (create), any transport-level failure becomes an unknown outcome:
563
- # the request may have mutated the forge, so callers must reconcile
564
- # by exact identity instead of retrying blindly.
565
- def send_mutation(operation, *arguments, ambiguous:, identity: nil)
566
- result = execute_repository(operation, arguments)
567
- return result if result[:success]
568
-
569
- classify_failure(result[:stderr], context: operation_context(operation, arguments))
570
- rescue Ace::Git::ProviderUnreachableError => e
571
- raise e unless ambiguous
572
-
573
- raise Ace::Git::ProviderUnknownOutcomeError,
574
- "PR create outcome unknown after transport failure (#{e.message}); " \
575
- "reconcile by exact identity before repeating: #{identity}"
576
- end
577
-
1002
+ # Run one repository-scoped `fj` operation on the validated target.
578
1003
  def execute_repository(operation, arguments)
579
1004
  target = repository_target
580
1005
  ensure_observed_version!
@@ -611,17 +1036,6 @@ module Ace
611
1036
  end
612
1037
  end
613
1038
 
614
- # Fetch one PR as normalized evidence with exact head SHA.
615
- def fetch_pr(number)
616
- with_head_sha(view_pr(number))
617
- end
618
-
619
- # Complete a parsed PR view with its exact head SHA evidence.
620
- def with_head_sha(parsed)
621
- parsed[:head_sha] = head_sha_of(parsed[:number])
622
- normalize_pr(parsed)
623
- end
624
-
625
1039
  # View one PR and parse its stable minimal-style output. The
626
1040
  # qualified `owner/repo#N` reference binds the read to the selected
627
1041
  # repository; a returned number that disagrees with the request is a
@@ -635,27 +1049,6 @@ module Ace
635
1049
  parsed
636
1050
  end
637
1051
 
638
- # Re-fetch and verify the PR created by an accepted create request.
639
- def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
640
- matches = find_open_pull_requests(
641
- head_repository_url: head_repository_url, head_ref: head_ref,
642
- base_repository_url: server.url, base_ref: base_ref
643
- )
644
- if matches.empty?
645
- raise Ace::Git::ProviderUnknownOutcomeError,
646
- "PR create accepted but no open pull request matches " \
647
- "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
648
- "reconcile by exact identity before repeating"
649
- end
650
- if matches.size > 1
651
- raise Ace::Git::ProviderConflictingMatchesError,
652
- "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
653
- "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
654
- end
655
-
656
- verify_expected_head!(matches.first, expected_head)
657
- end
658
-
659
1052
  def require_base_on_server!(base_repository_url)
660
1053
  return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
661
1054
 
@@ -788,8 +1181,9 @@ module Ace
788
1181
  end
789
1182
 
790
1183
  def issue_url(number)
791
- base = repository_target.url.to_s.chomp("/")
792
- "#{base}/issues/#{number}"
1184
+ # Identity evidence comes from the web endpoint, not the clone URL
1185
+ # (which may be SSH-style and is not a parseable web URL).
1186
+ "#{Ace::Git::Atoms::ServerUrl.web_base(server.url)}/issues/#{number}"
793
1187
  end
794
1188
  end
795
1189
  end