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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8a35836d33fbf6fc14e52f4c27ef396244d1f809bc919ceca6ac0202db5e583
4
- data.tar.gz: 548b2f7734d7f98ef2594e9630ddeb499ae9ead7f8c4297498ebe8726217c4a2
3
+ metadata.gz: e1e1e605a8808db94b0b68ad90d445149f8fce01df65f7ddad401f59a0fce255
4
+ data.tar.gz: 312685879a60d54363626b6f7dc98d2954b0575120aeb0dde0e128826252fba6
5
5
  SHA512:
6
- metadata.gz: c19a8e1f44853f9c173e51118a42f1de9fd6ab04d77c01371c68f16d867f654b938f4cf5b252d8f7d4300c4bdcca826c53ed7dafddc6e616051fab8292aa457d
7
- data.tar.gz: b312fbb4a37d5e871e55148f40e90951ebc2692c474d87e0052adb6beb0647fb24347b3599b914c7a4c7a93f9ca4726f201bd67301f4ba84ded528db979168da
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
- text.to_s.lines.map { |line| clean(line) }.filter_map do |line|
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+\((?:push|pull_request|schedule)\)/
129
+ /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
122
130
  )
123
- next nil unless match
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
- .select { |task| task[:sha].start_with?(ref.to_s) || ref.to_s.start_with?(task[:sha]) }
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
- base = repository_target.url.to_s.chomp("/")
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) }
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.3.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
@@ -7,6 +7,7 @@ require_relative "forgejo/repository_binding"
7
7
  require_relative "forgejo/cli_executor"
8
8
  require_relative "forgejo/pr_identifier"
9
9
  require_relative "forgejo/parsers"
10
+ require_relative "forgejo/http_client"
10
11
  require_relative "forgejo/provider"
11
12
 
12
13
  module Ace
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-git-forgejo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.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-01 00:00:00.000000000 Z
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.24'
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.24'
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