ace-git-forgejo 0.3.0 → 0.4.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 +7 -0
- data/lib/ace/git/forgejo/http_client.rb +111 -0
- data/lib/ace/git/forgejo/parsers.rb +23 -4
- data/lib/ace/git/forgejo/provider.rb +307 -2
- data/lib/ace/git/forgejo/repository_binding.rb +3 -0
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1e1e605a8808db94b0b68ad90d445149f8fce01df65f7ddad401f59a0fce255
|
|
4
|
+
data.tar.gz: 312685879a60d54363626b6f7dc98d2954b0575120aeb0dde0e128826252fba6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85a2d43f44c7d07d7c60c236f9a5c81c350233fca510c4a6503181f42ab2052a50e31094380e1a2ac752a55bb3ecdaf6923376a439197c459c1a71a66ed21364
|
|
7
|
+
data.tar.gz: ddfee8db45e1545dfb7592e77396255a71d90e083c455a1507ee412da71482fee2a499c098c47ed59cbcc0b6abe46952cea8faa5b151d97b2521106c5e2f6bc8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
|
|
14
|
+
- Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
|
|
15
|
+
|
|
16
|
+
|
|
10
17
|
## [0.3.0] - 2026-09-29
|
|
11
18
|
|
|
12
19
|
### Added
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "repository_binding"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Git
|
|
12
|
+
module Forgejo
|
|
13
|
+
# Authenticated, repository-bound JSON transport for Forgejo API
|
|
14
|
+
# capabilities whose fj v0.6 output omits stable object IDs.
|
|
15
|
+
class HttpClient
|
|
16
|
+
def initialize(server:, timeout:, runner: nil)
|
|
17
|
+
@target = RepositoryBinding::Target.resolve(server.url)
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
@runner = runner
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def paginate(suffix)
|
|
23
|
+
page = 1
|
|
24
|
+
entries = []
|
|
25
|
+
seen_pages = {}
|
|
26
|
+
loop do
|
|
27
|
+
separator = suffix.include?("?") ? "&" : "?"
|
|
28
|
+
batch = request(:get, "#{suffix}#{separator}page=#{page}&limit=50")
|
|
29
|
+
unless batch.is_a?(Array)
|
|
30
|
+
raise Ace::Git::ProviderMalformedOutputError, "Forgejo #{suffix} did not return a list"
|
|
31
|
+
end
|
|
32
|
+
entries.concat(batch)
|
|
33
|
+
# A server may cap pages below the requested limit, so a short
|
|
34
|
+
# page is not proof of the end; stop only on an empty page and
|
|
35
|
+
# guard against servers repeating the same page forever.
|
|
36
|
+
break if batch.empty?
|
|
37
|
+
digest = Digest::SHA256.hexdigest(batch.to_s)
|
|
38
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
39
|
+
"Forgejo #{suffix} pagination repeated page #{page}" if seen_pages[digest]
|
|
40
|
+
|
|
41
|
+
seen_pages[digest] = true
|
|
42
|
+
page += 1
|
|
43
|
+
end
|
|
44
|
+
entries
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def request(method, suffix, body: nil)
|
|
48
|
+
RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
|
|
49
|
+
path = "/api/v1/repos/#{@target.repo}/#{suffix}"
|
|
50
|
+
if @runner
|
|
51
|
+
result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
|
|
52
|
+
timeout: @timeout, env: {})
|
|
53
|
+
status = result[:status] || (result[:success] ? 200 : 503)
|
|
54
|
+
payload = result[:stdout].to_s
|
|
55
|
+
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
|
|
66
|
+
payload = response.body.to_s
|
|
67
|
+
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
|
|
75
|
+
error = (method == :get) ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
|
|
76
|
+
raise error, "Forgejo #{method} #{path} outcome unknown: #{e.class}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def access_token!
|
|
82
|
+
path = RepositoryBinding.default_keys_path
|
|
83
|
+
data = path && JSON.parse(File.read(path))
|
|
84
|
+
# fj stores credentials under the bare host (or host:port)
|
|
85
|
+
# authority, never the scheme-prefixed URL.
|
|
86
|
+
login = data.is_a?(Hash) && data.fetch("hosts", {})[@target.authority]
|
|
87
|
+
token = login.is_a?(Hash) && login["token"]
|
|
88
|
+
return token if token.is_a?(String) && !token.empty?
|
|
89
|
+
|
|
90
|
+
raise Ace::Git::ProviderAuthenticationError,
|
|
91
|
+
"No fj token for selected host #{@target.authority}; authenticate with fj first"
|
|
92
|
+
rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
|
|
93
|
+
raise Ace::Git::ProviderAuthenticationError,
|
|
94
|
+
"Cannot read fj authentication for selected host #{@target.authority}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def classify_status!(status, method, path)
|
|
98
|
+
return if (200..299).cover?(status.to_i)
|
|
99
|
+
|
|
100
|
+
error = case status.to_i
|
|
101
|
+
when 401, 403 then Ace::Git::ProviderAuthenticationError
|
|
102
|
+
when 404 then Ace::Git::ProviderObjectNotFoundError
|
|
103
|
+
when 409, 422 then Ace::Git::ProviderIdentityMismatchError
|
|
104
|
+
else (method == :get) ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
|
|
105
|
+
end
|
|
106
|
+
raise error, "Forgejo #{method} #{path} returned HTTP #{status}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -116,18 +116,37 @@ module Ace
|
|
|
116
116
|
#
|
|
117
117
|
# @return [Array<Hash>] {name:, state:, sha:}
|
|
118
118
|
def self.parse_actions_tasks(text)
|
|
119
|
-
|
|
119
|
+
tasks = []
|
|
120
|
+
declared_count = nil
|
|
121
|
+
text.to_s.lines.map { |line| clean(line) }.reject(&:empty?).each do |line|
|
|
122
|
+
# `fj` emits a leading "<n> tasks" count header before task lines.
|
|
123
|
+
if (header = line.match(/\A(\d+) tasks?\z/i))
|
|
124
|
+
declared_count = header[1].to_i
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
|
|
120
128
|
match = line.match(
|
|
121
|
-
/\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((
|
|
129
|
+
/\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
|
|
122
130
|
)
|
|
123
|
-
|
|
131
|
+
# Any other nonempty line that cannot be parsed must not silently
|
|
132
|
+
# shrink check evidence; fail closed instead.
|
|
133
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
134
|
+
"Unrecognized `fj actions tasks` output line: #{line[0, 80]}" unless match
|
|
124
135
|
|
|
125
|
-
{
|
|
136
|
+
tasks << {
|
|
126
137
|
name: match[:name],
|
|
127
138
|
state: match[:state].downcase.to_sym,
|
|
128
139
|
sha: match[:sha]
|
|
129
140
|
}
|
|
130
141
|
end
|
|
142
|
+
# A declared count that disagrees with the parsed rows means
|
|
143
|
+
# truncated output; fail closed instead of accepting partial
|
|
144
|
+
# check evidence.
|
|
145
|
+
if declared_count && declared_count != tasks.length
|
|
146
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
147
|
+
"Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
|
|
148
|
+
end
|
|
149
|
+
tasks
|
|
131
150
|
end
|
|
132
151
|
|
|
133
152
|
# Parse `fj repo view` output.
|
|
@@ -113,7 +113,9 @@ module Ace
|
|
|
113
113
|
def checks(ref:)
|
|
114
114
|
tasks = fj(:actions_tasks)
|
|
115
115
|
Parsers.parse_actions_tasks(tasks)
|
|
116
|
-
|
|
116
|
+
# Exact-head evidence requires the complete 40-character SHA
|
|
117
|
+
# equal to the reviewed head; prefix matches are rejected.
|
|
118
|
+
.select { |task| task[:sha] == ref.to_s }
|
|
117
119
|
.map do |task|
|
|
118
120
|
Ace::Git::ProviderCheck.new(
|
|
119
121
|
server_name: server.name,
|
|
@@ -142,6 +144,201 @@ module Ace
|
|
|
142
144
|
)
|
|
143
145
|
end
|
|
144
146
|
|
|
147
|
+
# The fj CLI surface omits the PR body; the repository-bound API
|
|
148
|
+
# supplies it for review evidence. Malformed payloads fail closed.
|
|
149
|
+
def pull_request_body(number:)
|
|
150
|
+
number = request_number!(number)
|
|
151
|
+
data = review_http.request(:get, "pulls/#{number}")
|
|
152
|
+
unless data.is_a?(Hash) && data["number"] == number && data.key?("body") &&
|
|
153
|
+
(data["body"].nil? || data["body"].is_a?(String))
|
|
154
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR body evidence"
|
|
155
|
+
end
|
|
156
|
+
data["body"]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def pull_request_review_evidence(number:, expected_head:)
|
|
160
|
+
number = request_number!(number)
|
|
161
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
162
|
+
comments = review_http.paginate("issues/#{number}/comments").map do |entry|
|
|
163
|
+
review_comment(entry, number, expected_head)
|
|
164
|
+
end
|
|
165
|
+
reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
|
|
166
|
+
review_entry(entry, number, expected_head)
|
|
167
|
+
end
|
|
168
|
+
# Review comments live under each review, not at the PR level.
|
|
169
|
+
inline = reviews.flat_map do |review|
|
|
170
|
+
# The per-review endpoint returns the complete list (no
|
|
171
|
+
# pagination); a repeated nonempty page would trip the loop
|
|
172
|
+
# guard, so fetch once and require an array.
|
|
173
|
+
entries = review_http.request(:get, "pulls/#{number}/reviews/#{review.id}/comments")
|
|
174
|
+
unless entries.is_a?(Array)
|
|
175
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
176
|
+
end
|
|
177
|
+
entries.map { |entry| review_comment(entry, number, expected_head) }
|
|
178
|
+
end
|
|
179
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
180
|
+
Ace::Git::ProviderReviewEvidence.new(
|
|
181
|
+
server_name: server.name, repository_url: server.url,
|
|
182
|
+
pr_number: number, head_sha: expected_head,
|
|
183
|
+
comments: comments + inline, reviews: reviews
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def pull_request_review_details(number:)
|
|
188
|
+
number = request_number!(number)
|
|
189
|
+
data = review_http.request(:get, "pulls/#{number}")
|
|
190
|
+
files = review_http.paginate("pulls/#{number}/files").map do |entry|
|
|
191
|
+
entry.is_a?(Hash) && entry["filename"]
|
|
192
|
+
end
|
|
193
|
+
unless data.is_a?(Hash) && data["base"].is_a?(Hash) && data["number"] == number &&
|
|
194
|
+
data.dig("base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/) &&
|
|
195
|
+
data["changed_files"].is_a?(Integer) && data["changed_files"] == files.length &&
|
|
196
|
+
files.all? { |path| path.is_a?(String) && !path.empty? }
|
|
197
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed or incomplete Forgejo PR file evidence"
|
|
198
|
+
end
|
|
199
|
+
Ace::Git::ProviderReviewDetails.new(
|
|
200
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
201
|
+
base_sha: data.dig("base", "sha"), files: files
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def pull_request_checks(number:, head_sha:)
|
|
206
|
+
verify_expected_head!(pull_request(number: number), head_sha)
|
|
207
|
+
checks(ref: head_sha)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def repository_file(path:, ref:)
|
|
211
|
+
require "base64"
|
|
212
|
+
# %20, not form encoding: "+" in a path segment is a literal plus.
|
|
213
|
+
# Explicit unreserved-character allowlist: percent-encode
|
|
214
|
+
# everything else (DEFAULT_PARSER leaves ? and + ambiguous).
|
|
215
|
+
escaped = path.split("/").map { |part|
|
|
216
|
+
part.gsub(/[^A-Za-z0-9._~!$&'()*+,;=@:-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
217
|
+
}.join("/")
|
|
218
|
+
data = review_http.request(:get, "contents/#{escaped}?ref=#{ref}")
|
|
219
|
+
unless data.is_a?(Hash) && data["encoding"] == "base64" && data["content"].is_a?(String)
|
|
220
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file evidence"
|
|
221
|
+
end
|
|
222
|
+
Base64.strict_decode64(data["content"].delete("\n"))
|
|
223
|
+
rescue ArgumentError => e
|
|
224
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file content: #{e.message}"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def create_pull_request_comment(number:, expected_head:, body:, correlation:)
|
|
228
|
+
number = request_number!(number)
|
|
229
|
+
pr = verify_expected_head!(pull_request(number: number), expected_head)
|
|
230
|
+
require_open_pr!(pr)
|
|
231
|
+
marker = comment_marker(correlation)
|
|
232
|
+
with_session_lock(correlation) do
|
|
233
|
+
existing = matching_review_comments(number, marker, expected_head)
|
|
234
|
+
if existing.length > 1
|
|
235
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
236
|
+
"Multiple comments match review session #{correlation} on PR ##{number}"
|
|
237
|
+
end
|
|
238
|
+
if existing.one?
|
|
239
|
+
# A repeat reconciles only the exact session comment; a marker
|
|
240
|
+
# match with different content is a conflict, never our post.
|
|
241
|
+
sent = "#{body}\n\n#{marker}"
|
|
242
|
+
if existing.first.body == sent
|
|
243
|
+
verify_post_mutation_head!(number, expected_head, correlation)
|
|
244
|
+
return review_mutation(number, expected_head, existing.first, :existing)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
248
|
+
"Review session #{correlation} comment exists on PR ##{number} with different content"
|
|
249
|
+
end
|
|
250
|
+
second = verify_expected_head!(pull_request(number: number), expected_head)
|
|
251
|
+
require_open_pr!(second)
|
|
252
|
+
begin
|
|
253
|
+
review_http.request(:post, "issues/#{number}/comments", body: {body: "#{body}\n\n#{marker}"})
|
|
254
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
255
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
256
|
+
"PR comment sent but response unreadable for #{server.name}/#{number}, head #{expected_head}, session #{correlation}: #{e.message}; reconcile before repeating"
|
|
257
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
258
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
259
|
+
"PR comment outcome unknown for #{server.name}/#{number}, head #{expected_head}, " \
|
|
260
|
+
"session #{correlation}: #{e.message}; reconcile before repeating"
|
|
261
|
+
end
|
|
262
|
+
begin
|
|
263
|
+
matches = matching_review_comments(number, marker, expected_head)
|
|
264
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
265
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
266
|
+
"PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
|
|
267
|
+
end
|
|
268
|
+
unless matches.one? && matches.first.body == "#{body}\n\n#{marker}"
|
|
269
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
270
|
+
"PR comment sent but reconciliation found #{matches.length} exact-content match(es) for session #{correlation}"
|
|
271
|
+
end
|
|
272
|
+
verify_post_mutation_head!(number, expected_head, correlation)
|
|
273
|
+
review_mutation(number, expected_head, matches.first, :created)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
|
|
278
|
+
number = request_number!(number)
|
|
279
|
+
comment_id = Integer(comment_id)
|
|
280
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
281
|
+
issue_matches = review_http.paginate("issues/#{number}/comments").select { |entry| entry["id"] == comment_id }
|
|
282
|
+
# Inline ids live under individual reviews; locate them there
|
|
283
|
+
# so an unsupported edit is classified explicitly instead of
|
|
284
|
+
# being misread as not-belonging to this PR.
|
|
285
|
+
inline_found = issue_matches.empty? && review_comment_ids(number).include?(comment_id)
|
|
286
|
+
if inline_found
|
|
287
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
288
|
+
"Editing inline review comments is unsupported by the observed fj v0.6.0 surface (comment #{comment_id})"
|
|
289
|
+
end
|
|
290
|
+
matches = issue_matches
|
|
291
|
+
update_route = "issues/comments/#{comment_id}"
|
|
292
|
+
unless matches.one?
|
|
293
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
294
|
+
"Comment #{comment_id} does not belong to selected PR ##{number}"
|
|
295
|
+
end
|
|
296
|
+
comment = review_comment(matches.first, number, expected_head)
|
|
297
|
+
if comment.body == body
|
|
298
|
+
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
299
|
+
return review_mutation(number, expected_head, comment, :existing)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
verify_expected_head!(pull_request(number: number), expected_head)
|
|
303
|
+
begin
|
|
304
|
+
review_http.request(:patch, update_route, body: {body: body})
|
|
305
|
+
rescue Ace::Git::ProviderMalformedOutputError => e
|
|
306
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
307
|
+
"Comment update sent but response unreadable for #{server.name}/#{number}, comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
308
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
309
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
310
|
+
"Comment update outcome unknown for #{server.name}/#{number}, " \
|
|
311
|
+
"head #{expected_head}, comment #{comment_id}: #{e.message}"
|
|
312
|
+
end
|
|
313
|
+
begin
|
|
314
|
+
# Fetch individually and validate the Forgejo issue identity.
|
|
315
|
+
begin
|
|
316
|
+
updated = review_http.request(:get, update_route)
|
|
317
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
318
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
319
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
320
|
+
end
|
|
321
|
+
unless updated.is_a?(Hash) && updated["issue_url"].to_s.end_with?("/#{number}")
|
|
322
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
323
|
+
"Comment update sent but edited comment #{comment_id} does not verify against PR ##{number}; reconcile before repeating"
|
|
324
|
+
end
|
|
325
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
326
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
327
|
+
"Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
|
|
328
|
+
end
|
|
329
|
+
unless updated && updated["id"] == comment_id && updated["body"] == body
|
|
330
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
331
|
+
"Comment update sent but exact PR comment #{comment_id} could not be verified"
|
|
332
|
+
end
|
|
333
|
+
verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
|
|
334
|
+
review_mutation(number, expected_head, review_comment(updated, number, expected_head), :updated)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def resolve_pull_request_thread(number:, expected_head:, thread_id:)
|
|
338
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
339
|
+
"Forgejo does not expose an observed repository-bound review thread resolution capability"
|
|
340
|
+
end
|
|
341
|
+
|
|
145
342
|
# ---- PR lifecycle mutations ----
|
|
146
343
|
|
|
147
344
|
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
@@ -233,6 +430,110 @@ module Ace
|
|
|
233
430
|
|
|
234
431
|
private
|
|
235
432
|
|
|
433
|
+
def require_open_pr!(pr)
|
|
434
|
+
return if pr.state == :open
|
|
435
|
+
|
|
436
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
437
|
+
"Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def review_http
|
|
441
|
+
@review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A mutation receipt may only bind the expected head; if the PR head
|
|
445
|
+
# moved during the mutation the outcome stays uncertain.
|
|
446
|
+
def verify_post_mutation_head!(number, expected_head, correlation)
|
|
447
|
+
current = begin
|
|
448
|
+
pull_request(number: number)
|
|
449
|
+
rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
|
|
450
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
451
|
+
"Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
|
|
452
|
+
end
|
|
453
|
+
return if current.head_sha == expected_head
|
|
454
|
+
|
|
455
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
456
|
+
"PR head moved during mutation for #{server.name}/##{number} " \
|
|
457
|
+
"(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Serialize the read/post/reconcile sequence per session so two
|
|
461
|
+
# concurrent callers cannot both observe "no marker" and post.
|
|
462
|
+
def with_session_lock(correlation)
|
|
463
|
+
require "tmpdir"
|
|
464
|
+
lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
|
|
465
|
+
File.open(lock_path, File::CREAT | File::RDWR, 0600) do |lock|
|
|
466
|
+
lock.flock(File::LOCK_EX)
|
|
467
|
+
yield
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def comment_marker(correlation)
|
|
472
|
+
unless correlation.to_s.match?(/\A[a-zA-Z0-9._:-]+\z/)
|
|
473
|
+
raise Ace::Git::ConfigError, "Invalid review session correlation"
|
|
474
|
+
end
|
|
475
|
+
"<!-- ace-review-session:#{correlation} -->"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def matching_review_comments(number, marker, expected_head)
|
|
479
|
+
review_http.paginate("issues/#{number}/comments").filter_map do |entry|
|
|
480
|
+
next unless entry.is_a?(Hash) && entry["body"].is_a?(String) && entry["body"].include?(marker)
|
|
481
|
+
|
|
482
|
+
review_comment(entry, number, expected_head)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def review_comment(entry, number, head)
|
|
487
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
|
|
488
|
+
entry.dig("user", "login").is_a?(String)
|
|
489
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
|
|
490
|
+
end
|
|
491
|
+
Ace::Git::ProviderReviewComment.new(
|
|
492
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
493
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
494
|
+
url: entry["html_url"], path: entry["path"],
|
|
495
|
+
# Forgejo supplies position/original_position rather than
|
|
496
|
+
# line; the positive side position is the normalized line.
|
|
497
|
+
line: entry["line"] || [entry["position"], entry["original_position"]].compact.reject { |v| !v.is_a?(Integer) || v <= 0 }.first,
|
|
498
|
+
head_sha: entry["commit_id"] || head, resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
|
|
499
|
+
thread_id: nil
|
|
500
|
+
)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Collect inline (review) comment ids by walking each review.
|
|
504
|
+
def review_comment_ids(number)
|
|
505
|
+
review_http.paginate("pulls/#{number}/reviews").flat_map do |review|
|
|
506
|
+
entries = review_http.request(:get, "pulls/#{number}/reviews/#{review["id"]}/comments")
|
|
507
|
+
unless entries.is_a?(Array)
|
|
508
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
|
|
509
|
+
end
|
|
510
|
+
entries.filter_map { |entry| entry["id"] if entry.is_a?(Hash) && entry["id"].is_a?(Integer) }
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def review_entry(entry, number, head)
|
|
515
|
+
reviewer = entry.dig("user", "login") || entry.dig("team", "name")
|
|
516
|
+
unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
|
|
517
|
+
entry["state"].is_a?(String) && !entry["state"].empty?
|
|
518
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
|
|
519
|
+
end
|
|
520
|
+
Ace::Git::ProviderReview.new(
|
|
521
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
522
|
+
id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
|
|
523
|
+
# Forgejo reports REQUEST_CHANGES; consumers count only the
|
|
524
|
+
# canonical CHANGES_REQUESTED spelling.
|
|
525
|
+
state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
|
|
526
|
+
url: entry["html_url"], head_sha: entry["commit_id"] || head
|
|
527
|
+
)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def review_mutation(number, head, comment, idempotency)
|
|
531
|
+
Ace::Git::ProviderReviewMutation.new(
|
|
532
|
+
server_name: server.name, repository_url: server.url, pr_number: number,
|
|
533
|
+
head_sha: head, comment: comment, idempotency: idempotency
|
|
534
|
+
)
|
|
535
|
+
end
|
|
536
|
+
|
|
236
537
|
# Validated selected repository identity. Built once; malformed
|
|
237
538
|
# selections fail as configuration errors before any subprocess.
|
|
238
539
|
def repository_target
|
|
@@ -384,6 +685,7 @@ module Ace
|
|
|
384
685
|
server_name: server.name,
|
|
385
686
|
number: parsed[:number],
|
|
386
687
|
title: parsed[:title],
|
|
688
|
+
body: parsed[:body],
|
|
387
689
|
state: parsed[:state],
|
|
388
690
|
head_ref: parsed[:head_ref],
|
|
389
691
|
base_ref: parsed[:base_ref],
|
|
@@ -423,6 +725,7 @@ module Ace
|
|
|
423
725
|
server_name: server.name,
|
|
424
726
|
number: entry[:number],
|
|
425
727
|
title: entry[:title],
|
|
728
|
+
body: nil,
|
|
426
729
|
state: nil,
|
|
427
730
|
head_ref: nil,
|
|
428
731
|
base_ref: nil,
|
|
@@ -478,7 +781,9 @@ module Ace
|
|
|
478
781
|
end
|
|
479
782
|
|
|
480
783
|
def pr_url(number)
|
|
481
|
-
|
|
784
|
+
# A configured URL may end in .git (clone form); the web URL
|
|
785
|
+
# must be built from the repository page base instead.
|
|
786
|
+
base = repository_target.url.to_s.chomp("/").sub(/\.git\z/i, "")
|
|
482
787
|
"#{base}/pulls/#{number}"
|
|
483
788
|
end
|
|
484
789
|
|
|
@@ -185,7 +185,10 @@ module Ace
|
|
|
185
185
|
if home
|
|
186
186
|
candidates << File.join(home, ".local", "share", "forgejo-cli", "keys.json")
|
|
187
187
|
candidates << File.join(home, "Library", "Application Support", "forgejo-cli", "keys.json")
|
|
188
|
+
# macOS app-support uses reverse-DNS bundle directories.
|
|
189
|
+
candidates << File.join(home, "Library", "Application Support", "forgejo-cli.forgejo-cli", "keys.json")
|
|
188
190
|
candidates << File.join(home, ".local", "share", "Cyborus", "forgejo-cli", "keys.json")
|
|
191
|
+
candidates << File.join(home, "Library", "Application Support", "Cyborus.forgejo-cli", "keys.json")
|
|
189
192
|
candidates << File.join(home, "Library", "Application Support", "Cyborus", "forgejo-cli", "keys.json")
|
|
190
193
|
end
|
|
191
194
|
candidates.find { |path| File.exist?(path) }
|
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.4.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-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.26'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.26'
|
|
26
26
|
description: 'Implements the shared ace-git provider contract for Forgejo: all `fj`
|
|
27
27
|
CLI invocation, output parsing, authentication verification, and classified failure
|
|
28
28
|
handling live behind one normalized evidence boundary. Ad-hoc curl fallbacks are
|
|
@@ -39,6 +39,7 @@ files:
|
|
|
39
39
|
- Rakefile
|
|
40
40
|
- lib/ace/git/forgejo.rb
|
|
41
41
|
- lib/ace/git/forgejo/cli_executor.rb
|
|
42
|
+
- lib/ace/git/forgejo/http_client.rb
|
|
42
43
|
- lib/ace/git/forgejo/parsers.rb
|
|
43
44
|
- lib/ace/git/forgejo/pr_identifier.rb
|
|
44
45
|
- lib/ace/git/forgejo/provider.rb
|