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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/ace/git/forgejo/http_client.rb +111 -0
- data/lib/ace/git/forgejo/issue_api.rb +171 -0
- data/lib/ace/git/forgejo/parsers.rb +41 -5
- data/lib/ace/git/forgejo/provider.rb +427 -4
- data/lib/ace/git/forgejo/repository_binding.rb +7 -2
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +6 -4
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,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
|
-
|
|
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+\((
|
|
134
|
+
/\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
|
|
122
135
|
)
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
|
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) }
|
data/lib/ace/git/forgejo.rb
CHANGED
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
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
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.
|
|
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
|