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 +4 -4
- data/CHANGELOG.md +13 -13
- data/README.md +52 -10
- data/lib/ace/git/forgejo/http_client.rb +84 -20
- data/lib/ace/git/forgejo/provider.rb +408 -132
- data/lib/ace/git/forgejo/pull_request_api.rb +195 -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: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
|
|
4
|
+
data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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,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 "
|
|
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 =
|
|
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
|
+
"/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`
|
|
15
|
-
# normalized evidence types defined by the ace-git
|
|
16
|
-
# repository-scoped call is bound to the selected server's
|
|
17
|
-
# host/owner/repository through the {CliExecutor}
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
next unless
|
|
445
|
-
next unless Ace::Git::Atoms::ServerUrl.match?(
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
#
|
|
492
|
-
#
|
|
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!(
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
#
|
|
505
|
-
#
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
#
|
|
512
|
-
#
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
901
|
+
review_comment(entry, number)
|
|
578
902
|
end
|
|
579
903
|
end
|
|
580
904
|
|
|
581
|
-
def review_comment(entry, number
|
|
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
|
-
#
|
|
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,
|
data/lib/ace/git/forgejo.rb
CHANGED
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.
|
|
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-
|
|
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
|