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 +4 -4
- data/CHANGELOG.md +8 -1
- data/lib/ace/git/forgejo/issue_api.rb +171 -0
- data/lib/ace/git/forgejo/parsers.rb +20 -3
- data/lib/ace/git/forgejo/provider.rb +134 -16
- data/lib/ace/git/forgejo/repository_binding.rb +4 -2
- data/lib/ace/git/forgejo/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7388b7c2b27b633234131dca7f190a344c7e1f323e23e2e01b532ed3a16407a
|
|
4
|
+
data.tar.gz: 3960cdfc787b85500050589ac27b1a8bceb8d3e5cd594eb37db2448d7e314426
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
117
|
-
#
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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"]
|
|
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
|
-
|
|
516
|
-
|
|
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:
|
|
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"]
|
|
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
|
-
|
|
792
|
-
|
|
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
|
|
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"]
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-forgejo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.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-
|
|
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
|