ace-git-forgejo 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7388b7c2b27b633234131dca7f190a344c7e1f323e23e2e01b532ed3a16407a
4
- data.tar.gz: 3960cdfc787b85500050589ac27b1a8bceb8d3e5cd594eb37db2448d7e314426
3
+ metadata.gz: b31bdebd863b7bee391783037e9aef4dab2a1ac4bede0b2b897e8442ac297b7f
4
+ data.tar.gz: db3a546c09effeda3781370316a691cb893af860cb0212ef982320a521ab9cc0
5
5
  SHA512:
6
- metadata.gz: 77a634e5f5f9de6c004a2585e57379104062f11e6eee34532edb390c8200aeaba6d3fc3433ec21ccc6e6ee1a1a3aca22b47b99e83890982e9f8d4ef7400bd045
7
- data.tar.gz: c13861e169c6bb26db7bde14c400186677f01c70c185bec7d07a374b6fdcde08717b27ef7233900da128072587af39393af5dc0584603085d0c8293eeba9aff1
6
+ metadata.gz: b4323793a3d0b8b0f217bf7acca41d31d89dc9316b14c51509352edc903760d6811e7412497d1189bf9339cd774154ac6fbc70b506d4c0587a017a3bc8dce2f9
7
+ data.tar.gz: e501d7e7d7ddf48e3194eb6a110c29df8984ba5f873295c0d6bbbbaa621987dcd814f50ede1fb594eb7f18c605dd1b8bc98edb2ca95a3cb6837db8f7a829d305
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.1] - 2026-10-04
11
+
12
+ ### Fixed
13
+ - Prove Forgejo's source repository selector before PR creation and bind accepted receipts to source/base repository IDs, refs, SHA and draft state. Incomplete accepted responses and failed verification remain unknown outcomes without replay.
14
+
15
+ ## [0.6.0] - 2026-10-04
16
+
17
+ ### Added
18
+ - 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).
19
+ - 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.
20
+ - Duplicate-create races reconcile to the exact open match; already-merged refusals are reusable only with authoritative merged evidence at the expected source SHA.
21
+
22
+ ### Changed
23
+ - 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.
24
+
25
+ ### Removed
26
+ - 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.
27
+ - The unused `expected_head` plumbing on the review-comment reconciliation helpers (internal refactor; no behavior change).
28
+
10
29
  ## [0.5.0] - 2026-10-03
11
30
 
12
31
  ### Added
@@ -75,19 +94,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
75
94
  precondition atomically, so both refuse instead of racing.
76
95
  - PR view parser captures the fork repository prefix of the `From` segment.
77
96
 
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
97
  ## [0.1.1] - 2026-09-28
92
98
 
93
99
 
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,48 @@ 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.
99
+
100
+ Create preflight verifies the repository that Forgejo's `owner:branch` selector resolves, including direct forks and a fork's parent. Sharing an owner, branch or SHA does not establish source identity. An accepted create returns a receipt only when the response and authoritative read agree with the requested repository IDs, refs, SHA and draft state. A failed or contradictory verification stays `unknown_outcome`; reconcile by reading the exact identity before authorizing another mutation.
@@ -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,99 @@ 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
+ return "/api/v1/repos/#{@target.repo}" if suffix.to_s.empty?
105
+
106
+ "/api/v1/repos/#{@target.repo}/#{suffix}"
107
+ end
108
+
109
+ # A non-JSON body is an absent payload, not a parse failure: the
110
+ # status carries the outcome, and every consumer validates the shape
111
+ # of what it reads, so malformed evidence still fails closed.
112
+ def parse_payload(payload)
113
+ return nil if payload.nil? || payload.empty?
114
+
115
+ JSON.parse(payload)
116
+ rescue JSON::ParserError
117
+ nil
118
+ end
119
+
120
+ # The forge's own explanation for a failed status, sanitized: only
121
+ # the JSON `message` string survives, truncated, so tokens and raw
122
+ # response bodies never reach an error message.
123
+ def server_error_message(payload)
124
+ parsed = parse_payload(payload)
125
+ return nil unless parsed.is_a?(Hash) && parsed["message"].is_a?(String)
126
+
127
+ message = parsed["message"].strip
128
+ message.empty? ? nil : message[0, 300]
129
+ end
130
+
131
+ # Connection per HTTP method: the retry middleware is installed only
132
+ # for the safe verb so a mutation can never be silently re-sent.
133
+ def connection(method)
134
+ @connections = {} unless @connections.is_a?(Hash)
135
+ @connections[method] ||= Faraday.new(url: @target.host_url) do |faraday|
136
+ faraday.options.timeout = @timeout
137
+ faraday.options.open_timeout = @timeout
138
+ faraday.headers["Accept"] = "application/json"
139
+ faraday.headers["Authorization"] = "token #{access_token!}"
140
+ if method == :get
141
+ faraday.request :retry, max: 2, methods: [:get], retry_if: ->(_env, _result) { false }
142
+ end
143
+ faraday.adapter Faraday.default_adapter
144
+ end
145
+ end
146
+
81
147
  def access_token!
82
148
  path = RepositoryBinding.default_keys_path
83
149
  data = path && JSON.parse(File.read(path))