ace-git-forgejo 0.2.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.
@@ -11,9 +11,14 @@ module Ace
11
11
  # Forgejo provider implementation of the shared provider contract.
12
12
  #
13
13
  # Owns Forgejo terminology and translates `fj` responses into the
14
- # normalized evidence types defined by the ace-git core. Every failure is
15
- # classified with the shared taxonomy; `fj` failures are never hidden
16
- # behind ad-hoc curl calls or silent fallbacks.
14
+ # normalized evidence types defined by the ace-git core. Every
15
+ # repository-scoped call is bound to the selected server's exact
16
+ # host/owner/repository through the {CliExecutor} repository boundary:
17
+ # the selected identity is mandatory, unobserved operations refuse
18
+ # before launch, and returned evidence is checked for conflicting
19
+ # identity. Every failure is classified with the shared taxonomy;
20
+ # `fj` failures are never hidden behind ad-hoc curl calls or silent
21
+ # fallbacks.
17
22
  class Provider < Ace::Git::Providers::Base
18
23
  class << self
19
24
  def display_name
@@ -31,14 +36,15 @@ module Ace
31
36
  CliExecutor.check_installed!(runner: runner)
32
37
  end
33
38
 
34
- # @return [Boolean] true when the server host has a configured `fj` login
39
+ # @return [Boolean] true when the selected server's host has a
40
+ # configured `fj` login
35
41
  def authenticated?
36
- CliExecutor.authenticated?(server_host, runner: runner)
42
+ CliExecutor.authenticated?(repository_target.authority, runner: runner)
37
43
  end
38
44
 
39
45
  # @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
40
46
  def check_authenticated!
41
- CliExecutor.check_authenticated!(host: server_host, runner: runner)
47
+ CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
42
48
  end
43
49
 
44
50
  # @return [ProviderPullRequest] normalized pull request evidence
@@ -51,13 +57,13 @@ module Ace
51
57
 
52
58
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
53
59
  def pull_request_for_branch(branch:)
54
- # `fj pr search` has no --limit flag; fetch all and order newest-first.
55
- listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
60
+ # `fj pr search` has no --limit flag (observed v0.6.0); fetch all
61
+ # and order newest-first.
62
+ listing = fj(:pr_search, "all")
56
63
  entries = Parsers.parse_search(listing).sort_by { |entry| -entry[:number] }
57
64
 
58
65
  entries.each do |entry|
59
- view = fj(["--style", "minimal", "pr", "view", entry[:number].to_s])
60
- parsed = Parsers.parse_pr_view(view)
66
+ parsed = view_pr(entry[:number])
61
67
  next unless parsed && parsed[:head_ref] == branch
62
68
 
63
69
  parsed[:head_sha] = head_sha_of(entry[:number])
@@ -69,7 +75,7 @@ module Ace
69
75
 
70
76
  # @return [String] unified diff text for the pull request
71
77
  def pull_request_diff(number:)
72
- fj(["pr", "view", number.to_s, "diff"])
78
+ fj(:pr_diff, request_number!(number))
73
79
  end
74
80
 
75
81
  # `fj pr search` has no --limit flag; cap client-side after sorting
@@ -77,7 +83,7 @@ module Ace
77
83
  #
78
84
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
79
85
  def recent_pull_requests(limit:)
80
- listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
86
+ listing = fj(:pr_search, "all")
81
87
  Parsers.parse_search(listing)
82
88
  .map { |entry| normalize_search_entry(entry) }
83
89
  .sort_by { |pr| -pr.number }
@@ -86,10 +92,11 @@ module Ace
86
92
 
87
93
  # @return [ProviderIssue] normalized issue evidence
88
94
  def issue(number:)
89
- view = fj(["--style", "minimal", "issue", "view", number.to_s])
95
+ view = fj(:issue_view, request_number!(number))
90
96
  parsed = Parsers.parse_issue_view(view) ||
91
97
  raise(Ace::Git::ProviderMalformedOutputError,
92
98
  "Unrecognized `fj issue view #{number}` output; update the Forgejo provider parser")
99
+ verify_returned_number!(parsed, number, "issue")
93
100
 
94
101
  Ace::Git::ProviderIssue.new(
95
102
  server_name: server.name,
@@ -104,9 +111,11 @@ module Ace
104
111
 
105
112
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
106
113
  def checks(ref:)
107
- tasks = fj(["--style", "minimal", "actions", "tasks"])
114
+ tasks = fj(:actions_tasks)
108
115
  Parsers.parse_actions_tasks(tasks)
109
- .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 }
110
119
  .map do |task|
111
120
  Ace::Git::ProviderCheck.new(
112
121
  server_name: server.name,
@@ -119,25 +128,224 @@ module Ace
119
128
  end
120
129
 
121
130
  # @return [ProviderRepository] normalized repository evidence
131
+ # @raise [ProviderIdentityMismatchError] when the CLI reports a
132
+ # repository other than the selected one
122
133
  def repository
123
- view = fj(["--style", "minimal", "repo", "view"])
134
+ target = repository_target
135
+ view = fj(:repo_view)
124
136
  parsed = Parsers.parse_repo_view(view)
137
+ verify_returned_repository!(parsed, target)
125
138
 
126
139
  Ace::Git::ProviderRepository.new(
127
140
  server_name: server.name,
128
141
  full_name: parsed[:full_name],
129
142
  default_branch: nil,
130
- url: parsed[:url]
143
+ url: parsed[:url] || target.url
144
+ )
145
+ end
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
131
202
  )
132
203
  end
133
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
+
134
342
  # ---- PR lifecycle mutations ----
135
343
 
136
344
  # @return [Array<ProviderPullRequest>] open PRs matching the exact
137
345
  # base/head identity, with head SHA provenance
138
346
  def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
139
347
  require_base_on_server!(base_repository_url)
140
- listing = fj(["--style", "minimal", "pr", "search", "--state", "open"])
348
+ listing = fj(:pr_search, "open")
141
349
  Parsers.parse_search(listing).filter_map do |entry|
142
350
  parsed = view_pr(entry[:number])
143
351
  next unless parsed && parsed[:state] == :open
@@ -150,14 +358,14 @@ module Ace
150
358
 
151
359
  # Create a PR via `fj pr create`, reconciling to an exact open match
152
360
  # first and proving the resulting head against `expected_head`.
153
- # `fj` cannot create draft PRs; the receipt reports the provider's
154
- # real draft state.
361
+ # `fj` cannot encode an explicit fork head; the receipt reports the
362
+ # provider's real draft state.
155
363
  #
156
364
  # @return [ProviderMutationReceipt] idempotency :created/:existing
157
365
  def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
158
- # `fj pr create` operates on the checkout's repository and cannot
159
- # encode an explicit fork head; refusing before any command (even
160
- # lookups) beats creating the wrong PR.
366
+ # `fj pr create -r` binds the base repository, but no observed form
367
+ # encodes a cross-repository fork head; refusing before any command
368
+ # (even lookups) beats creating the wrong PR.
161
369
  unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
162
370
  raise Ace::Git::ProviderUnsupportedCapabilityError,
163
371
  "Forgejo CLI (fj) pr create cannot target head repository " \
@@ -178,9 +386,8 @@ module Ace
178
386
  return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
179
387
  end
180
388
 
181
- args = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
182
- args += ["--body", body] if body
183
- send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
389
+ send_mutation(:pr_create, title, head_ref, base_ref, body,
390
+ ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
184
391
 
185
392
  created = reconcile_created(
186
393
  head_repository_url: head_repository_url, head_ref: head_ref,
@@ -195,9 +402,10 @@ module Ace
195
402
  #
196
403
  # @return [ProviderMutationReceipt] operation :update
197
404
  def update_pull_request(number:, expected_head:, title: nil, body: nil)
405
+ number = request_number!(number)
198
406
  verify_expected_head!(fetch_pr(number), expected_head)
199
- send_mutation(["pr", "edit", number.to_s, "title", title], ambiguous: false) if title
200
- send_mutation(["pr", "edit", number.to_s, "body", body], ambiguous: false) if body
407
+ send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
408
+ send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
201
409
  receipt(:update, fetch_pr(number), nil)
202
410
  end
203
411
 
@@ -222,23 +430,143 @@ module Ace
222
430
 
223
431
  private
224
432
 
225
- # Run one `fj` command, classifying every failure with the taxonomy.
226
- def fj(args)
227
- result = CliExecutor.execute(args, timeout: timeout, runner: runner)
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
+
537
+ # Validated selected repository identity. Built once; malformed
538
+ # selections fail as configuration errors before any subprocess.
539
+ def repository_target
540
+ @repository_target ||= RepositoryBinding::Target.resolve(server.url)
541
+ end
542
+
543
+ # Refuse before any repository subprocess when the installed fj was
544
+ # never observed; a different version must not inherit capabilities.
545
+ def ensure_observed_version!
546
+ return if @version_checked
547
+
548
+ CliExecutor.check_version_supported!(runner: runner)
549
+ @version_checked = true
550
+ end
551
+
552
+ # Run one repository-scoped `fj` command bound to the selected
553
+ # target, classifying every failure with the taxonomy.
554
+ def fj(operation, *arguments)
555
+ result = execute_repository(operation, arguments)
228
556
  return result[:stdout] if result[:success]
229
557
 
230
- classify_failure(result[:stderr], context: args.join(" "))
558
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
231
559
  end
232
560
 
233
- # Send one mutating `fj` command. When `ambiguous` is true (create),
234
- # any transport-level failure becomes an unknown outcome: the request
235
- # may have mutated the forge, so callers must reconcile by exact
236
- # identity instead of retrying blindly.
237
- def send_mutation(args, ambiguous:, identity: nil)
238
- result = CliExecutor.execute(args, timeout: timeout, runner: runner)
561
+ # Send one mutating repository operation. When `ambiguous` is true
562
+ # (create), any transport-level failure becomes an unknown outcome:
563
+ # the request may have mutated the forge, so callers must reconcile
564
+ # by exact identity instead of retrying blindly.
565
+ def send_mutation(operation, *arguments, ambiguous:, identity: nil)
566
+ result = execute_repository(operation, arguments)
239
567
  return result if result[:success]
240
568
 
241
- classify_failure(result[:stderr], context: args.join(" "))
569
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
242
570
  rescue Ace::Git::ProviderUnreachableError => e
243
571
  raise e unless ambiguous
244
572
 
@@ -247,12 +575,36 @@ module Ace
247
575
  "reconcile by exact identity before repeating: #{identity}"
248
576
  end
249
577
 
578
+ def execute_repository(operation, arguments)
579
+ target = repository_target
580
+ ensure_observed_version!
581
+ ensure_not_redirecting!(target)
582
+ CliExecutor.execute_repository(
583
+ target: target, operation: operation,
584
+ arguments: arguments, timeout: timeout, runner: runner
585
+ )
586
+ end
587
+
588
+ # Refuse before any subprocess when the readable fj configuration
589
+ # aliases the selected host to a different endpoint: `-H` cannot
590
+ # prevent that redirect, and returned-identity checks cannot see it.
591
+ def ensure_not_redirecting!(target)
592
+ return if @redirect_checked
593
+
594
+ RepositoryBinding.reject_selected_host_redirect!(target.host_url)
595
+ @redirect_checked = true
596
+ end
597
+
598
+ def operation_context(operation, arguments)
599
+ "#{operation} #{arguments.compact.join(" ")}".strip
600
+ end
601
+
250
602
  def classify_failure(message, context:)
251
603
  message = message.to_s
252
604
  case message
253
605
  when /not found|does not exist|no pull request|no issue/i
254
606
  raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{context}): #{message}"
255
- when /access denied|unauthorized|token/i
607
+ when /access denied|unauthorized|token|only signed in|not logged in|log ?in required/i
256
608
  raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
257
609
  else
258
610
  raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{context}): #{message}"
@@ -270,12 +622,17 @@ module Ace
270
622
  normalize_pr(parsed)
271
623
  end
272
624
 
273
- # View one PR and parse its stable minimal-style output.
625
+ # View one PR and parse its stable minimal-style output. The
626
+ # qualified `owner/repo#N` reference binds the read to the selected
627
+ # repository; a returned number that disagrees with the request is a
628
+ # routed mismatch and fails closed.
274
629
  def view_pr(number)
275
- view = fj(["--style", "minimal", "pr", "view", number.to_s])
276
- Parsers.parse_pr_view(view) ||
630
+ view = fj(:pr_view, number)
631
+ parsed = Parsers.parse_pr_view(view) ||
277
632
  raise(Ace::Git::ProviderMalformedOutputError,
278
633
  "Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
634
+ verify_returned_number!(parsed, number, "pull request")
635
+ parsed
279
636
  end
280
637
 
281
638
  # Re-fetch and verify the PR created by an accepted create request.
@@ -319,7 +676,7 @@ module Ace
319
676
  end
320
677
 
321
678
  def head_sha_of(number)
322
- commits = fj(["--style", "minimal", "pr", "view", number.to_s, "commits"])
679
+ commits = fj(:pr_commits, number)
323
680
  Parsers.parse_head_sha(commits)
324
681
  end
325
682
 
@@ -328,6 +685,7 @@ module Ace
328
685
  server_name: server.name,
329
686
  number: parsed[:number],
330
687
  title: parsed[:title],
688
+ body: parsed[:body],
331
689
  state: parsed[:state],
332
690
  head_ref: parsed[:head_ref],
333
691
  base_ref: parsed[:base_ref],
@@ -337,7 +695,10 @@ module Ace
337
695
  draft: nil,
338
696
  merged_at: nil,
339
697
  head_repository_url: head_repository_url_of(parsed),
340
- base_repository_url: server.url,
698
+ base_repository_url: repository_target.url,
699
+ # fj v0.6.0 exposes no authoritative merge-commit field for a
700
+ # selected PR (observed; see the task capability evidence), so
701
+ # merge evidence stays empty and cleanup retains the checkout.
341
702
  merge_commit_sha: nil
342
703
  )
343
704
  end
@@ -347,14 +708,14 @@ module Ace
347
708
  # base repository; canonical PRs use the configured repository.
348
709
  def head_repository_url_of(parsed)
349
710
  repo = parsed[:head_repository]
350
- return server.url if repo.nil? || repo.empty?
711
+ return repository_target.url if repo.nil? || repo.empty?
351
712
 
352
713
  "#{server_host_root}/#{repo}"
353
714
  end
354
715
 
355
716
  def server_host_root
356
717
  @server_host_root ||= begin
357
- uri = URI.parse(server.url.to_s)
718
+ uri = URI.parse(repository_target.url)
358
719
  "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
359
720
  end
360
721
  end
@@ -364,6 +725,7 @@ module Ace
364
725
  server_name: server.name,
365
726
  number: entry[:number],
366
727
  title: entry[:title],
728
+ body: nil,
367
729
  state: nil,
368
730
  head_ref: nil,
369
731
  base_ref: nil,
@@ -373,27 +735,60 @@ module Ace
373
735
  draft: nil,
374
736
  merged_at: nil,
375
737
  head_repository_url: nil,
376
- base_repository_url: nil,
738
+ base_repository_url: repository_target.url,
377
739
  merge_commit_sha: nil
378
740
  )
379
741
  end
380
742
 
381
- def server_host
382
- @server_host ||= begin
383
- uri = URI.parse(server.url.to_s)
384
- uri.host
385
- rescue URI::Error
386
- nil
743
+ # Reject non-numeric or non-positive PR/issue numbers as
744
+ # configuration failures before any subprocess.
745
+ def request_number!(number)
746
+ text = number.to_s
747
+ unless /\A\d+\z/.match?(text) && text.to_i.positive?
748
+ raise Ace::Git::ConfigError,
749
+ "Pull request and issue numbers must be positive integers, got #{number.inspect}"
387
750
  end
751
+
752
+ text.to_i
753
+ end
754
+
755
+ # PR and issue numbers are not globally unique: evidence whose
756
+ # number disagrees with the selected reference was not routed to the
757
+ # selected repository and must never be relabelled.
758
+ def verify_returned_number!(parsed, requested, kind)
759
+ return if parsed[:number] == requested.to_i
760
+
761
+ raise Ace::Git::ProviderIdentityMismatchError,
762
+ "Forgejo returned #{kind} ##{parsed[:number]} for selected #{kind} " \
763
+ "##{requested} on #{repository_target.repo}; refusing misrouted evidence"
764
+ end
765
+
766
+ # `fj repo view` was bound to the selected owner/repository, so
767
+ # returned identity fields must agree with the selection.
768
+ def verify_returned_repository!(parsed, target)
769
+ full_name = parsed[:full_name].to_s.sub(%r{\.git\z}i, "")
770
+ unless !full_name.empty? && full_name.casecmp(target.repo).zero?
771
+ raise Ace::Git::ProviderIdentityMismatchError,
772
+ "Forgejo repo view returned #{full_name.inspect} for selected repository " \
773
+ "#{target.repo}; refusing misrouted evidence"
774
+ end
775
+ return if parsed[:url].nil? || parsed[:url].empty? ||
776
+ Ace::Git::Atoms::ServerUrl.match?(parsed[:url], target.url)
777
+
778
+ raise Ace::Git::ProviderIdentityMismatchError,
779
+ "Forgejo repo view returned URL #{parsed[:url].inspect} for selected repository " \
780
+ "#{target.url}; refusing misrouted evidence"
388
781
  end
389
782
 
390
783
  def pr_url(number)
391
- base = server.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, "")
392
787
  "#{base}/pulls/#{number}"
393
788
  end
394
789
 
395
790
  def issue_url(number)
396
- base = server.url.to_s.chomp("/")
791
+ base = repository_target.url.to_s.chomp("/")
397
792
  "#{base}/issues/#{number}"
398
793
  end
399
794
  end