ace-git-forgejo 0.3.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: c8a35836d33fbf6fc14e52f4c27ef396244d1f809bc919ceca6ac0202db5e583
4
- data.tar.gz: 548b2f7734d7f98ef2594e9630ddeb499ae9ead7f8c4297498ebe8726217c4a2
3
+ metadata.gz: e7388b7c2b27b633234131dca7f190a344c7e1f323e23e2e01b532ed3a16407a
4
+ data.tar.gz: 3960cdfc787b85500050589ac27b1a8bceb8d3e5cd594eb37db2448d7e314426
5
5
  SHA512:
6
- metadata.gz: c19a8e1f44853f9c173e51118a42f1de9fd6ab04d77c01371c68f16d867f654b938f4cf5b252d8f7d4300c4bdcca826c53ed7dafddc6e616051fab8292aa457d
7
- data.tar.gz: b312fbb4a37d5e871e55148f40e90951ebc2692c474d87e0052adb6beb0647fb24347b3599b914c7a4c7a93f9ca4726f201bd67301f4ba84ded528db979168da
6
+ metadata.gz: 77a634e5f5f9de6c004a2585e57379104062f11e6eee34532edb390c8200aeaba6d3fc3433ec21ccc6e6ee1a1a3aca22b47b99e83890982e9f8d4ef7400bd045
7
+ data.tar.gz: c13861e169c6bb26db7bde14c400186677f01c70c185bec7d07a374b6fdcde08717b27ef7233900da128072587af39393af5dc0584603085d0c8293eeba9aff1
data/CHANGELOG.md CHANGED
@@ -7,6 +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
+
18
+ ## [0.4.0] - 2026-10-02
19
+
20
+ ### Added
21
+ - Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
22
+ - Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
23
+
10
24
  ## [0.3.0] - 2026-09-29
11
25
 
12
26
  ### 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
@@ -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
@@ -116,18 +116,54 @@ 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 exactly one leading "<n> tasks" count header
123
+ # before task lines; require it (including 0 tasks).
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
127
+ declared_count = header[1].to_i
128
+ next
129
+ end
130
+ raise Ace::Git::ProviderMalformedOutputError,
131
+ "Missing `fj actions tasks` count header" unless declared_count
132
+
120
133
  match = line.match(
121
- /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?:push|pull_request|schedule)\)/
134
+ /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
122
135
  )
123
- next nil unless match
124
-
125
- {
136
+ # Any other nonempty line that cannot be parsed must not silently
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.
141
+ raise Ace::Git::ProviderMalformedOutputError,
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
147
+
148
+ tasks << {
126
149
  name: match[:name],
127
150
  state: match[:state].downcase.to_sym,
128
151
  sha: match[:sha]
129
152
  }
130
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
159
+ # A declared count that disagrees with the parsed rows means
160
+ # truncated output; fail closed instead of accepting partial
161
+ # check evidence.
162
+ if declared_count != tasks.length
163
+ raise Ace::Git::ProviderMalformedOutputError,
164
+ "Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
165
+ end
166
+ tasks
131
167
  end
132
168
 
133
169
  # Parse `fj repo view` output.
@@ -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,11 +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
- .select { |task| task[:sha].start_with?(ref.to_s) || ref.to_s.start_with?(task[:sha]) }
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) }
117
204
  .map do |task|
118
205
  Ace::Git::ProviderCheck.new(
119
206
  server_name: server.name,
@@ -123,6 +210,13 @@ module Ace
123
210
  url: nil
124
211
  )
125
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
+ []
126
220
  end
127
221
 
128
222
  # @return [ProviderRepository] normalized repository evidence
@@ -142,6 +236,201 @@ module Ace
142
236
  )
143
237
  end
144
238
 
239
+ # The fj CLI surface omits the PR body; the repository-bound API
240
+ # supplies it for review evidence. Malformed payloads fail closed.
241
+ def pull_request_body(number:)
242
+ number = request_number!(number)
243
+ data = review_http.request(:get, "pulls/#{number}")
244
+ unless data.is_a?(Hash) && data["number"] == number && data.key?("body") &&
245
+ (data["body"].nil? || data["body"].is_a?(String))
246
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR body evidence"
247
+ end
248
+ data["body"]
249
+ end
250
+
251
+ def pull_request_review_evidence(number:, expected_head:)
252
+ number = request_number!(number)
253
+ verify_expected_head!(pull_request(number: number), expected_head)
254
+ comments = review_http.paginate("issues/#{number}/comments").map do |entry|
255
+ review_comment(entry, number, expected_head)
256
+ end
257
+ reviews = review_http.paginate("pulls/#{number}/reviews").map do |entry|
258
+ review_entry(entry, number, expected_head)
259
+ end
260
+ # Review comments live under each review, not at the PR level.
261
+ inline = reviews.flat_map do |review|
262
+ # The per-review endpoint returns the complete list (no
263
+ # pagination); a repeated nonempty page would trip the loop
264
+ # guard, so fetch once and require an array.
265
+ entries = review_http.request(:get, "pulls/#{number}/reviews/#{review.id}/comments")
266
+ unless entries.is_a?(Array)
267
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
268
+ end
269
+ entries.map { |entry| review_comment(entry, number, expected_head) }
270
+ end
271
+ verify_expected_head!(pull_request(number: number), expected_head)
272
+ Ace::Git::ProviderReviewEvidence.new(
273
+ server_name: server.name, repository_url: server.url,
274
+ pr_number: number, head_sha: expected_head,
275
+ comments: comments + inline, reviews: reviews
276
+ )
277
+ end
278
+
279
+ def pull_request_review_details(number:)
280
+ number = request_number!(number)
281
+ data = review_http.request(:get, "pulls/#{number}")
282
+ files = review_http.paginate("pulls/#{number}/files").map do |entry|
283
+ entry.is_a?(Hash) && entry["filename"]
284
+ end
285
+ unless data.is_a?(Hash) && data["base"].is_a?(Hash) && data["number"] == number &&
286
+ data.dig("base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/) &&
287
+ data["changed_files"].is_a?(Integer) && data["changed_files"] == files.length &&
288
+ files.all? { |path| path.is_a?(String) && !path.empty? }
289
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed or incomplete Forgejo PR file evidence"
290
+ end
291
+ Ace::Git::ProviderReviewDetails.new(
292
+ server_name: server.name, repository_url: server.url, pr_number: number,
293
+ base_sha: data.dig("base", "sha"), files: files
294
+ )
295
+ end
296
+
297
+ def pull_request_checks(number:, head_sha:)
298
+ verify_expected_head!(pull_request(number: number), head_sha)
299
+ checks(ref: head_sha)
300
+ end
301
+
302
+ def repository_file(path:, ref:)
303
+ require "base64"
304
+ # %20, not form encoding: "+" in a path segment is a literal plus.
305
+ # Explicit unreserved-character allowlist: percent-encode
306
+ # everything else (DEFAULT_PARSER leaves ? and + ambiguous).
307
+ escaped = path.split("/").map { |part|
308
+ part.gsub(/[^A-Za-z0-9._~!$&'()*+,;=@:-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
309
+ }.join("/")
310
+ data = review_http.request(:get, "contents/#{escaped}?ref=#{ref}")
311
+ unless data.is_a?(Hash) && data["encoding"] == "base64" && data["content"].is_a?(String)
312
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file evidence"
313
+ end
314
+ Base64.strict_decode64(data["content"].delete("\n"))
315
+ rescue ArgumentError => e
316
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo repository file content: #{e.message}"
317
+ end
318
+
319
+ def create_pull_request_comment(number:, expected_head:, body:, correlation:)
320
+ number = request_number!(number)
321
+ pr = verify_expected_head!(pull_request(number: number), expected_head)
322
+ require_open_pr!(pr)
323
+ marker = comment_marker(correlation)
324
+ with_session_lock(correlation) do
325
+ existing = matching_review_comments(number, marker, expected_head)
326
+ if existing.length > 1
327
+ raise Ace::Git::ProviderConflictingMatchesError,
328
+ "Multiple comments match review session #{correlation} on PR ##{number}"
329
+ end
330
+ if existing.one?
331
+ # A repeat reconciles only the exact session comment; a marker
332
+ # match with different content is a conflict, never our post.
333
+ sent = "#{body}\n\n#{marker}"
334
+ if existing.first.body == sent
335
+ verify_post_mutation_head!(number, expected_head, correlation)
336
+ return review_mutation(number, expected_head, existing.first, :existing)
337
+ end
338
+
339
+ raise Ace::Git::ProviderConflictingMatchesError,
340
+ "Review session #{correlation} comment exists on PR ##{number} with different content"
341
+ end
342
+ second = verify_expected_head!(pull_request(number: number), expected_head)
343
+ require_open_pr!(second)
344
+ begin
345
+ review_http.request(:post, "issues/#{number}/comments", body: {body: "#{body}\n\n#{marker}"})
346
+ rescue Ace::Git::ProviderMalformedOutputError => e
347
+ raise Ace::Git::ProviderUnknownOutcomeError,
348
+ "PR comment sent but response unreadable for #{server.name}/#{number}, head #{expected_head}, session #{correlation}: #{e.message}; reconcile before repeating"
349
+ rescue Ace::Git::ProviderUnknownOutcomeError => e
350
+ raise Ace::Git::ProviderUnknownOutcomeError,
351
+ "PR comment outcome unknown for #{server.name}/#{number}, head #{expected_head}, " \
352
+ "session #{correlation}: #{e.message}; reconcile before repeating"
353
+ end
354
+ begin
355
+ matches = matching_review_comments(number, marker, expected_head)
356
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
357
+ raise Ace::Git::ProviderUnknownOutcomeError,
358
+ "PR comment sent but reconciliation read failed for session #{correlation}: #{e.message}; reconcile before repeating"
359
+ end
360
+ unless matches.one? && matches.first.body == "#{body}\n\n#{marker}"
361
+ raise Ace::Git::ProviderUnknownOutcomeError,
362
+ "PR comment sent but reconciliation found #{matches.length} exact-content match(es) for session #{correlation}"
363
+ end
364
+ verify_post_mutation_head!(number, expected_head, correlation)
365
+ review_mutation(number, expected_head, matches.first, :created)
366
+ end
367
+ end
368
+
369
+ def update_pull_request_comment(number:, expected_head:, comment_id:, body:)
370
+ number = request_number!(number)
371
+ comment_id = Integer(comment_id)
372
+ verify_expected_head!(pull_request(number: number), expected_head)
373
+ issue_matches = review_http.paginate("issues/#{number}/comments").select { |entry| entry["id"] == comment_id }
374
+ # Inline ids live under individual reviews; locate them there
375
+ # so an unsupported edit is classified explicitly instead of
376
+ # being misread as not-belonging to this PR.
377
+ inline_found = issue_matches.empty? && review_comment_ids(number).include?(comment_id)
378
+ if inline_found
379
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
380
+ "Editing inline review comments is unsupported by the observed fj v0.6.0 surface (comment #{comment_id})"
381
+ end
382
+ matches = issue_matches
383
+ update_route = "issues/comments/#{comment_id}"
384
+ unless matches.one?
385
+ raise Ace::Git::ProviderIdentityMismatchError,
386
+ "Comment #{comment_id} does not belong to selected PR ##{number}"
387
+ end
388
+ comment = review_comment(matches.first, number, expected_head)
389
+ if comment.body == body
390
+ verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
391
+ return review_mutation(number, expected_head, comment, :existing)
392
+ end
393
+
394
+ verify_expected_head!(pull_request(number: number), expected_head)
395
+ begin
396
+ review_http.request(:patch, update_route, body: {body: body})
397
+ rescue Ace::Git::ProviderMalformedOutputError => e
398
+ raise Ace::Git::ProviderUnknownOutcomeError,
399
+ "Comment update sent but response unreadable for #{server.name}/#{number}, comment #{comment_id}: #{e.message}; reconcile before repeating"
400
+ rescue Ace::Git::ProviderUnknownOutcomeError => e
401
+ raise Ace::Git::ProviderUnknownOutcomeError,
402
+ "Comment update outcome unknown for #{server.name}/#{number}, " \
403
+ "head #{expected_head}, comment #{comment_id}: #{e.message}"
404
+ end
405
+ begin
406
+ # Fetch individually and validate the Forgejo issue identity.
407
+ begin
408
+ updated = review_http.request(:get, update_route)
409
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
410
+ raise Ace::Git::ProviderUnknownOutcomeError,
411
+ "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
412
+ end
413
+ unless updated.is_a?(Hash) && updated["issue_url"].to_s.end_with?("/#{number}")
414
+ raise Ace::Git::ProviderUnknownOutcomeError,
415
+ "Comment update sent but edited comment #{comment_id} does not verify against PR ##{number}; reconcile before repeating"
416
+ end
417
+ rescue Ace::Git::ProviderUnreachableError => e
418
+ raise Ace::Git::ProviderUnknownOutcomeError,
419
+ "Comment update sent but verification read failed for comment #{comment_id}: #{e.message}; reconcile before repeating"
420
+ end
421
+ unless updated && updated["id"] == comment_id && updated["body"] == body
422
+ raise Ace::Git::ProviderUnknownOutcomeError,
423
+ "Comment update sent but exact PR comment #{comment_id} could not be verified"
424
+ end
425
+ verify_post_mutation_head!(number, expected_head, "comment #{comment_id}")
426
+ review_mutation(number, expected_head, review_comment(updated, number, expected_head), :updated)
427
+ end
428
+
429
+ def resolve_pull_request_thread(number:, expected_head:, thread_id:)
430
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
431
+ "Forgejo does not expose an observed repository-bound review thread resolution capability"
432
+ end
433
+
145
434
  # ---- PR lifecycle mutations ----
146
435
 
147
436
  # @return [Array<ProviderPullRequest>] open PRs matching the exact
@@ -233,6 +522,135 @@ module Ace
233
522
 
234
523
  private
235
524
 
525
+ def require_open_pr!(pr)
526
+ return if pr.state == :open
527
+
528
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
529
+ "Cannot post review comment to PR ##{pr.number} in #{pr.state} state"
530
+ end
531
+
532
+ def review_http
533
+ @review_http ||= HttpClient.new(server: server, timeout: timeout, runner: runner)
534
+ end
535
+
536
+ # A mutation receipt may only bind the expected head; if the PR head
537
+ # moved during the mutation the outcome stays uncertain.
538
+ def verify_post_mutation_head!(number, expected_head, correlation)
539
+ current = begin
540
+ pull_request(number: number)
541
+ rescue Ace::Git::ProviderMalformedOutputError, Ace::Git::ProviderAuthenticationError, Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderUnreachableError => e
542
+ raise Ace::Git::ProviderUnknownOutcomeError,
543
+ "Head verification read failed after mutation for #{server.name}: #{e.message}; reconcile before repeating"
544
+ end
545
+ return if current.head_sha == expected_head
546
+
547
+ raise Ace::Git::ProviderUnknownOutcomeError,
548
+ "PR head moved during mutation for #{server.name}/##{number} " \
549
+ "(#{expected_head} -> #{current.head_sha}), session #{correlation}: reconcile before repeating"
550
+ end
551
+
552
+ # Serialize the read/post/reconcile sequence per session so two
553
+ # concurrent callers cannot both observe "no marker" and post.
554
+ def with_session_lock(correlation)
555
+ require "tmpdir"
556
+ lock_path = File.join(Dir.tmpdir, "ace-review-session-#{correlation}.lock")
557
+ File.open(lock_path, File::CREAT | File::RDWR, 0600) do |lock|
558
+ lock.flock(File::LOCK_EX)
559
+ yield
560
+ end
561
+ end
562
+
563
+ def comment_marker(correlation)
564
+ unless correlation.to_s.match?(/\A[a-zA-Z0-9._:-]+\z/)
565
+ raise Ace::Git::ConfigError, "Invalid review session correlation"
566
+ end
567
+ "<!-- ace-review-session:#{correlation} -->"
568
+ end
569
+
570
+ def matching_review_comments(number, marker, expected_head)
571
+ review_http.paginate("issues/#{number}/comments").filter_map do |entry|
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)
576
+
577
+ review_comment(entry, number, expected_head)
578
+ end
579
+ end
580
+
581
+ def review_comment(entry, number, head)
582
+ user = entry.is_a?(Hash) ? entry["user"] : nil
583
+ unless entry.is_a?(Hash) && entry["id"].is_a?(Integer) && entry["id"].positive? && entry["body"].is_a?(String) &&
584
+ user.is_a?(Hash) && user["login"].is_a?(String)
585
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR comment evidence"
586
+ end
587
+ Ace::Git::ProviderReviewComment.new(
588
+ server_name: server.name, repository_url: server.url, pr_number: number,
589
+ id: entry["id"], author: user["login"], body: entry["body"],
590
+ url: entry["html_url"], path: entry["path"],
591
+ # Forgejo supplies position/original_position rather than
592
+ # line; the positive side position is the normalized line.
593
+ line: entry["line"] || [entry["position"], entry["original_position"]].compact.reject { |v| !v.is_a?(Integer) || v <= 0 }.first,
594
+ head_sha: entry["commit_id"], resolved: entry.key?("resolver") ? !entry["resolver"].nil? : nil,
595
+ thread_id: nil
596
+ )
597
+ end
598
+
599
+ # Collect inline (review) comment ids by walking each review.
600
+ def review_comment_ids(number)
601
+ review_http.paginate("pulls/#{number}/reviews").flat_map do |review|
602
+ entries = review_http.request(:get, "pulls/#{number}/reviews/#{review["id"]}/comments")
603
+ unless entries.is_a?(Array)
604
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo review comment evidence"
605
+ end
606
+ entries.filter_map { |entry| entry["id"] if entry.is_a?(Hash) && entry["id"].is_a?(Integer) }
607
+ end
608
+ end
609
+
610
+ def review_entry(entry, number, head)
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) &&
623
+ entry["state"].is_a?(String) && !entry["state"].empty?
624
+ raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo PR review evidence"
625
+ end
626
+ Ace::Git::ProviderReview.new(
627
+ server_name: server.name, repository_url: server.url, pr_number: number,
628
+ id: entry["id"], author: reviewer, body: entry["body"],
629
+ # Forgejo reports REQUEST_CHANGES; consumers count only the
630
+ # canonical CHANGES_REQUESTED spelling.
631
+ state: (entry["state"] == "REQUEST_CHANGES") ? "CHANGES_REQUESTED" : entry["state"],
632
+ url: entry["html_url"], head_sha: entry["commit_id"]
633
+ )
634
+ end
635
+
636
+ def review_mutation(number, head, comment, idempotency)
637
+ Ace::Git::ProviderReviewMutation.new(
638
+ server_name: server.name, repository_url: server.url, pr_number: number,
639
+ head_sha: head, comment: comment, idempotency: idempotency
640
+ )
641
+ end
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
+
236
654
  # Validated selected repository identity. Built once; malformed
237
655
  # selections fail as configuration errors before any subprocess.
238
656
  def repository_target
@@ -384,6 +802,7 @@ module Ace
384
802
  server_name: server.name,
385
803
  number: parsed[:number],
386
804
  title: parsed[:title],
805
+ body: parsed[:body],
387
806
  state: parsed[:state],
388
807
  head_ref: parsed[:head_ref],
389
808
  base_ref: parsed[:base_ref],
@@ -423,6 +842,7 @@ module Ace
423
842
  server_name: server.name,
424
843
  number: entry[:number],
425
844
  title: entry[:title],
845
+ body: nil,
426
846
  state: nil,
427
847
  head_ref: nil,
428
848
  base_ref: nil,
@@ -478,13 +898,16 @@ module Ace
478
898
  end
479
899
 
480
900
  def pr_url(number)
481
- base = repository_target.url.to_s.chomp("/")
901
+ # A configured URL may end in .git (clone form); the web URL
902
+ # must be built from the repository page base instead.
903
+ base = repository_target.url.to_s.chomp("/").sub(/\.git\z/i, "")
482
904
  "#{base}/pulls/#{number}"
483
905
  end
484
906
 
485
907
  def issue_url(number)
486
- base = repository_target.url.to_s.chomp("/")
487
- "#{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}"
488
911
  end
489
912
  end
490
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"]
@@ -185,7 +187,10 @@ module Ace
185
187
  if home
186
188
  candidates << File.join(home, ".local", "share", "forgejo-cli", "keys.json")
187
189
  candidates << File.join(home, "Library", "Application Support", "forgejo-cli", "keys.json")
190
+ # macOS app-support uses reverse-DNS bundle directories.
191
+ candidates << File.join(home, "Library", "Application Support", "forgejo-cli.forgejo-cli", "keys.json")
188
192
  candidates << File.join(home, ".local", "share", "Cyborus", "forgejo-cli", "keys.json")
193
+ candidates << File.join(home, "Library", "Application Support", "Cyborus.forgejo-cli", "keys.json")
189
194
  candidates << File.join(home, "Library", "Application Support", "Cyborus", "forgejo-cli", "keys.json")
190
195
  end
191
196
  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.5.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.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-01 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
@@ -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,8 @@ 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
43
+ - lib/ace/git/forgejo/issue_api.rb
42
44
  - lib/ace/git/forgejo/parsers.rb
43
45
  - lib/ace/git/forgejo/pr_identifier.rb
44
46
  - lib/ace/git/forgejo/provider.rb