ace-git-forgejo 0.1.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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +23 -0
- data/Rakefile +13 -0
- data/lib/ace/git/forgejo/cli_executor.rb +119 -0
- data/lib/ace/git/forgejo/parsers.rb +152 -0
- data/lib/ace/git/forgejo/pr_identifier.rb +49 -0
- data/lib/ace/git/forgejo/provider.rb +216 -0
- data/lib/ace/git/forgejo/version.rb +9 -0
- data/lib/ace/git/forgejo.rb +25 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dd545a2d89b063b16fe426301be445f5fd96e9831c90f4f3cbc8bcdf5a6cd535
|
|
4
|
+
data.tar.gz: a0d960a767bb9c94286d1f3cf0f0d2faa90bff6e1d27e798acd4559c4944f987
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 61487148163544e819c66ad42c63be3de3d350b641162650d49094f78186673820dbf47a4ae9fa29ccddacc333eebddeb6f884c5e8259496a780a62f101c7ce1
|
|
7
|
+
data.tar.gz: 1c3d7f3985b77e4cb0104b7be71c22245c63fdacc4fd4f3148931fbee5d57f335565edaf4dcf7fdd5b8c1aab95246494293909eea61ff15ad227406522c3f175
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to ace-git-forgejo will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Parse real `fj` v0.6.0 minimal-style output: strip Unicode bidi isolate/pop-directional marks (U+2066–U+2069, U+202A–U+202E, U+200E/U+200F, soft hyphen) that `[[:cntrl:]]` does not cover. Real `fj` output wraps dynamic fields (titles, numbers, URLs) in U+2068/U+2069, which made `parse_pr_view` return `nil` and leaked marks into repo-URL evidence.
|
|
13
|
+
- Listing API: `fj pr search` has no `--limit` flag on real `fj`; the flag made both listing paths fail unconditionally. Fetch all and cap `recent_pull_requests` client-side after the newest-first sort.
|
|
14
|
+
- Presence probe: `fj` rejects `--version` ("unexpected argument"), so `check_available!` misclassified healthy installs as missing. Probe with the supported `fj version` subcommand; classified `ProviderCliMissingError` semantics unchanged.
|
|
15
|
+
- Captured real `fj` outputs (pr view/commits/search, issue view, repo view) as fixtures; parser, executor, provider, and contract-parity suites assert against them.
|
|
16
|
+
|
|
17
|
+
## [0.1.0] - 2026-09-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Initial Forgejo provider package implementing the forge-neutral ace-git provider contract.
|
|
22
|
+
- Owns all `fj` CLI invocation, minimal-style output parsing, and authentication
|
|
23
|
+
verification (foundation chunk F0, task 8wk.t.l1e; the ace-git core previously
|
|
24
|
+
had zero Forgejo support).
|
|
25
|
+
- Normalized evidence translation for pull requests, issues, Actions checks, and
|
|
26
|
+
repository metadata, with the shared classified failure taxonomy.
|
|
27
|
+
- Ad-hoc curl fallbacks are strictly excluded; `fj` failures surface as
|
|
28
|
+
classified provider failures.
|
|
29
|
+
- Shared provider-contract parity suite coverage against scripted fakes.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michal Czyz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# ace-git-forgejo
|
|
2
|
+
|
|
3
|
+
Forgejo provider for the forge-neutral [`ace-git`](../ace-git) core.
|
|
4
|
+
|
|
5
|
+
This package owns **all** Forgejo-specific behavior for ACE:
|
|
6
|
+
|
|
7
|
+
- `fj` CLI invocation (with timeout handling and structured output)
|
|
8
|
+
- `fj` minimal-style output parsing and normalization
|
|
9
|
+
- `fj` authentication verification (`fj auth list` per configured server host)
|
|
10
|
+
- Forgejo-specific identifier formats (including `/pulls/N` web URLs)
|
|
11
|
+
|
|
12
|
+
It implements the provider contract defined by the core
|
|
13
|
+
(`Ace::Git::Providers::Base`) and registers itself under the `:forgejo`
|
|
14
|
+
provider type, so consumers resolve it through
|
|
15
|
+
`Ace::Git::Providers.for(server)` and read normalized evidence types
|
|
16
|
+
(`Ace::Git::ProviderPullRequest`, `Ace::Git::ProviderIssue`,
|
|
17
|
+
`Ace::Git::ProviderCheck`, `Ace::Git::ProviderRepository`).
|
|
18
|
+
|
|
19
|
+
Failures are classified with the shared taxonomy
|
|
20
|
+
(`ProviderCliMissingError`, `ProviderAuthenticationError`,
|
|
21
|
+
`ProviderUnreachableError`, `ProviderMalformedOutputError`,
|
|
22
|
+
`ProviderObjectNotFoundError`). Ad-hoc curl fallbacks are strictly
|
|
23
|
+
forbidden; there is no silent fallback of any kind.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Git
|
|
8
|
+
module Forgejo
|
|
9
|
+
# Executes the Forgejo CLI (`fj`) with timeout and structured output.
|
|
10
|
+
#
|
|
11
|
+
# Owns every `fj` subprocess invocation for ACE. A test runner may be
|
|
12
|
+
# injected instead of spawning real processes (see Providers::Base).
|
|
13
|
+
module CliExecutor
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
BINARY = "fj"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Execute an `fj` command with timeout and structured output.
|
|
19
|
+
#
|
|
20
|
+
# @param args [Array<String>] full argument vector after the binary
|
|
21
|
+
# @param timeout [Integer, nil] seconds before the call times out
|
|
22
|
+
# @param runner [Proc, nil] injectable command runner for tests
|
|
23
|
+
# @return [Hash] {success:, stdout:, stderr:, exit_code:}
|
|
24
|
+
# @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
|
|
25
|
+
# @raise [Ace::Git::ProviderUnreachableError] when the call times out
|
|
26
|
+
def execute(args, timeout: nil, runner: nil)
|
|
27
|
+
timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_TIMEOUT
|
|
28
|
+
result = run_command([BINARY] + args, timeout_seconds, runner)
|
|
29
|
+
if result == :timeout
|
|
30
|
+
raise Ace::Git::ProviderUnreachableError,
|
|
31
|
+
"fj command timed out after #{timeout_seconds} seconds: #{args.join(" ")}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Probe whether the `fj` binary is installed and runnable.
|
|
38
|
+
# `fj` has no `--version` flag (it rejects it with "unexpected
|
|
39
|
+
# argument"); the supported presence probe is `fj version`.
|
|
40
|
+
def installed?(runner: nil)
|
|
41
|
+
result = execute(["version"], runner: runner)
|
|
42
|
+
result != :timeout && result[:success]
|
|
43
|
+
rescue Ace::Git::ProviderCliMissingError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Probe whether the resolved server host has a configured `fj` login.
|
|
48
|
+
# `fj auth list` exits nonzero when no instance is configured and
|
|
49
|
+
# prints one host per line otherwise.
|
|
50
|
+
def authenticated?(host, runner: nil)
|
|
51
|
+
result = execute(["auth", "list"], runner: runner)
|
|
52
|
+
return false unless result[:success]
|
|
53
|
+
|
|
54
|
+
hosts = result[:stdout].to_s.lines.map(&:strip).reject(&:empty?)
|
|
55
|
+
return hosts.any? unless host
|
|
56
|
+
|
|
57
|
+
hosts.any? { |line| line.include?(host.to_s) }
|
|
58
|
+
rescue Ace::Git::ProviderCliMissingError
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
|
|
63
|
+
def check_installed!(runner: nil)
|
|
64
|
+
return true if installed?(runner: runner)
|
|
65
|
+
|
|
66
|
+
raise Ace::Git::ProviderCliMissingError,
|
|
67
|
+
"Forgejo CLI (fj) is not installed or not runnable; install the Forgejo CLI and retry"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
|
|
71
|
+
def check_authenticated!(host: nil, runner: nil)
|
|
72
|
+
return true if authenticated?(host, runner: runner)
|
|
73
|
+
|
|
74
|
+
raise Ace::Git::ProviderAuthenticationError,
|
|
75
|
+
"Forgejo CLI (fj) has no usable login for #{host || "the target host"}; " \
|
|
76
|
+
"check fj auth list"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Run a full argument vector against `fj`.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] result hash, or :timeout sentinel on timeout
|
|
82
|
+
def run_command(command, timeout_seconds, runner = nil)
|
|
83
|
+
if runner
|
|
84
|
+
call_runner(runner, command, timeout_seconds)
|
|
85
|
+
else
|
|
86
|
+
spawn_with_timeout(command, timeout_seconds)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def call_runner(runner, command, timeout_seconds)
|
|
93
|
+
runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"})
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
{success: false, stdout: "", stderr: e.message, exit_code: 1}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def spawn_with_timeout(command, timeout_seconds)
|
|
99
|
+
stdout_str, stderr_str, status = Timeout.timeout(timeout_seconds) do
|
|
100
|
+
Open3.capture3({"LC_ALL" => "C"}, *command)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
success: status.success?,
|
|
105
|
+
stdout: stdout_str,
|
|
106
|
+
stderr: stderr_str,
|
|
107
|
+
exit_code: status.exitstatus
|
|
108
|
+
}
|
|
109
|
+
rescue Timeout::Error
|
|
110
|
+
:timeout
|
|
111
|
+
rescue Errno::ENOENT
|
|
112
|
+
raise Ace::Git::ProviderCliMissingError,
|
|
113
|
+
"Forgejo CLI (fj) is not installed or not runnable; install the Forgejo CLI and retry"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Forgejo
|
|
6
|
+
# Parse `fj` human-oriented output (minimal style) into structured data.
|
|
7
|
+
#
|
|
8
|
+
# `fj` has no JSON output mode, so the provider owns parsers for the
|
|
9
|
+
# stable text formats. All parsers are pure functions over strings.
|
|
10
|
+
module Parsers
|
|
11
|
+
# Invisible bidi isolate/pop-directional marks. Real `fj` (v0.6.0) wraps
|
|
12
|
+
# dynamic fields (titles, numbers, URLs) in U+2068/U+2069 even in minimal
|
|
13
|
+
# style; they are Unicode Cf format characters, so `[[:cntrl:]]` does not
|
|
14
|
+
# cover them and they must be stripped explicitly. Includes the full
|
|
15
|
+
# isolate/pop set (U+2066-U+2069, U+202A-U+202E), directional marks
|
|
16
|
+
# (U+200E/U+200F), and the soft hyphen.
|
|
17
|
+
BIDI_MARKS = /[\u00ad\u200e\u200f\u202a-\u202e\u2066-\u2069]/.freeze
|
|
18
|
+
|
|
19
|
+
# Strip control characters, bidi isolate/pop-directional marks, and
|
|
20
|
+
# surrounding whitespace from an `fj` line.
|
|
21
|
+
def self.clean(line)
|
|
22
|
+
line.to_s.gsub(/[[:cntrl:]]/, "").gsub(BIDI_MARKS, "").strip
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse `fj pr view <ID>` output.
|
|
26
|
+
#
|
|
27
|
+
# Layout (minimal style):
|
|
28
|
+
# Ship the provider contract #25
|
|
29
|
+
# By lab-builder - Merged - +252 -8
|
|
30
|
+
# From `owner/repo:head-branch` into `main`
|
|
31
|
+
#
|
|
32
|
+
# @return [Hash, nil] {number:, title:, author:, state:, head_ref:, base_ref:}
|
|
33
|
+
def self.parse_pr_view(text)
|
|
34
|
+
lines = text.to_s.lines.map { |line| clean(line) }.reject(&:empty?)
|
|
35
|
+
return nil if lines.empty?
|
|
36
|
+
|
|
37
|
+
title_line = lines[0]
|
|
38
|
+
numbered = title_line.match(/^(?<title>.+?)\s+#(?<number>\d+)\z/)
|
|
39
|
+
return nil unless numbered
|
|
40
|
+
|
|
41
|
+
info = {
|
|
42
|
+
number: numbered[:number].to_i,
|
|
43
|
+
title: numbered[:title],
|
|
44
|
+
author: nil,
|
|
45
|
+
state: nil,
|
|
46
|
+
head_ref: nil,
|
|
47
|
+
base_ref: nil
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
byline = lines[1].to_s
|
|
51
|
+
if (match = byline.match(/\ABy\s+(?<author>\S+)\s+[-\u2013\u2014]\s+(?<state>\w+)/))
|
|
52
|
+
info[:author] = match[:author]
|
|
53
|
+
info[:state] = normalize_state(match[:state])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
refs = lines.find { |line| line.start_with?("From ") }
|
|
57
|
+
if (match = refs.to_s.match(/\AFrom\s+`(?:[^`:]+:)?(?<head>[^`]+)`\s+into\s+`(?<base>[^`]+)`\z/))
|
|
58
|
+
info[:head_ref] = match[:head]
|
|
59
|
+
info[:base_ref] = match[:base]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
info
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Parse `fj pr view <ID> commits` output; the first commit line carries
|
|
66
|
+
# the head sha.
|
|
67
|
+
#
|
|
68
|
+
# @return [String, nil] head commit sha
|
|
69
|
+
def self.parse_head_sha(text)
|
|
70
|
+
match = text.to_s.match(/^commit\s+(?<sha>[0-9a-f]{7,64})\b/)
|
|
71
|
+
match && match[:sha]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Parse `fj pr search` / issue search listing output.
|
|
75
|
+
#
|
|
76
|
+
# Layout (minimal style):
|
|
77
|
+
# 25 pull requests
|
|
78
|
+
# #31: Wire status to providers (by lab-builder)
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Hash>] {number:, title:, author:}
|
|
81
|
+
def self.parse_search(text)
|
|
82
|
+
text.to_s.lines.map { |line| clean(line) }.filter_map do |line|
|
|
83
|
+
match = line.match(/\A#(?<number>\d+):\s+(?<title>.+?)\s+\(by\s+(?<author>[^)]+)\)\z/)
|
|
84
|
+
next nil unless match
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
number: match[:number].to_i,
|
|
88
|
+
title: match[:title],
|
|
89
|
+
author: match[:author]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse `fj issue view <ID>` output (same layout as PR view).
|
|
95
|
+
#
|
|
96
|
+
# @return [Hash, nil] {number:, title:, author:, state:}
|
|
97
|
+
def self.parse_issue_view(text)
|
|
98
|
+
parsed = parse_pr_view(text)
|
|
99
|
+
return nil unless parsed
|
|
100
|
+
|
|
101
|
+
parsed.slice(:number, :title, :author, :state)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Parse `fj actions tasks` output into check evidence entries.
|
|
105
|
+
#
|
|
106
|
+
# Layout (minimal style):
|
|
107
|
+
# #83 (fc14c43d36) success Complete package suite 23s (push): subject
|
|
108
|
+
#
|
|
109
|
+
# @return [Array<Hash>] {name:, state:, sha:}
|
|
110
|
+
def self.parse_actions_tasks(text)
|
|
111
|
+
text.to_s.lines.map { |line| clean(line) }.filter_map do |line|
|
|
112
|
+
match = line.match(
|
|
113
|
+
/\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?:push|pull_request|schedule)\)/
|
|
114
|
+
)
|
|
115
|
+
next nil unless match
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
name: match[:name],
|
|
119
|
+
state: match[:state].downcase.to_sym,
|
|
120
|
+
sha: match[:sha]
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parse `fj repo view` output.
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash] {full_name:, url:}
|
|
128
|
+
def self.parse_repo_view(text)
|
|
129
|
+
lines = text.to_s.lines.map { |line| clean(line) }.reject(&:empty?)
|
|
130
|
+
full_name = lines[0].to_s.empty? ? nil : lines[0]
|
|
131
|
+
url_line = lines.find { |line| line.start_with?("View online at") }
|
|
132
|
+
url = url_line && url_line.sub(/\AView online at\s+/, "")
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
full_name: full_name,
|
|
136
|
+
url: url
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Map `fj` state vocabulary onto the neutral state symbols.
|
|
141
|
+
def self.normalize_state(raw)
|
|
142
|
+
case raw.to_s.downcase
|
|
143
|
+
when "open" then :open
|
|
144
|
+
when "merged" then :merged
|
|
145
|
+
when "closed" then :closed
|
|
146
|
+
else raw.to_s.downcase.to_sym
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Forgejo
|
|
6
|
+
# Parse Forgejo pull request URL references into structured form.
|
|
7
|
+
#
|
|
8
|
+
# Supports the Forgejo-specific formats:
|
|
9
|
+
# - Simple number: "123"
|
|
10
|
+
# - Qualified reference: "owner/repo#456"
|
|
11
|
+
# - Forgejo URL: "https://forgejo.example.com/owner/repo/pulls/789"
|
|
12
|
+
module PrIdentifier
|
|
13
|
+
# Parsed identifier result. `forgejo_format` is the numeric form
|
|
14
|
+
# accepted by `fj pr` commands.
|
|
15
|
+
ParseResult = Data.define(:number, :repo, :forgejo_format)
|
|
16
|
+
|
|
17
|
+
MAX_IDENTIFIER_LENGTH = 256
|
|
18
|
+
|
|
19
|
+
def self.parse(input)
|
|
20
|
+
return nil if input.nil?
|
|
21
|
+
|
|
22
|
+
input_str = input.to_s.strip
|
|
23
|
+
return nil if input_str.empty?
|
|
24
|
+
|
|
25
|
+
if input_str.length > MAX_IDENTIFIER_LENGTH
|
|
26
|
+
raise ArgumentError, "PR identifier too long (max #{MAX_IDENTIFIER_LENGTH} characters)"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
case input_str
|
|
30
|
+
when /\A(\d+)\z/
|
|
31
|
+
number = ::Regexp.last_match(1)
|
|
32
|
+
raise ArgumentError, "Invalid PR identifier format: #{input_str}" if number.to_i.zero?
|
|
33
|
+
|
|
34
|
+
canonical_number = number.to_i.to_s
|
|
35
|
+
ParseResult.new(number: canonical_number, repo: nil, forgejo_format: canonical_number)
|
|
36
|
+
when /\A(?<repo>[a-zA-Z0-9_\-.]+\/[a-zA-Z0-9_\-.]+)#(?<number>\d+)\z/
|
|
37
|
+
match = ::Regexp.last_match
|
|
38
|
+
ParseResult.new(number: match[:number], repo: match[:repo], forgejo_format: match[:number])
|
|
39
|
+
when %r{\Ahttps?://[^/]+/(?<repo>[^/]+/[^/]+)/pulls/(?<number>\d+)\z}i
|
|
40
|
+
match = ::Regexp.last_match
|
|
41
|
+
ParseResult.new(number: match[:number], repo: match[:repo], forgejo_format: match[:number])
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError, "Invalid PR identifier format: #{input_str}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "cli_executor"
|
|
6
|
+
require_relative "parsers"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Git
|
|
10
|
+
module Forgejo
|
|
11
|
+
# Forgejo provider implementation of the shared provider contract.
|
|
12
|
+
#
|
|
13
|
+
# Owns Forgejo terminology and translates `fj` responses into the
|
|
14
|
+
# normalized evidence types defined by the ace-git core. Every failure is
|
|
15
|
+
# classified with the shared taxonomy; `fj` failures are never hidden
|
|
16
|
+
# behind ad-hoc curl calls or silent fallbacks.
|
|
17
|
+
class Provider < Ace::Git::Providers::Base
|
|
18
|
+
class << self
|
|
19
|
+
def display_name
|
|
20
|
+
"Forgejo"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Boolean] true when the `fj` binary is installed
|
|
25
|
+
def available?
|
|
26
|
+
CliExecutor.installed?(runner: runner)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
|
|
30
|
+
def check_available!
|
|
31
|
+
CliExecutor.check_installed!(runner: runner)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true when the server host has a configured `fj` login
|
|
35
|
+
def authenticated?
|
|
36
|
+
CliExecutor.authenticated?(server_host, runner: runner)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
|
|
40
|
+
def check_authenticated!
|
|
41
|
+
CliExecutor.check_authenticated!(host: server_host, runner: runner)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [ProviderPullRequest] normalized pull request evidence
|
|
45
|
+
# @raise [ProviderObjectNotFoundError] when the PR does not exist
|
|
46
|
+
# @raise [ProviderMalformedOutputError] when `fj` output is unexpected
|
|
47
|
+
# @raise [ProviderUnreachableError] when the endpoint is unreachable
|
|
48
|
+
def pull_request(number:)
|
|
49
|
+
view = fj(["--style", "minimal", "pr", "view", number.to_s])
|
|
50
|
+
parsed = Parsers.parse_pr_view(view) ||
|
|
51
|
+
raise(Ace::Git::ProviderMalformedOutputError,
|
|
52
|
+
"Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
|
|
53
|
+
|
|
54
|
+
parsed[:head_sha] = head_sha_of(number)
|
|
55
|
+
normalize_pr(parsed)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
59
|
+
def pull_request_for_branch(branch:)
|
|
60
|
+
# `fj pr search` has no --limit flag; fetch all and order newest-first.
|
|
61
|
+
listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
|
|
62
|
+
entries = Parsers.parse_search(listing).sort_by { |entry| -entry[:number] }
|
|
63
|
+
|
|
64
|
+
entries.each do |entry|
|
|
65
|
+
view = fj(["--style", "minimal", "pr", "view", entry[:number].to_s])
|
|
66
|
+
parsed = Parsers.parse_pr_view(view)
|
|
67
|
+
next unless parsed && parsed[:head_ref] == branch
|
|
68
|
+
|
|
69
|
+
parsed[:head_sha] = head_sha_of(entry[:number])
|
|
70
|
+
return normalize_pr(parsed)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] unified diff text for the pull request
|
|
77
|
+
def pull_request_diff(number:)
|
|
78
|
+
fj(["pr", "view", number.to_s, "diff"])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# `fj pr search` has no --limit flag; cap client-side after sorting
|
|
82
|
+
# newest-first so the returned window is deterministic.
|
|
83
|
+
#
|
|
84
|
+
# @return [Array<ProviderPullRequest>] recent PRs, newest first
|
|
85
|
+
def recent_pull_requests(limit:)
|
|
86
|
+
listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
|
|
87
|
+
Parsers.parse_search(listing)
|
|
88
|
+
.map { |entry| normalize_search_entry(entry) }
|
|
89
|
+
.sort_by { |pr| -pr.number }
|
|
90
|
+
.first(limit)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [ProviderIssue] normalized issue evidence
|
|
94
|
+
def issue(number:)
|
|
95
|
+
view = fj(["--style", "minimal", "issue", "view", number.to_s])
|
|
96
|
+
parsed = Parsers.parse_issue_view(view) ||
|
|
97
|
+
raise(Ace::Git::ProviderMalformedOutputError,
|
|
98
|
+
"Unrecognized `fj issue view #{number}` output; update the Forgejo provider parser")
|
|
99
|
+
|
|
100
|
+
Ace::Git::ProviderIssue.new(
|
|
101
|
+
server_name: server.name,
|
|
102
|
+
number: parsed[:number],
|
|
103
|
+
title: parsed[:title],
|
|
104
|
+
state: parsed[:state],
|
|
105
|
+
author: parsed[:author],
|
|
106
|
+
url: issue_url(parsed[:number]),
|
|
107
|
+
labels: nil
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Array<ProviderCheck>] normalized check evidence for a ref
|
|
112
|
+
def checks(ref:)
|
|
113
|
+
tasks = fj(["--style", "minimal", "actions", "tasks"])
|
|
114
|
+
Parsers.parse_actions_tasks(tasks)
|
|
115
|
+
.select { |task| task[:sha].start_with?(ref.to_s) || ref.to_s.start_with?(task[:sha]) }
|
|
116
|
+
.map do |task|
|
|
117
|
+
Ace::Git::ProviderCheck.new(
|
|
118
|
+
server_name: server.name,
|
|
119
|
+
name: task[:name],
|
|
120
|
+
state: task[:state],
|
|
121
|
+
conclusion: task[:state],
|
|
122
|
+
url: nil
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [ProviderRepository] normalized repository evidence
|
|
128
|
+
def repository
|
|
129
|
+
view = fj(["--style", "minimal", "repo", "view"])
|
|
130
|
+
parsed = Parsers.parse_repo_view(view)
|
|
131
|
+
|
|
132
|
+
Ace::Git::ProviderRepository.new(
|
|
133
|
+
server_name: server.name,
|
|
134
|
+
full_name: parsed[:full_name],
|
|
135
|
+
default_branch: nil,
|
|
136
|
+
url: parsed[:url]
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Run one `fj` command, classifying every failure with the taxonomy.
|
|
143
|
+
def fj(args)
|
|
144
|
+
result = CliExecutor.execute(args, timeout: timeout, runner: runner)
|
|
145
|
+
return result[:stdout] if result[:success]
|
|
146
|
+
|
|
147
|
+
message = result[:stderr].to_s
|
|
148
|
+
case message
|
|
149
|
+
when /not found|does not exist|no pull request|no issue/i
|
|
150
|
+
raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{args.join(" ")}): #{message}"
|
|
151
|
+
when /access denied|unauthorized|token/i
|
|
152
|
+
raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
|
|
153
|
+
else
|
|
154
|
+
raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{args.join(" ")}): #{message}"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def head_sha_of(number)
|
|
159
|
+
commits = fj(["--style", "minimal", "pr", "view", number.to_s, "commits"])
|
|
160
|
+
Parsers.parse_head_sha(commits)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def normalize_pr(parsed)
|
|
164
|
+
Ace::Git::ProviderPullRequest.new(
|
|
165
|
+
server_name: server.name,
|
|
166
|
+
number: parsed[:number],
|
|
167
|
+
title: parsed[:title],
|
|
168
|
+
state: parsed[:state],
|
|
169
|
+
head_ref: parsed[:head_ref],
|
|
170
|
+
base_ref: parsed[:base_ref],
|
|
171
|
+
head_sha: parsed[:head_sha],
|
|
172
|
+
author: parsed[:author],
|
|
173
|
+
url: pr_url(parsed[:number]),
|
|
174
|
+
draft: nil,
|
|
175
|
+
merged_at: nil
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def normalize_search_entry(entry)
|
|
180
|
+
Ace::Git::ProviderPullRequest.new(
|
|
181
|
+
server_name: server.name,
|
|
182
|
+
number: entry[:number],
|
|
183
|
+
title: entry[:title],
|
|
184
|
+
state: nil,
|
|
185
|
+
head_ref: nil,
|
|
186
|
+
base_ref: nil,
|
|
187
|
+
head_sha: nil,
|
|
188
|
+
author: entry[:author],
|
|
189
|
+
url: pr_url(entry[:number]),
|
|
190
|
+
draft: nil,
|
|
191
|
+
merged_at: nil
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def server_host
|
|
196
|
+
@server_host ||= begin
|
|
197
|
+
uri = URI.parse(server.url.to_s)
|
|
198
|
+
uri.host
|
|
199
|
+
rescue URI::Error
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def pr_url(number)
|
|
205
|
+
base = server.url.to_s.chomp("/")
|
|
206
|
+
"#{base}/pulls/#{number}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def issue_url(number)
|
|
210
|
+
base = server.url.to_s.chomp("/")
|
|
211
|
+
"#{base}/issues/#{number}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/git"
|
|
4
|
+
|
|
5
|
+
require_relative "forgejo/version"
|
|
6
|
+
require_relative "forgejo/cli_executor"
|
|
7
|
+
require_relative "forgejo/pr_identifier"
|
|
8
|
+
require_relative "forgejo/parsers"
|
|
9
|
+
require_relative "forgejo/provider"
|
|
10
|
+
|
|
11
|
+
module Ace
|
|
12
|
+
module Git
|
|
13
|
+
# Forgejo provider package for the forge-neutral ace-git core.
|
|
14
|
+
#
|
|
15
|
+
# Owns all Forgejo-specific behavior: `fj` CLI invocation, minimal-style
|
|
16
|
+
# output parsing, authentication verification, and Forgejo terminology.
|
|
17
|
+
# Implements the shared provider contract exposed by the core and
|
|
18
|
+
# registers itself in the core provider registry.
|
|
19
|
+
module Forgejo
|
|
20
|
+
PROVIDER_TYPE = :forgejo
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Ace::Git::Providers.register(Ace::Git::Forgejo::PROVIDER_TYPE, Ace::Git::Forgejo::Provider)
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ace-git-forgejo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michal Czyz
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ace-git
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.24'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.24'
|
|
26
|
+
description: 'Implements the shared ace-git provider contract for Forgejo: all `fj`
|
|
27
|
+
CLI invocation, output parsing, authentication verification, and classified failure
|
|
28
|
+
handling live behind one normalized evidence boundary. Ad-hoc curl fallbacks are
|
|
29
|
+
strictly excluded.'
|
|
30
|
+
email:
|
|
31
|
+
- mc@cs3b.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- Rakefile
|
|
40
|
+
- lib/ace/git/forgejo.rb
|
|
41
|
+
- lib/ace/git/forgejo/cli_executor.rb
|
|
42
|
+
- lib/ace/git/forgejo/parsers.rb
|
|
43
|
+
- lib/ace/git/forgejo/pr_identifier.rb
|
|
44
|
+
- lib/ace/git/forgejo/provider.rb
|
|
45
|
+
- lib/ace/git/forgejo/version.rb
|
|
46
|
+
homepage: https://forgejo.tail6c0887.ts.net/cs3b/ace
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
allowed_push_host: https://rubygems.org
|
|
51
|
+
homepage_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace
|
|
52
|
+
source_code_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace/src/branch/main/ace-git-forgejo
|
|
53
|
+
changelog_uri: https://forgejo.tail6c0887.ts.net/cs3b/ace/src/branch/main/ace-git-forgejo/CHANGELOG.md
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 3.2.0
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 3.6.9
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Forgejo provider for the forge-neutral ace-git core
|
|
71
|
+
test_files: []
|