ace-git-forgejo 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7388b7c2b27b633234131dca7f190a344c7e1f323e23e2e01b532ed3a16407a
4
- data.tar.gz: 3960cdfc787b85500050589ac27b1a8bceb8d3e5cd594eb37db2448d7e314426
3
+ metadata.gz: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
4
+ data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
5
5
  SHA512:
6
- metadata.gz: 77a634e5f5f9de6c004a2585e57379104062f11e6eee34532edb390c8200aeaba6d3fc3433ec21ccc6e6ee1a1a3aca22b47b99e83890982e9f8d4ef7400bd045
7
- data.tar.gz: c13861e169c6bb26db7bde14c400186677f01c70c185bec7d07a374b6fdcde08717b27ef7233900da128072587af39393af5dc0584603085d0c8293eeba9aff1
6
+ metadata.gz: c3d888812cbbec6e223ddfca72514bbee53f3281374825b4147c4f1ebc3fd415528f4fc5c08eafadb6cfed35df6acac4220955f629e15b90dc593d91ceee963c
7
+ data.tar.gz: 9dd8b2e6fed777a0c038b111e448c536c0a960c7a7ff53e6d0d9ca1e200bd083e591cb35bf6d7943ec552e206b006021a7314b9bb615e93a9df1a34e179c1f98
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-04
11
+
12
+ ### Added
13
+ - Complete PR delivery lifecycle over the repository-bound Forgejo API v1: create with canonical and same-server fork heads and exact draft state, mark-ready, and merge with the server-enforced `head_commit_id` expected-head precondition (minimum supported server: Forgejo 8.0 — first line enforcing the merge precondition and reporting the API draft field — probed before mutations).
14
+ - Authoritative pull request reads from the API: exact head provenance, draft state, and merge-commit evidence replace the fj view/commits pair for PR identity.
15
+ - Duplicate-create races reconcile to the exact open match; already-merged refusals are reusable only with authoritative merged evidence at the expected source SHA.
16
+
17
+ ### Changed
18
+ - PR reads, lifecycle matching, and updates ride the Faraday HTTP transport (ADR-010) with retries limited to safe GETs and no redirect following; lifecycle mutations are gated on a documented server capability floor and fail closed without it.
19
+
20
+ ### Removed
21
+ - The fj-era refusals for fork creation, draft-to-ready, and expected-head merging; the conservative `fj pr merge`/`pr edit` paths are replaced by the API lifecycle.
22
+
10
23
  ## [0.5.0] - 2026-10-03
11
24
 
12
25
  ### Added
@@ -75,19 +88,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
75
88
  precondition atomically, so both refuse instead of racing.
76
89
  - PR view parser captures the fork repository prefix of the `From` segment.
77
90
 
78
- ### Added
79
- - PR lifecycle mutations matching the shared provider contract: exact-match
80
- lookup via `fj pr search`, idempotent create reconciliation, head-verified
81
- title/body updates.
82
- - Fork provenance: the `fj pr view` head-repository segment is parsed and
83
- returned in normalized PR evidence.
84
-
85
- ### Changed
86
- - `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
87
- offers no draft-to-ready command and cannot enforce an expected-head merge
88
- precondition atomically, so both refuse instead of racing.
89
- - PR view parser captures the fork repository prefix of the `From` segment.
90
-
91
91
  ## [0.1.1] - 2026-09-28
92
92
 
93
93
 
data/README.md CHANGED
@@ -36,16 +36,15 @@ forbidden; there is no silent fallback of any kind.
36
36
 
37
37
  ## Supported operations (observed on forgejo-cli v0.6.0)
38
38
 
39
- PR view/diff/head-commits, PR search (all/open, client-side newest-first
40
- window — `fj` has no `--limit`), PR create (same-repository head only),
41
- PR edit title/body, issue view, actions tasks, repository view, version
42
- and `auth list` probes. `fj` v0.6.0 exposes no draft-to-ready command, no
43
- atomic expected-head merge, and no authoritative merge-commit field, so
44
- `ready_pull_request`/`merge_pull_request` refuse as unsupported
45
- capabilities and `merge_commit_sha` stays empty — worktree cleanup
46
- conservatively retains Forgejo checkouts until `fj` exposes that field.
47
- The capability evidence and its provenance live in the
48
- `8wr.t.uj0` task folder (`evidence/fj-capabilities.md`).
39
+ Through `fj` (observed on forgejo-cli v0.6.0): PR view/diff/head-commits,
40
+ PR search (all/open, client-side newest-first window — `fj` has no
41
+ `--limit`), issue view, actions tasks, repository view, version and
42
+ `auth list` probes. Through the repository-bound API v1: authoritative PR
43
+ reads, PR create (canonical and same-server fork heads, exact draft
44
+ state), edit, ready transitions, and expected-head-guarded merges — see
45
+ the delivery lifecycle section below. The per-surface capability evidence
46
+ and its provenance live in the `8wr.t.uj0` (`evidence/fj-capabilities.md`)
47
+ and `8x2.t.z78` (`capability-evidence-2026-10-04.md`) task folders.
49
48
 
50
49
  Endpoint fidelity: `-H` carries the selected `scheme://authority` (fj
51
50
  otherwise assumes HTTPS), and because fj v0.6.0 silently applies its
@@ -54,3 +53,46 @@ observed keys-file locations and refuses a repository command when an
54
53
  alias redirects the selected host — never modifying user fj
55
54
  configuration; an absent or unreadable keys file is allowed.
56
55
 
56
+ ## PR delivery lifecycle (Forgejo API v1)
57
+
58
+ The delivery lifecycle — create (canonical and same-server fork sources,
59
+ exact draft state), mark-ready, and merge with a server-enforced
60
+ expected-head precondition — rides the repository-bound Forgejo REST API
61
+ v1 through the same selected host and `fj` token as the review transport.
62
+ The `fj` v0.6 CLI cannot encode a fork head, an explicit draft state, a
63
+ ready transition, or an atomic merge precondition, so those operations do
64
+ not approximate it with check-then-act sequencing.
65
+
66
+ Minimum supported server: **Forgejo 8.0** (the first line that both
67
+ enforces the documented `MergePullRequestOption.head_commit_id` merge
68
+ precondition server-side and reports the API `draft` field; the provider
69
+ probes `/api/v1/version` and refuses lifecycle mutations below the
70
+ floor). Official capability evidence lives in the
71
+ `8x2.t.z78` task folder (`capability-evidence-2026-10-04.md`).
72
+
73
+ Forgejo-specific conventions the provider translates:
74
+
75
+ - **Drafts**: the create/edit API forms have no writable draft field —
76
+ a draft is a WIP-prefixed title (server defaults `WIP:`, `[WIP]`;
77
+ compared case-insensitively). The server reports the computed boolean
78
+ in the API `draft` response field, which this provider treats as the
79
+ only draft truth on reads. Create with
80
+ `draft: true` prefixes the title `WIP: `; `draft: false` refuses a
81
+ WIP-prefixed title instead of silently publishing it as a draft. Ready
82
+ strips one leading prefix and proves the resulting `draft: false` by
83
+ read-back. A draft whose title carries no known prefix refuses before
84
+ the mutation (the server likely uses configured prefixes).
85
+ - **Merge** sends the full expected SHA in `head_commit_id`; Forgejo
86
+ re-resolves the head ref inside the merge and refuses with 409 when it
87
+ moved. The provider classifies that as
88
+ `ProviderExpectedHeadConflictError` and never pre-reads as a substitute
89
+ guard. A 409 that reads back as already merged at the expected source
90
+ SHA, with a merge commit, is reusable authoritative evidence.
91
+ - **Reconciliation**: a transport failure on any mutation raises
92
+ `ProviderUnknownOutcomeError` carrying the exact identity; nothing is
93
+ retried automatically. A duplicate-create 409 reconciles to the exact
94
+ open match; mutation outcomes are proven by authoritative read-backs
95
+ (head unchanged, requested state, merged evidence).
96
+
97
+ Identical lifecycle assertions run against every provider through the
98
+ shared `Ace::TestSupport::PullRequestLifecycleContract` parity suite.
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "digest"
4
4
  require "json"
5
- require "net/http"
5
+ require "faraday"
6
+ require "faraday/retry"
6
7
  require "openssl"
7
- require "uri"
8
8
  require_relative "repository_binding"
9
9
 
10
10
  module Ace
@@ -12,6 +12,13 @@ module Ace
12
12
  module Forgejo
13
13
  # Authenticated, repository-bound JSON transport for Forgejo API
14
14
  # capabilities whose fj v0.6 output omits stable object IDs.
15
+ #
16
+ # Live traffic uses Faraday (ADR-010): no redirect middleware is
17
+ # installed (a redirect must never carry credentials or a write to
18
+ # another host; the configured endpoint is verified up front by
19
+ # {RepositoryBinding.reject_selected_host_redirect!}), and retries are
20
+ # limited to the safe GET verb — a mutation is sent at most once per
21
+ # call, so a lost response stays an unknown outcome.
15
22
  class HttpClient
16
23
  def initialize(server:, timeout:, runner: nil)
17
24
  @target = RepositoryBinding::Target.resolve(server.url)
@@ -44,40 +51,97 @@ module Ace
44
51
  entries
45
52
  end
46
53
 
54
+ # Send one request and classify every non-success status with the
55
+ # shared taxonomy.
56
+ #
57
+ # @return [Object, nil] parsed JSON body (nil when empty)
47
58
  def request(method, suffix, body: nil)
59
+ status, payload = exchange(method, suffix, body: body)
60
+ classify_status!(status, method, exchange_path(suffix))
61
+ payload
62
+ end
63
+
64
+ # Send one request and return the raw HTTP outcome so endpoint-aware
65
+ # callers can classify statuses the generic map cannot distinguish
66
+ # (e.g. a 409 that may mean "already exists" on create or "head out
67
+ # of date" on merge). Transport failures still classify by method:
68
+ # reads are unreachable, mutations are unknown outcomes.
69
+ #
70
+ # @return [Array(Integer, Object, nil)] status, parsed JSON body
71
+ # (nil when empty), and the server-supplied error message for
72
+ # non-success statuses (sanitized, truncated)
73
+ def exchange(method, suffix, body: nil)
48
74
  RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
49
- path = "/api/v1/repos/#{@target.repo}/#{suffix}"
75
+ path = exchange_path(suffix)
50
76
  if @runner
51
77
  result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
52
78
  timeout: @timeout, env: {})
53
79
  status = result[:status] || (result[:success] ? 200 : 503)
54
80
  payload = result[:stdout].to_s
55
81
  else
56
- uri = URI.parse("#{@target.host_url}#{path}")
57
- klass = {get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch}.fetch(method)
58
- req = klass.new(uri)
59
- req["Authorization"] = "token #{access_token!}"
60
- req["Accept"] = "application/json"
61
- req["Content-Type"] = "application/json" if body
62
- req.body = JSON.generate(body) if body
63
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
64
- open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
65
- status = response.code.to_i
82
+ response = connection(method).send(method, path) do |req|
83
+ req.headers["Content-Type"] = "application/json" if body
84
+ req.body = JSON.generate(body) if body
85
+ end
86
+ status = response.status
66
87
  payload = response.body.to_s
67
88
  end
68
- classify_status!(status, method, path)
69
- return nil if payload.empty?
70
-
71
- JSON.parse(payload)
72
- rescue JSON::ParserError => e
73
- raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo response: #{e.message}"
74
- rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IOError, SocketError, OpenSSL::SSL::SSLError, SystemCallError => e
89
+ [status, parse_payload(payload), server_error_message(payload)]
90
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, IOError, SocketError,
91
+ OpenSSL::SSL::SSLError, SystemCallError => e
75
92
  error = (method == :get) ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
76
93
  raise error, "Forgejo #{method} #{path} outcome unknown: #{e.class}"
77
94
  end
78
95
 
79
96
  private
80
97
 
98
+ # Suffixes already carrying the API root are used verbatim (the
99
+ # server-level /api/v1/version probe); everything else is
100
+ # repository-scoped to the selected owner/repo.
101
+ def exchange_path(suffix)
102
+ return suffix if suffix.to_s.start_with?("/api/v1/")
103
+
104
+ "/api/v1/repos/#{@target.repo}/#{suffix}"
105
+ end
106
+
107
+ # A non-JSON body is an absent payload, not a parse failure: the
108
+ # status carries the outcome, and every consumer validates the shape
109
+ # of what it reads, so malformed evidence still fails closed.
110
+ def parse_payload(payload)
111
+ return nil if payload.nil? || payload.empty?
112
+
113
+ JSON.parse(payload)
114
+ rescue JSON::ParserError
115
+ nil
116
+ end
117
+
118
+ # The forge's own explanation for a failed status, sanitized: only
119
+ # the JSON `message` string survives, truncated, so tokens and raw
120
+ # response bodies never reach an error message.
121
+ def server_error_message(payload)
122
+ parsed = parse_payload(payload)
123
+ return nil unless parsed.is_a?(Hash) && parsed["message"].is_a?(String)
124
+
125
+ message = parsed["message"].strip
126
+ message.empty? ? nil : message[0, 300]
127
+ end
128
+
129
+ # Connection per HTTP method: the retry middleware is installed only
130
+ # for the safe verb so a mutation can never be silently re-sent.
131
+ def connection(method)
132
+ @connections = {} unless @connections.is_a?(Hash)
133
+ @connections[method] ||= Faraday.new(url: @target.host_url) do |faraday|
134
+ faraday.options.timeout = @timeout
135
+ faraday.options.open_timeout = @timeout
136
+ faraday.headers["Accept"] = "application/json"
137
+ faraday.headers["Authorization"] = "token #{access_token!}"
138
+ if method == :get
139
+ faraday.request :retry, max: 2, methods: [:get], retry_if: ->(_env, _result) { false }
140
+ end
141
+ faraday.adapter Faraday.default_adapter
142
+ end
143
+ end
144
+
81
145
  def access_token!
82
146
  path = RepositoryBinding.default_keys_path
83
147
  data = path && JSON.parse(File.read(path))
@@ -2,28 +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"
7
9
  require_relative "issue_api"
10
+ require_relative "pull_request_api"
8
11
 
9
12
  module Ace
10
13
  module Git
11
14
  module Forgejo
12
15
  # Forgejo provider implementation of the shared provider contract.
13
16
  #
14
- # Owns Forgejo terminology and translates `fj` responses into the
15
- # normalized evidence types defined by the ace-git core. Every
16
- # repository-scoped call is bound to the selected server's exact
17
- # host/owner/repository through the {CliExecutor} repository boundary:
18
- # the selected identity is mandatory, unobserved operations refuse
19
- # before launch, and returned evidence is checked for conflicting
20
- # identity. Every failure is classified with the shared taxonomy;
21
- # `fj` failures are never hidden behind ad-hoc curl calls or silent
22
- # 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.
23
27
  class Provider < Ace::Git::Providers::Base
24
28
  # Fill color for repo labels created on demand by issue linking.
25
29
  TRACKED_LABEL_COLOR = "0e8a16"
26
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
+
27
35
  class << self
28
36
  def display_name
29
37
  "Forgejo"
@@ -51,12 +59,18 @@ module Ace
51
59
  CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
52
60
  end
53
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
+ #
54
67
  # @return [ProviderPullRequest] normalized pull request evidence
55
- # @raise [ProviderObjectNotFoundError] when the PR does not exist
56
- # @raise [ProviderMalformedOutputError] when `fj` output is unexpected
57
- # @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
58
71
  def pull_request(number:)
59
- fetch_pr(number)
72
+ number = request_number!(number)
73
+ normalize_api_pr(pr_api.pull_request(number))
60
74
  end
61
75
 
62
76
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
@@ -252,7 +266,7 @@ module Ace
252
266
  number = request_number!(number)
253
267
  verify_expected_head!(pull_request(number: number), expected_head)
254
268
  comments = review_http.paginate("issues/#{number}/comments").map do |entry|
255
- review_comment(entry, number, expected_head)
269
+ review_comment(entry, number)
256
270
  end
257
271
  reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
258
272
  review_entry(entry, number, expected_head)
@@ -266,7 +280,7 @@ module Ace
266
280
  unless entries.is_a?(Array)
267
281
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
268
282
  end
269
- entries.map { |entry| review_comment(entry, number, expected_head) }
283
+ entries.map { |entry| review_comment(entry, number) }
270
284
  end
271
285
  verify_expected_head!(pull_request(number: number), expected_head)
272
286
  Ace::Git::ProviderReviewEvidence.new(
@@ -322,7 +336,7 @@ module Ace
322
336
  require_open_pr!(pr)
323
337
  marker = comment_marker(correlation)
324
338
  with_session_lock(correlation) do
325
- existing = matching_review_comments(number, marker, expected_head)
339
+ existing = matching_review_comments(number, marker)
326
340
  if existing.length > 1
327
341
  raise Ace::Git::ProviderConflictingMatchesError,
328
342
  "Multiple comments match review session #{correlation} on PR ##{number}"
@@ -352,7 +366,7 @@ module Ace
352
366
  "session #{correlation}: #{e.message}; reconcile before repeating"
353
367
  end
354
368
  begin
355
- matches = matching_review_comments(number, marker, expected_head)
369
+ matches = matching_review_comments(number, marker)
356
370
  rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
357
371
  raise Ace::Git::ProviderUnknownOutcomeError,
358
372
  "PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
@@ -385,7 +399,7 @@ module Ace
385
399
  raise Ace::Git::ProviderIdentityMismatchError,
386
400
  "Comment #{comment_id} does not belong to selected PR ##{number}"
387
401
  end
388
- comment = review_comment(matches.first, number, expected_head)
402
+ comment = review_comment(matches.first, number)
389
403
  if comment.body == body
390
404
  verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
391
405
  return review_mutation(number, expected_head, comment, :existing)
@@ -423,7 +437,7 @@ module Ace
423
437
  "Comment update sent but exact PR comment #{comment_id} could not be verified"
424
438
  end
425
439
  verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
426
- review_mutation(number, expected_head, review_comment(updated, number, expected_head), :updated)
440
+ review_mutation(number, expected_head, review_comment(updated, number), :updated)
427
441
  end
428
442
 
429
443
  def resolve_pull_request_thread(number:, expected_head:, thread_id:)
@@ -432,39 +446,38 @@ module Ace
432
446
  end
433
447
 
434
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.
435
456
 
436
457
  # @return [Array<ProviderPullRequest>] open PRs matching the exact
437
- # base/head identity, with head SHA provenance
458
+ # base/head identity, with head SHA provenance from the API list
438
459
  def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
439
460
  require_base_on_server!(base_repository_url)
440
- listing = fj(:pr_search, "open")
441
- Parsers.parse_search(listing).filter_map do |entry|
442
- parsed = view_pr(entry[:number])
443
- next unless parsed && parsed[:state] == :open
444
- next unless parsed[:head_ref] == head_ref && parsed[:base_ref] == base_ref
445
- next unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url_of(parsed), head_repository_url)
446
-
447
- 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)
448
469
  end
449
470
  end
450
471
 
451
- # Create a PR via `fj pr create`, reconciling to an exact open match
452
- # first and proving the resulting head against `expected_head`.
453
- # `fj` cannot encode an explicit fork head; the receipt reports the
454
- # 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.
455
478
  #
456
479
  # @return [ProviderMutationReceipt] idempotency :created/:existing
457
480
  def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
458
- # `fj pr create -r` binds the base repository, but no observed form
459
- # encodes a cross-repository fork head; refusing before any command
460
- # (even lookups) beats creating the wrong PR.
461
- unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
462
- raise Ace::Git::ProviderUnsupportedCapabilityError,
463
- "Forgejo CLI (fj) pr create cannot target head repository " \
464
- "#{head_repository_url} (resolved server repository: #{server.url}); " \
465
- "run from the fork checkout or use the forge web UI"
466
- end
467
-
468
481
  matches = find_open_pull_requests(
469
482
  head_repository_url: head_repository_url, head_ref: head_ref,
470
483
  base_repository_url: server.url, base_ref: base_ref
@@ -475,53 +488,363 @@ module Ace
475
488
  "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
476
489
  end
477
490
  if matches.size == 1
478
- 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)
479
494
  end
480
495
 
481
- send_mutation(:pr_create, title, head_ref, base_ref, body,
482
- 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
483
502
 
484
- created = reconcile_created(
485
- head_repository_url: head_repository_url, head_ref: head_ref,
486
- base_ref: base_ref, expected_head: expected_head
487
- )
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)
488
515
  receipt(:create, created, :created)
489
516
  end
490
517
 
491
- # `fj pr edit` takes one field subcommand per invocation (title|body),
492
- # so multi-field updates are separate commands with final evidence
493
- # 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.
494
520
  #
495
521
  # @return [ProviderMutationReceipt] operation :update
496
522
  def update_pull_request(number:, expected_head:, title: nil, body: nil)
497
523
  number = request_number!(number)
498
- verify_expected_head!(fetch_pr(number), expected_head)
499
- send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
500
- send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
501
- 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)
502
538
  end
503
539
 
504
- # `fj` offers no draft-to-ready command; classified as an unsupported
505
- # 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
506
546
  def ready_pull_request(number:, expected_head:)
507
- raise Ace::Git::ProviderUnsupportedCapabilityError,
508
- "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)
509
576
  end
510
577
 
511
- # `fj pr merge` cannot enforce an expected-head precondition
512
- # 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
513
586
  def merge_pull_request(number:, expected_head:, method:)
514
587
  unless %i[squash merge rebase].include?(method)
515
588
  raise ArgumentError, "Invalid merge method #{method.inspect}"
516
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
517
604
 
518
- raise Ace::Git::ProviderUnsupportedCapabilityError,
519
- "Forgejo CLI (fj) pr merge cannot enforce expected-head (#{expected_head}) " \
520
- "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
521
639
  end
522
640
 
523
641
  private
524
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
+
525
848
  def require_open_pr!(pr)
526
849
  return if pr.state == :open
527
850
 
@@ -533,24 +856,25 @@ module Ace
533
856
  @review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
534
857
  end
535
858
 
536
- # A mutation receipt may only bind the expected head; if the PR head
537
- # moved during the mutation the outcome stays uncertain.
538
- def verify_post_mutation_head!(number, expected_head, correlation)
539
- current = begin
540
- pull_request(number: number)
541
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
542
- raise Ace::Git::ProviderUnknownOutcomeError,
543
- "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
544
- end
545
- 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
546
870
 
547
- raise Ace::Git::ProviderUnknownOutcomeError,
548
- "PR head moved during mutation for #{server.name}/##{number} " \
549
- "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
550
- 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
551
875
 
552
876
  # Serialize the read/post/reconcile sequence per session so two
553
- # concurrent callers cannot both observe "no marker" and post.
877
+ # concurrent callers cannot both observe "no marker" and post.
554
878
  def with_session_lock(correlation)
555
879
  require "tmpdir"
556
880
  lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
@@ -567,18 +891,18 @@ module Ace
567
891
  "<!-- ace-review-session:#{correlation} -->"
568
892
  end
569
893
 
570
- def matching_review_comments(number, marker, expected_head)
894
+ def matching_review_comments(number, marker)
571
895
  review_http.paginate("issues/#{number}/comments").filter_map do |entry|
572
896
  unless entry.is_a?(Hash) && entry["body"].is_a?(String)
573
897
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment collection"
574
898
  end
575
899
  next unless entry["body"].include?(marker)
576
900
 
577
- review_comment(entry, number, expected_head)
901
+ review_comment(entry, number)
578
902
  end
579
903
  end
580
904
 
581
- def review_comment(entry, number, head)
905
+ def review_comment(entry, number)
582
906
  user = entry.is_a?(Hash) ? entry["user"] : nil
583
907
  unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
584
908
  user.is_a?(Hash) && user["login"].is_a?(String)
@@ -675,23 +999,7 @@ module Ace
675
999
  classify_failure(result[:stderr], context: operation_context(operation, arguments))
676
1000
  end
677
1001
 
678
- # Send one mutating repository operation. When `ambiguous` is true
679
- # (create), any transport-level failure becomes an unknown outcome:
680
- # the request may have mutated the forge, so callers must reconcile
681
- # by exact identity instead of retrying blindly.
682
- def send_mutation(operation, *arguments, ambiguous:, identity: nil)
683
- result = execute_repository(operation, arguments)
684
- return result if result[:success]
685
-
686
- classify_failure(result[:stderr], context: operation_context(operation, arguments))
687
- rescue Ace::Git::ProviderUnreachableError => e
688
- raise e unless ambiguous
689
-
690
- raise Ace::Git::ProviderUnknownOutcomeError,
691
- "PR create outcome unknown after transport failure (#{e.message}); " \
692
- "reconcile by exact identity before repeating: #{identity}"
693
- end
694
-
1002
+ # Run one repository-scoped `fj` operation on the validated target.
695
1003
  def execute_repository(operation, arguments)
696
1004
  target = repository_target
697
1005
  ensure_observed_version!
@@ -728,17 +1036,6 @@ module Ace
728
1036
  end
729
1037
  end
730
1038
 
731
- # Fetch one PR as normalized evidence with exact head SHA.
732
- def fetch_pr(number)
733
- with_head_sha(view_pr(number))
734
- end
735
-
736
- # Complete a parsed PR view with its exact head SHA evidence.
737
- def with_head_sha(parsed)
738
- parsed[:head_sha] = head_sha_of(parsed[:number])
739
- normalize_pr(parsed)
740
- end
741
-
742
1039
  # View one PR and parse its stable minimal-style output. The
743
1040
  # qualified `owner/repo#N` reference binds the read to the selected
744
1041
  # repository; a returned number that disagrees with the request is a
@@ -752,27 +1049,6 @@ module Ace
752
1049
  parsed
753
1050
  end
754
1051
 
755
- # Re-fetch and verify the PR created by an accepted create request.
756
- def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
757
- matches = find_open_pull_requests(
758
- head_repository_url: head_repository_url, head_ref: head_ref,
759
- base_repository_url: server.url, base_ref: base_ref
760
- )
761
- if matches.empty?
762
- raise Ace::Git::ProviderUnknownOutcomeError,
763
- "PR create accepted but no open pull request matches " \
764
- "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
765
- "reconcile by exact identity before repeating"
766
- end
767
- if matches.size > 1
768
- raise Ace::Git::ProviderConflictingMatchesError,
769
- "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
770
- "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
771
- end
772
-
773
- verify_expected_head!(matches.first, expected_head)
774
- end
775
-
776
1052
  def require_base_on_server!(base_repository_url)
777
1053
  return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
778
1054
 
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Git
7
+ module Forgejo
8
+ # Repository-bound Forgejo API v1 operations for the PR delivery
9
+ # lifecycle (create, edit, ready, merge) plus the authoritative single
10
+ # pull request read.
11
+ #
12
+ # The fj v0.6 CLI cannot encode a fork head, an explicit draft state,
13
+ # a ready transition, or an atomic expected-head merge precondition;
14
+ # this molecule owns those endpoints on the same selected host and
15
+ # token transport as {HttpClient}. Every returned payload is validated
16
+ # against the documented Forgejo `PullRequest` schema before it may
17
+ # become provider evidence; contradictory or missing identity fields
18
+ # fail closed as malformed evidence.
19
+ #
20
+ # Mutations return a {MutationOutcome} for the statuses that carry
21
+ # endpoint-specific meaning (201/200 success, 405/409/422 refusal) so
22
+ # the provider can classify the forge's own semantics; authentication,
23
+ # absence, and transport failures raise the shared taxonomy directly.
24
+ class PullRequestApi
25
+ # Official capability evidence (see the task capability-evidence
26
+ # report): `head_commit_id` merge enforcement is documented from
27
+ # release branch v7.0, and the API `draft` field is only computed
28
+ # from the WIP title from v8.0 (`Draft: pr.IsWorkInProgress` and a
29
+ # non-omitempty bool; v7.0 leaves it unset). The lifecycle proves
30
+ # draft state, so the floor is the first line reporting both.
31
+ MINIMUM_SERVER_VERSION = Gem::Version.new("8.0.0")
32
+
33
+ # Outcome of one mutation send whose status the provider must
34
+ # interpret: the HTTP status, the sanitized server message (nil on
35
+ # success), and the parsed payload when the server returned one.
36
+ MutationOutcome = Data.define(:status, :message, :payload)
37
+
38
+ def initialize(server:, timeout:, runner: nil)
39
+ @http = HttpClient.new(server: server, timeout: timeout, runner: runner)
40
+ end
41
+
42
+ # Probe and validate the selected server's API version once per
43
+ # instance. Every lifecycle mutation gate goes through here and
44
+ # fails closed when the server cannot prove the documented
45
+ # capability floor. Transport and authentication failures classify
46
+ # as they would for any read; an unusable or too-old version
47
+ # reports is a capability refusal.
48
+ #
49
+ # @raise [Ace::Git::ProviderUnsupportedCapabilityError] when the
50
+ # server version cannot be verified or is below the floor
51
+ def ensure_version_supported!
52
+ return if @version_supported
53
+
54
+ status, payload = @http.exchange(:get, "/api/v1/version")
55
+ version_text = payload.is_a?(Hash) ? payload["version"] : nil
56
+ unless status == 200 && version_text.is_a?(String) && !version_text.empty?
57
+ if [401, 403].include?(status.to_i)
58
+ raise Ace::Git::ProviderAuthenticationError,
59
+ "Forgejo server version probe returned HTTP #{status}; authenticate with fj first"
60
+ end
61
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
62
+ "Forgejo server did not report a usable API version (HTTP #{status}); " \
63
+ "cannot prove the PR delivery capability floor " \
64
+ "#{MINIMUM_SERVER_VERSION} before mutating"
65
+ end
66
+
67
+ version = parse_version(version_text)
68
+ unless version >= MINIMUM_SERVER_VERSION
69
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
70
+ "Forgejo server reports #{version}; PR delivery requires #{MINIMUM_SERVER_VERSION} " \
71
+ "(server-enforced expected-head merge is not documented below it)"
72
+ end
73
+ @version_supported = true
74
+ end
75
+
76
+ # @return [Hash] the raw validated pull request payload
77
+ # @raise [Ace::Git::ProviderObjectNotFoundError] when absent
78
+ # @raise [Ace::Git::ProviderMalformedOutputError] on schema violation
79
+ # @raise [Ace::Git::ProviderIdentityMismatchError] when the payload
80
+ # reports another object number (misrouted evidence)
81
+ def pull_request(number)
82
+ status, payload = @http.exchange(:get, "pulls/#{number}")
83
+ case status
84
+ when 200 then validate_pr_payload!(payload, number)
85
+ when 404 then raise Ace::Git::ProviderObjectNotFoundError, "Forgejo pull request ##{number} not found"
86
+ when 401, 403
87
+ raise Ace::Git::ProviderAuthenticationError,
88
+ "Forgejo pull request ##{number} read failed with HTTP #{status}"
89
+ else
90
+ raise Ace::Git::ProviderUnreachableError,
91
+ "Forgejo pull request ##{number} read failed with HTTP #{status}"
92
+ end
93
+ end
94
+
95
+ # @return [Array<Hash>] validated payloads of all open pull requests
96
+ def open_pull_requests
97
+ @http.paginate("pulls?state=open").map { |payload| validate_pr_payload!(payload, payload["number"]) }
98
+ end
99
+
100
+ # Send the create mutation. The head argument must already be in the
101
+ # documented API form (plain branch, or "owner:branch" for a
102
+ # same-server fork).
103
+ #
104
+ # @return [MutationOutcome] 201 with the created payload, or a
105
+ # refusal outcome (409 duplicate, 404/422 unprovable source)
106
+ def create_pull_request(head:, base:, title:, body: nil)
107
+ form = {"title" => title, "base" => base, "head" => head}
108
+ form["body"] = body if body
109
+ status, payload, message = @http.exchange(:post, "pulls", body: form)
110
+ classify_send!(status, "create pull request")
111
+ MutationOutcome.new(status: status, message: message, payload: payload)
112
+ end
113
+
114
+ # Edit title/body of one pull request (also the ready transition:
115
+ # Forgejo marks a draft ready by removing its WIP title prefix).
116
+ #
117
+ # @return [MutationOutcome] 200, or a refusal outcome
118
+ def edit_pull_request(number, title: nil, body: nil)
119
+ form = {}
120
+ form["title"] = title if title
121
+ form["body"] = body if body
122
+ status, payload, message = @http.exchange(:patch, "pulls/#{number}", body: form)
123
+ classify_send!(status, "edit pull request #{number}")
124
+ MutationOutcome.new(status: status, message: message, payload: payload)
125
+ end
126
+
127
+ # Send the server-enforced merge: Forgejo re-resolves the head ref
128
+ # at merge time and refuses with 409 when it does not equal
129
+ # `head_commit_id` (documented `MergePullRequestOption`).
130
+ #
131
+ # @param do_method [String] "squash", "merge", or "rebase"
132
+ # @return [MutationOutcome] 200, or a refusal outcome (405 disabled
133
+ # style, 409 stale head / conflicts / already merged)
134
+ def merge_pull_request(number, do_method:, head_commit_id:)
135
+ form = {"Do" => do_method, "head_commit_id" => head_commit_id}
136
+ status, payload, message = @http.exchange(:post, "pulls/#{number}/merge", body: form)
137
+ classify_send!(status, "merge pull request #{number}")
138
+ MutationOutcome.new(status: status, message: message, payload: payload)
139
+ end
140
+
141
+ private
142
+
143
+ # Authentication, absence, and transport failures classify directly
144
+ # inside the transport; only statuses whose meaning is
145
+ # endpoint-specific reach the provider as an outcome.
146
+ def classify_send!(status, operation)
147
+ return if (200..299).cover?(status)
148
+ return if [404, 405, 409, 422].include?(status.to_i)
149
+
150
+ error = case status.to_i
151
+ when 401, 403 then Ace::Git::ProviderAuthenticationError
152
+ else Ace::Git::ProviderUnknownOutcomeError
153
+ end
154
+ raise error, "Forgejo #{operation} returned HTTP #{status}; reconcile before repeating"
155
+ end
156
+
157
+ def validate_pr_payload!(payload, requested_number)
158
+ unless payload.is_a?(Hash)
159
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo pull request evidence"
160
+ end
161
+ head = payload["head"]
162
+ base = payload["base"]
163
+ unless payload["number"].is_a?(Integer) && payload["number"].positive? &&
164
+ %w[open closed].include?(payload["state"]) &&
165
+ [true, false].include?(payload["draft"]) &&
166
+ [true, false].include?(payload["merged"]) &&
167
+ head.is_a?(Hash) && head["ref"].is_a?(String) && !head["ref"].empty? &&
168
+ head["sha"].to_s.match?(/\A[0-9a-f]{40}\z/) &&
169
+ base.is_a?(Hash) && base["ref"].is_a?(String) && !base["ref"].empty?
170
+ raise Ace::Git::ProviderMalformedOutputError,
171
+ "Malformed Forgejo pull request payload (missing number/state/draft/head/base identity)"
172
+ end
173
+ unless payload["number"] == requested_number
174
+ raise Ace::Git::ProviderIdentityMismatchError,
175
+ "Forgejo returned pull request ##{payload["number"]} for selected ##{requested_number}; " \
176
+ "refusing misrouted evidence"
177
+ end
178
+ payload
179
+ end
180
+
181
+ # Server version strings look like "7.0.5+gitea-..." or "v12.0.1";
182
+ # only the leading numeric release is comparable.
183
+ def parse_version(text)
184
+ match = text.strip.sub(/\Av/i, "").match(/\A(\d+\.\d+\.\d+)/)
185
+ unless match
186
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
187
+ "Forgejo server version #{text.inspect} is not a comparable release; " \
188
+ "cannot prove the PR delivery capability floor #{MINIMUM_SERVER_VERSION}"
189
+ end
190
+ Gem::Version.new(match[1])
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -100,17 +100,6 @@ module Ace
100
100
  pr_search: ->(target, state) do
101
101
  ["--style", "minimal", "pr", "search", "--state", state, "-r", target.repo]
102
102
  end,
103
- pr_create: ->(target, title, head_ref, base_ref, body) do
104
- argv = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
105
- argv += ["--body", body] if body
106
- argv + ["-r", target.repo]
107
- end,
108
- pr_edit_title: ->(target, number, title) do
109
- ["pr", "edit", target.qualified_ref(number), "title", title]
110
- end,
111
- pr_edit_body: ->(target, number, body) do
112
- ["pr", "edit", target.qualified_ref(number), "body", body]
113
- end,
114
103
  issue_view: ->(target, number) do
115
104
  ["--style", "minimal", "issue", "view", target.qualified_ref(number)]
116
105
  end,
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.5.0"
6
+ VERSION = "0.6.0"
7
7
  end
8
8
  end
9
9
  end
@@ -8,6 +8,7 @@ require_relative "forgejo/cli_executor"
8
8
  require_relative "forgejo/pr_identifier"
9
9
  require_relative "forgejo/parsers"
10
10
  require_relative "forgejo/http_client"
11
+ require_relative "forgejo/pull_request_api"
11
12
  require_relative "forgejo/provider"
12
13
 
13
14
  module Ace
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-forgejo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-03 00:00:00.000000000 Z
10
+ date: 2026-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git
@@ -23,6 +23,34 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.26'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.14'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.14'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-retry
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.4'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.4'
26
54
  description: 'Implements the shared ace-git provider contract for Forgejo: all `fj`
27
55
  CLI invocation, output parsing, authentication verification, and classified failure
28
56
  handling live behind one normalized evidence boundary. Ad-hoc curl fallbacks are
@@ -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