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 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.pattern = "test/**/*_test.rb"
9
+ t.warning = false
10
+ t.verbose = false
11
+ end
12
+
13
+ task default: :test
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Git
5
+ module Forgejo
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ 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: []