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 +4 -4
- data/CHANGELOG.md +19 -13
- data/README.md +54 -10
- data/lib/ace/git/forgejo/http_client.rb +86 -20
- data/lib/ace/git/forgejo/provider.rb +462 -133
- data/lib/ace/git/forgejo/pull_request_api.rb +229 -0
- data/lib/ace/git/forgejo/repository_binding.rb +0 -11
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +31 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b31bdebd863b7bee391783037e9aef4dab2a1ac4bede0b2b897e8442ac297b7f
|
|
4
|
+
data.tar.gz: db3a546c09effeda3781370316a691cb893af860cb0212ef982320a521ab9cc0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
40
|
-
window — `fj` has no
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 "
|
|
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 =
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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))
|