ace-git-forgejo 0.4.0 → 0.5.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: e1e1e605a8808db94b0b68ad90d445149f8fce01df65f7ddad401f59a0fce255
4
- data.tar.gz: 312685879a60d54363626b6f7dc98d2954b0575120aeb0dde0e128826252fba6
3
+ metadata.gz: e7388b7c2b27b633234131dca7f190a344c7e1f323e23e2e01b532ed3a16407a
4
+ data.tar.gz: 3960cdfc787b85500050589ac27b1a8bceb8d3e5cd594eb37db2448d7e314426
5
5
  SHA512:
6
- metadata.gz: 85a2d43f44c7d07d7c60c236f9a5c81c350233fca510c4a6503181f42ab2052a50e31094380e1a2ac752a55bb3ecdaf6923376a439197c459c1a71a66ed21364
7
- data.tar.gz: ddfee8db45e1545dfb7592e77396255a71d90e083c455a1507ee412da71482fee2a499c098c47ed59cbcc0b6abe46952cea8faa5b151d97b2521106c5e2f6bc8
6
+ metadata.gz: 77a634e5f5f9de6c004a2585e57379104062f11e6eee34532edb390c8200aeaba6d3fc3433ec21ccc6e6ee1a1a3aca22b47b99e83890982e9f8d4ef7400bd045
7
+ data.tar.gz: c13861e169c6bb26db7bde14c400186677f01c70c185bec7d07a374b6fdcde08717b27ef7233900da128072587af39393af5dc0584603085d0c8293eeba9aff1
data/CHANGELOG.md CHANGED
@@ -7,13 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-03
11
+
12
+ ### Added
13
+ - Forgejo issue tracking through the shared provider contract with selected-server authority checks and parsed issue evidence.
14
+
15
+ ### Fixed
16
+ - Missing tracking labels are created on demand before attachment, forbidden organization-label listings fall back to repository labels, and fj credentials are found in the real macOS bundle directories (forgejo-cli.forgejo-cli, legacy Cyborus.forgejo-cli).
17
+
10
18
  ## [0.4.0] - 2026-10-02
11
19
 
12
20
  ### Added
13
21
  - Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
14
22
  - Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
15
23
 
16
-
17
24
  ## [0.3.0] - 2026-09-29
18
25
 
19
26
  ### Added
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "repository_binding"
8
+ require "ace/git/atoms/server_url"
9
+
10
+ module Ace
11
+ module Git
12
+ module Forgejo
13
+ # Structured issue operations unavailable through fj's output contract.
14
+ # Uses the same exact selected host and the token from fj's keys file.
15
+ class IssueApi
16
+ def initialize(server:, timeout:, runner: nil)
17
+ # Clone URLs may be SSH-style; the HTTP issue client always talks
18
+ # to the verified HTTPS web endpoint derived from that identity.
19
+ @target = RepositoryBinding::Target.resolve(Ace::Git::Atoms::ServerUrl.web_base(server.url))
20
+ @timeout = timeout
21
+ @runner = runner
22
+ end
23
+
24
+ def issue(number)
25
+ request(:get, "issues/#{number}")
26
+ end
27
+
28
+ def comments(number)
29
+ paginate("issues/#{number}/comments")
30
+ end
31
+
32
+ def create_comment(number, body)
33
+ request(:post, "issues/#{number}/comments", body: {body: body})
34
+ end
35
+
36
+ def update_comment(comment_id, body)
37
+ request(:patch, "issues/comments/#{comment_id}", body: {body: body})
38
+ end
39
+
40
+ def delete_comment(comment_id)
41
+ request(:delete, "issues/comments/#{comment_id}")
42
+ end
43
+
44
+ def add_label(number, label_id)
45
+ request(:post, "issues/#{number}/labels", body: {labels: [label_id]})
46
+ end
47
+
48
+ def remove_label(number, label_id)
49
+ request(:delete, "issues/#{number}/labels/#{label_id}")
50
+ end
51
+
52
+ def repository_labels(wanted: nil)
53
+ labels = paginate("labels")
54
+ # Repository labels satisfy the common case; the organization label
55
+ # listing is an extra request that can fail (403 org scope) and must
56
+ # not block linking when the wanted label is already available.
57
+ return labels if wanted && labels.any? { |entry| entry["name"] == wanted }
58
+
59
+ owner = @target.repo.split("/", 2).first
60
+ begin
61
+ labels + paginate("/api/v1/orgs/#{owner}/labels")
62
+ rescue Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderAuthenticationError
63
+ # Organization labels are an optional supplement: 404 means no org
64
+ # scope exists and 401/403 mean the token cannot read it. Both
65
+ # leave the repository labels authoritative; errors from the
66
+ # repository-label request above still propagate.
67
+ labels
68
+ end
69
+ end
70
+
71
+ def create_repo_label(name:, color:)
72
+ request(:post, "labels", body: {name: name, color: color})
73
+ end
74
+
75
+ def set_state(number, state)
76
+ request(:patch, "issues/#{number}", body: {state: state.to_s})
77
+ end
78
+
79
+ private
80
+
81
+ def paginate(suffix)
82
+ page = 1
83
+ entries = []
84
+ seen = []
85
+ loop do
86
+ batch = request(:get, "#{suffix}?page=#{page}&limit=50")
87
+ unless batch.is_a?(Array)
88
+ raise Ace::Git::ProviderMalformedOutputError, "Forgejo #{suffix} did not return a list"
89
+ end
90
+ before = entries.length
91
+ # Stop when a page adds no new items (unique ids): instances
92
+ # may cap pages below the requested limit, so a short page alone
93
+ # does not prove the end of the collection.
94
+ fresh = batch.reject { |item| seen.include?(item["id"]) }
95
+ entries.concat(fresh)
96
+ fresh.each { |item| seen << item["id"] }
97
+ break if fresh.empty?
98
+
99
+ page += 1
100
+ end
101
+ entries
102
+ end
103
+
104
+ def request(method, suffix, body: nil)
105
+ RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
106
+ path = suffix.start_with?("/api/v1/") ? suffix : "/api/v1/repos/#{@target.repo}/#{suffix}"
107
+ if @runner
108
+ result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
109
+ timeout: @timeout, env: {})
110
+ status = result[:status] || (result[:success] ? 200 : 503)
111
+ payload = result[:stdout].to_s
112
+ else
113
+ uri = URI.parse("#{@target.host_url}#{path}")
114
+ token = access_token!
115
+ klass = {get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch,
116
+ delete: Net::HTTP::Delete}.fetch(method)
117
+ req = klass.new(uri)
118
+ req["Authorization"] = "token #{token}"
119
+ req["Accept"] = "application/json"
120
+ req["Content-Type"] = "application/json" if body
121
+ req.body = JSON.generate(body) if body
122
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
123
+ open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
124
+ status = response.code.to_i
125
+ payload = response.body.to_s
126
+ end
127
+ classify_status!(status, method, path)
128
+ return nil if payload.empty?
129
+
130
+ JSON.parse(payload)
131
+ rescue JSON::ParserError => e
132
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo issue response: #{e.message}"
133
+ rescue Net::OpenTimeout, Net::ReadTimeout, IOError, SocketError, SystemCallError,
134
+ OpenSSL::SSL::SSLError => e
135
+ error = method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
136
+ raise error, "Forgejo issue #{method} #{path} outcome unknown: #{e.class}"
137
+ end
138
+
139
+ def access_token!
140
+ path = RepositoryBinding.default_keys_path
141
+ data = path && JSON.parse(File.read(path))
142
+ login = data.is_a?(Hash) && data.fetch("hosts", {})[@target.authority]
143
+ token = login.is_a?(Hash) && login["token"]
144
+ return token if token.is_a?(String) && !token.empty?
145
+
146
+ raise Ace::Git::ProviderAuthenticationError,
147
+ "No fj token for selected host #{@target.host_url}; authenticate with fj first"
148
+ rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
149
+ raise Ace::Git::ProviderAuthenticationError,
150
+ "Cannot read fj authentication for selected host #{@target.host_url}"
151
+ end
152
+
153
+ def classify_status!(status, method, path)
154
+ return if (200..299).cover?(status.to_i)
155
+
156
+ error = case status.to_i
157
+ when 401, 403 then Ace::Git::ProviderAuthenticationError
158
+ when 404 then Ace::Git::ProviderObjectNotFoundError
159
+ # A conflict on a mutation does not prove ownership was rejected
160
+ # before the write: earlier requests in the same sync may already
161
+ # have committed (e.g. the tracking comment). Uncertainty keeps the
162
+ # local recovery identity instead of deleting the task.
163
+ when 409, 422 then method == :get ? Ace::Git::ProviderIdentityMismatchError : Ace::Git::ProviderUnknownOutcomeError
164
+ else method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
165
+ end
166
+ raise error, "Forgejo issue #{method} #{path} returned HTTP #{status}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -119,19 +119,31 @@ module Ace
119
119
  tasks = []
120
120
  declared_count = nil
121
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.
122
+ # `fj` emits exactly one leading "<n> tasks" count header
123
+ # before task lines; require it (including 0 tasks).
123
124
  if (header = line.match(/\A(\d+) tasks?\z/i))
125
+ raise Ace::Git::ProviderMalformedOutputError,
126
+ "Duplicate `fj actions tasks` count header" if declared_count
124
127
  declared_count = header[1].to_i
125
128
  next
126
129
  end
130
+ raise Ace::Git::ProviderMalformedOutputError,
131
+ "Missing `fj actions tasks` count header" unless declared_count
127
132
 
128
133
  match = line.match(
129
134
  /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
130
135
  )
131
136
  # Any other nonempty line that cannot be parsed must not silently
132
- # shrink check evidence; fail closed instead.
137
+ # shrink check evidence; fail closed instead. The SHA may be
138
+ # abbreviated; the provider binds it to the reviewed head by
139
+ # prefix, so anything shorter than Git's conventional minimum
140
+ # abbreviation cannot establish commit identity and is rejected.
133
141
  raise Ace::Git::ProviderMalformedOutputError,
134
142
  "Unrecognized `fj actions tasks` output line: #{line[0, 80]}" unless match
143
+ unless match[:sha].length.between?(7, 40)
144
+ raise Ace::Git::ProviderMalformedOutputError,
145
+ "Unbindable `fj actions tasks` SHA (need 7-40 hex chars): #{match[:sha]}"
146
+ end
135
147
 
136
148
  tasks << {
137
149
  name: match[:name],
@@ -139,10 +151,15 @@ module Ace
139
151
  sha: match[:sha]
140
152
  }
141
153
  end
154
+ # Empty or whitespace-only output never reaches the in-loop header
155
+ # check; the contract requires the count header even for zero tasks,
156
+ # so fail closed instead of returning an empty task list.
157
+ raise Ace::Git::ProviderMalformedOutputError,
158
+ "Missing `fj actions tasks` count header" unless declared_count
142
159
  # A declared count that disagrees with the parsed rows means
143
160
  # truncated output; fail closed instead of accepting partial
144
161
  # check evidence.
145
- if declared_count && declared_count != tasks.length
162
+ if declared_count != tasks.length
146
163
  raise Ace::Git::ProviderMalformedOutputError,
147
164
  "Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
148
165
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "uri"
5
5
  require_relative "cli_executor"
6
6
  require_relative "parsers"
7
+ require_relative "issue_api"
7
8
 
8
9
  module Ace
9
10
  module Git
@@ -20,6 +21,9 @@ module Ace
20
21
  # `fj` failures are never hidden behind ad-hoc curl calls or silent
21
22
  # fallbacks.
22
23
  class Provider < Ace::Git::Providers::Base
24
+ # Fill color for repo labels created on demand by issue linking.
25
+ TRACKED_LABEL_COLOR = "0e8a16"
26
+
23
27
  class << self
24
28
  def display_name
25
29
  "Forgejo"
@@ -109,13 +113,94 @@ module Ace
109
113
  )
110
114
  end
111
115
 
116
+ def issue_tracking(number:)
117
+ data = issue_api.issue(request_number!(number))
118
+ unless data.is_a?(Hash) && data["number"].to_i == number.to_i &&
119
+ data["html_url"].to_s == issue_url(number.to_i)
120
+ raise Ace::Git::ProviderIdentityMismatchError, "Issue ##{number} is not in #{server.url}"
121
+ end
122
+ evidence = Ace::Git::ProviderIssue.new(
123
+ server_name: server.name, number: data["number"], title: data["title"],
124
+ state: normalize_state(data["state"]),
125
+ author: data.dig("user", "login"), url: data["html_url"],
126
+ labels: Array(data["labels"]).map { |label| label["name"] }
127
+ )
128
+ {issue: evidence,
129
+ comments: Array(issue_api.comments(number)).map { |c| {id: c.fetch("id"), body: c["body"].to_s} },
130
+ labels: evidence.labels}
131
+ end
132
+
133
+ # Unrecognized or missing state is malformed evidence: mapping it to
134
+ # :closed would let sync close an issue without valid state proof.
135
+ def normalize_state(value)
136
+ case value.to_s
137
+ when "open" then :open
138
+ when "closed" then :closed
139
+ else raise Ace::Git::ProviderMalformedOutputError, "Unknown issue state #{value.inspect}"
140
+ end
141
+ end
142
+
143
+ def create_issue_comment(number:, body:)
144
+ issue_api.create_comment(request_number!(number), body)
145
+ end
146
+
147
+ def update_issue_comment(number:, comment_id:, body:)
148
+ assert_issue_comment_owned!(number, comment_id)
149
+ issue_api.update_comment(Integer(comment_id), body)
150
+ end
151
+
152
+ def delete_issue_comment(number:, comment_id:)
153
+ assert_issue_comment_owned!(number, comment_id)
154
+ issue_api.delete_comment(Integer(comment_id))
155
+ end
156
+
157
+ def add_issue_label(number:, label:)
158
+ issue_api.add_label(request_number!(number), ensure_repo_label_id(label))
159
+ end
160
+
161
+ def remove_issue_label(number:, label:)
162
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
163
+ return unless match
164
+
165
+ issue_api.remove_label(request_number!(number), match.fetch("id"))
166
+ end
167
+
168
+ # Fresh repositories lack the tracking label; creating it on demand
169
+ # keeps issue linking from committing a tracking comment it could
170
+ # never label. A concurrent creator surfaces as an uncertain mutation
171
+ # outcome, which only means re-list and attach what appeared.
172
+ def ensure_repo_label_id(label)
173
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
174
+ return match["id"] if match && match["id"].to_i.positive?
175
+
176
+ begin
177
+ issue_api.create_repo_label(name: label, color: TRACKED_LABEL_COLOR)
178
+ rescue Ace::Git::ProviderUnknownOutcomeError
179
+ nil
180
+ end
181
+ match = Array(issue_api.repository_labels(wanted: label)).find { |entry| entry["name"] == label }
182
+ unless match && match["id"].to_i.positive?
183
+ raise Ace::Git::ProviderObjectNotFoundError,
184
+ "Forgejo label #{label.inspect} could not be created or found on #{server.url}"
185
+ end
186
+ match["id"]
187
+ end
188
+
189
+ def set_issue_state(number:, state:)
190
+ raise ArgumentError, "Invalid issue state #{state.inspect}" unless %i[open closed].include?(state)
191
+
192
+ issue_api.set_state(request_number!(number), state)
193
+ end
194
+
112
195
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
113
196
  def checks(ref:)
114
197
  tasks = fj(:actions_tasks)
115
198
  Parsers.parse_actions_tasks(tasks)
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 }
199
+ # Exact-head evidence binds each task to the reviewed head by SHA
200
+ # prefix: a full or abbreviated SHA that prefixes the reviewed
201
+ # head identifies that commit, and anything else is another
202
+ # head's task. No matching evidence is silently dropped.
203
+ .select { |task| ref.to_s.downcase.start_with?(task[:sha].downcase) }
119
204
  .map do |task|
120
205
  Ace::Git::ProviderCheck.new(
121
206
  server_name: server.name,
@@ -125,6 +210,13 @@ module Ace
125
210
  url: nil
126
211
  )
127
212
  end
213
+ rescue Ace::Git::ProviderObjectNotFoundError
214
+ # A repository with Actions disabled serves no tasks endpoint. The
215
+ # review flows resolve the repository binding and the PR before
216
+ # collecting checks, so a not-found here means CI is unavailable,
217
+ # not a missing repository; empty check evidence keeps the review
218
+ # collectable. Parse and transport failures still fail closed.
219
+ []
128
220
  end
129
221
 
130
222
  # @return [ProviderRepository] normalized repository evidence
@@ -261,7 +353,7 @@ module Ace
261
353
  end
262
354
  begin
263
355
  matches = matching_review_comments(number, marker, expected_head)
264
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
356
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
265
357
  raise Ace::Git::ProviderUnknownOutcomeError,
266
358
  "PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
267
359
  end
@@ -314,7 +406,7 @@ module Ace
314
406
  # Fetch individually and validate the Forgejo issue identity.
315
407
  begin
316
408
  updated = review_http.request(:get, update_route)
317
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
409
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
318
410
  raise Ace::Git::ProviderUnknownOutcomeError,
319
411
  "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
320
412
  end
@@ -446,7 +538,7 @@ module Ace
446
538
  def verify_post_mutation_head!(number, expected_head, correlation)
447
539
  current = begin
448
540
  pull_request(number: number)
449
- rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderUnreachableError => e
541
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
450
542
  raise Ace::Git::ProviderUnknownOutcomeError,
451
543
  "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
452
544
  end
@@ -477,25 +569,29 @@ module Ace
477
569
 
478
570
  def matching_review_comments(number, marker, expected_head)
479
571
  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)
572
+ unless entry.is_a?(Hash) && entry["body"].is_a?(String)
573
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment collection"
574
+ end
575
+ next unless entry["body"].include?(marker)
481
576
 
482
577
  review_comment(entry, number, expected_head)
483
578
  end
484
579
  end
485
580
 
486
581
  def review_comment(entry, number, head)
582
+ user = entry.is_a?(Hash) ? entry["user"] : nil
487
583
  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)
584
+ user.is_a?(Hash) && user["login"].is_a?(String)
489
585
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
490
586
  end
491
587
  Ace::Git::ProviderReviewComment.new(
492
588
  server_name: server.name, repository_url: server.url, pr_number: number,
493
- id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
589
+ id: entry["id"], author: user["login"], body: entry["body"],
494
590
  url: entry["html_url"], path: entry["path"],
495
591
  # Forgejo supplies position/original_position rather than
496
592
  # line; the positive side position is the normalized line.
497
593
  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,
594
+ head_sha: entry["commit_id"], resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
499
595
  thread_id: nil
500
596
  )
501
597
  end
@@ -512,18 +608,28 @@ module Ace
512
608
  end
513
609
 
514
610
  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) &&
611
+ unless entry.is_a?(Hash)
612
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
613
+ end
614
+ user, team = entry["user"], entry["team"]
615
+ unless user.nil? || user.is_a?(Hash)
616
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: user must be an object"
617
+ end
618
+ unless team.nil? || team.is_a?(Hash)
619
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence: team must be an object"
620
+ end
621
+ reviewer = user&.[]("login") || team&.[]("name")
622
+ unless entry["id"].is_a?(Integer) && entry["id"].positive? && reviewer.is_a?(String) &&
517
623
  entry["state"].is_a?(String) && !entry["state"].empty?
518
624
  raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
519
625
  end
520
626
  Ace::Git::ProviderReview.new(
521
627
  server_name: server.name, repository_url: server.url, pr_number: number,
522
- id: entry["id"], author: entry.dig("user", "login"), body: entry["body"],
628
+ id: entry["id"], author: reviewer, body: entry["body"],
523
629
  # Forgejo reports REQUEST_CHANGES; consumers count only the
524
630
  # canonical CHANGES_REQUESTED spelling.
525
631
  state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
526
- url: entry["html_url"], head_sha: entry["commit_id"] || head
632
+ url: entry["html_url"], head_sha: entry["commit_id"]
527
633
  )
528
634
  end
529
635
 
@@ -534,6 +640,17 @@ module Ace
534
640
  )
535
641
  end
536
642
 
643
+ def assert_issue_comment_owned!(number, comment_id)
644
+ return if issue_tracking(number: number)[:comments].any? { |comment| comment[:id].to_i == comment_id.to_i }
645
+
646
+ raise Ace::Git::ProviderIdentityMismatchError,
647
+ "Comment #{comment_id} is not on selected issue ##{number} in #{server.url}"
648
+ end
649
+
650
+ def issue_api
651
+ @issue_api ||= IssueApi.new(server: server, timeout: timeout, runner: runner)
652
+ end
653
+
537
654
  # Validated selected repository identity. Built once; malformed
538
655
  # selections fail as configuration errors before any subprocess.
539
656
  def repository_target
@@ -788,8 +905,9 @@ module Ace
788
905
  end
789
906
 
790
907
  def issue_url(number)
791
- base = repository_target.url.to_s.chomp("/")
792
- "#{base}/issues/#{number}"
908
+ # Identity evidence comes from the web endpoint, not the clone URL
909
+ # (which may be SSH-style and is not a parseable web URL).
910
+ "#{Ace::Git::Atoms::ServerUrl.web_base(server.url)}/issues/#{number}"
793
911
  end
794
912
  end
795
913
  end
@@ -175,8 +175,10 @@ module Ace
175
175
  nil
176
176
  end
177
177
 
178
- # Observed forgejo-cli v0.6.0 keys-file locations (data_dir of the
179
- # `directories` crate, plus the legacy Cyborus org path).
178
+ # Observed forgejo-cli keys-file locations (data_dir of the
179
+ # `directories` crate, plus the legacy Cyborus org path). On macOS
180
+ # the real CLI uses bundle-dir names: forgejo-cli.forgejo-cli
181
+ # (current) and Cyborus.forgejo-cli (legacy).
180
182
  def default_keys_path
181
183
  candidates = []
182
184
  xdg = ENV["XDG_DATA_HOME"]
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.4.0"
6
+ VERSION = "0.5.0"
7
7
  end
8
8
  end
9
9
  end
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-02 00:00:00.000000000 Z
10
+ date: 2026-10-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git
@@ -40,6 +40,7 @@ files:
40
40
  - lib/ace/git/forgejo.rb
41
41
  - lib/ace/git/forgejo/cli_executor.rb
42
42
  - lib/ace/git/forgejo/http_client.rb
43
+ - lib/ace/git/forgejo/issue_api.rb
43
44
  - lib/ace/git/forgejo/parsers.rb
44
45
  - lib/ace/git/forgejo/pr_identifier.rb
45
46
  - lib/ace/git/forgejo/provider.rb