ace-git-forgejo 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f71ec19c2631e884747c20ba07ad19b595fff3ad52c4505571d3f91da61f2ff
4
- data.tar.gz: ca8d1ff36047ef554da1fa8b23285b7fb01d486b35bde3321b03d199ba92ffb0
3
+ metadata.gz: e1e1e605a8808db94b0b68ad90d445149f8fce01df65f7ddad401f59a0fce255
4
+ data.tar.gz: 312685879a60d54363626b6f7dc98d2954b0575120aeb0dde0e128826252fba6
5
5
  SHA512:
6
- metadata.gz: 6cddccbd90c235fe84a052c58dfe3030a6c3b826527da34681d4316579c46e798bc1ca212ee438f5020be3d47ac77d51fc99729ca2805d1f7c3e3f0c0046b30a
7
- data.tar.gz: 621fa2ebff13f4e344dba8500ad40b2e41604a97c39282979424d9eaf942c32c2f266be1f3aaa74037f1244924dd73518067f74d19a93162d0378f3781dcc487
6
+ metadata.gz: 85a2d43f44c7d07d7c60c236f9a5c81c350233fca510c4a6503181f42ab2052a50e31094380e1a2ac752a55bb3ecdaf6923376a439197c459c1a71a66ed21364
7
+ data.tar.gz: ddfee8db45e1545dfb7592e77396255a71d90e083c455a1507ee412da71482fee2a499c098c47ed59cbcc0b6abe46952cea8faa5b151d97b2521106c5e2f6bc8
data/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
14
+ - Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
15
+
16
+
17
+ ## [0.3.0] - 2026-09-29
18
+
19
+ ### Added
20
+ - Selected-repository binding for every `fj` repository command: the
21
+ resolved server's host/owner/repository is validated once (malformed
22
+ selections raise `ConfigError` before any subprocess) and stamped onto
23
+ every call via `-H` plus qualified `owner/repo#N` ids or `-r owner/repo`,
24
+ so a named server selection can never be silently retargeted by cwd,
25
+ remotes, default login, or another checkout.
26
+ - Capability table grounded in observed forgejo-cli v0.6.0 help/output
27
+ (upstream release binary, plus real-server read-only probes); operations
28
+ without an observed argv form and installed versions outside the observed
29
+ set refuse with `ProviderUnsupportedCapabilityError` before launch.
30
+ - Returned-identity validation: PR/issue numbers and repository view
31
+ full-name/URL must agree with the selection or evidence fails closed
32
+ with `ProviderIdentityMismatchError`.
33
+
34
+ ### Changed
35
+ - All `fj` subprocess launches now go through a two-path executor boundary:
36
+ an allowlisted control path (`fj version`, `fj auth list`) and the
37
+ repository boundary. Authentication compares `fj auth list` lines exactly
38
+ against the selected authority (substring hosts no longer count) and
39
+ scans both output streams (observed v0.6.0 prints "No logins." on stderr
40
+ with exit 0).
41
+ - Unauthenticated-API failures are classified as authentication errors
42
+ (observed Lab text "Only signed in user is allowed to call APIs.").
43
+ - `merge_commit_sha` stays empty: observed fj v0.6.0 exposes no
44
+ authoritative merge-commit field for a selected PR, so cleanup evidence
45
+ never claims one.
46
+
47
+ ### Fixed
48
+ - Selected-host fidelity: `-H` now carries the selected `scheme://authority`
49
+ (fj assumes HTTPS for a bare host, so an http authority was silently
50
+ upgraded), non-http(s) server schemes are rejected as configuration
51
+ errors before any subprocess, and the provider refuses to launch
52
+ repository commands when the readable fj keys file aliases the selected
53
+ host to a different endpoint (read-only check; absent/unreadable keys
54
+ file is allowed, never modified).
55
+
10
56
  ## [0.2.0] - 2026-09-28
11
57
 
12
58
  ### Added
data/README.md CHANGED
@@ -5,8 +5,20 @@ Forgejo provider for the forge-neutral [`ace-git`](../ace-git) core.
5
5
  This package owns **all** Forgejo-specific behavior for ACE:
6
6
 
7
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)
8
+ - **Selected-repository binding**: every repository-scoped `fj` call is
9
+ bound to the resolved server's exact host/owner/repository — `-H` plus
10
+ qualified `owner/repo#N` object ids or `-r owner/repo` — never cwd,
11
+ remotes, a default login, or user `fj` configuration. Malformed server
12
+ URLs fail as `ConfigError` before any subprocess; operations whose argv
13
+ form was never observed on a real `fj` refuse with
14
+ `ProviderUnsupportedCapabilityError` before launch, and the installed
15
+ `fj` version must be one of the observed versions (`v0.6.0`) to inherit
16
+ the capability table.
17
+ - `fj` minimal-style output parsing and normalization, with returned
18
+ identity validated against the selection (misrouted evidence raises
19
+ `ProviderIdentityMismatchError` instead of being relabelled)
20
+ - `fj` authentication verification (`fj auth list`, compared exactly
21
+ against the selected host authority)
10
22
  - Forgejo-specific identifier formats (including `/pulls/N` web URLs)
11
23
 
12
24
  It implements the provider contract defined by the core
@@ -21,3 +33,24 @@ Failures are classified with the shared taxonomy
21
33
  `ProviderUnreachableError`, `ProviderMalformedOutputError`,
22
34
  `ProviderObjectNotFoundError`). Ad-hoc curl fallbacks are strictly
23
35
  forbidden; there is no silent fallback of any kind.
36
+
37
+ ## Supported operations (observed on forgejo-cli v0.6.0)
38
+
39
+ PR view/diff/head-commits, PR search (all/open, client-side newest-first
40
+ window — `fj` has no `--limit`), PR create (same-repository head only),
41
+ PR edit title/body, issue view, actions tasks, repository view, version
42
+ and `auth list` probes. `fj` v0.6.0 exposes no draft-to-ready command, no
43
+ atomic expected-head merge, and no authoritative merge-commit field, so
44
+ `ready_pull_request`/`merge_pull_request` refuse as unsupported
45
+ capabilities and `merge_commit_sha` stays empty — worktree cleanup
46
+ conservatively retains Forgejo checkouts until `fj` exposes that field.
47
+ The capability evidence and its provenance live in the
48
+ `8wr.t.uj0` task folder (`evidence/fj-capabilities.md`).
49
+
50
+ Endpoint fidelity: `-H` carries the selected `scheme://authority` (fj
51
+ otherwise assumes HTTPS), and because fj v0.6.0 silently applies its
52
+ keys-file `aliases` map to `-H`, the provider read-only checks the
53
+ observed keys-file locations and refuses a repository command when an
54
+ alias redirects the selected host — never modifying user fj
55
+ configuration; an absent or unreadable keys file is allowed.
56
+
@@ -2,59 +2,129 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
+ require_relative "parsers"
6
+ require_relative "repository_binding"
5
7
 
6
8
  module Ace
7
9
  module Git
8
10
  module Forgejo
9
11
  # Executes the Forgejo CLI (`fj`) with timeout and structured output.
10
12
  #
11
- # Owns every `fj` subprocess invocation for ACE. A test runner may be
12
- # injected instead of spawning real processes (see Providers::Base).
13
+ # Owns every `fj` subprocess invocation for ACE. Two launch paths exist:
14
+ #
15
+ # - {execute_control}: allowlisted login/version probes (`fj version`,
16
+ # `fj auth list`) that are host- or installation-scoped.
17
+ # - {execute_repository}: repository-scoped commands, which require the
18
+ # validated selected {RepositoryBinding::Target} and a named operation
19
+ # with an observed argv form. The executor stamps `-H <authority>`
20
+ # from the target and refuses unobserved operations before launch —
21
+ # repository selection never comes from cwd, remotes, a default
22
+ # login, or user `fj` configuration.
23
+ #
24
+ # A test runner may be injected instead of spawning real processes
25
+ # (see Providers::Base).
13
26
  module CliExecutor
14
27
  DEFAULT_TIMEOUT = 30
15
28
  BINARY = "fj"
16
29
 
30
+ # Host/installation-scoped probes; never repository targeting.
31
+ CONTROL_ARGS = {
32
+ version: ["version"],
33
+ auth_list: ["auth", "list"]
34
+ }.freeze
35
+
17
36
  class << self
18
- # Execute an `fj` command with timeout and structured output.
37
+ # Execute an allowlisted control command with timeout.
19
38
  #
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
39
+ # @param command [Symbol] key in {CONTROL_ARGS}
23
40
  # @return [Hash] {success:, stdout:, stderr:, exit_code:}
24
41
  # @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
25
42
  # @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(" ")}"
43
+ # @raise [ArgumentError] when the command is not allowlisted
44
+ def execute_control(command, timeout: nil, runner: nil)
45
+ args = CONTROL_ARGS.fetch(command) do
46
+ raise ArgumentError, "Unknown fj control command #{command.inspect}"
32
47
  end
48
+ execute(args, timeout: timeout, runner: runner)
49
+ end
33
50
 
34
- result
51
+ # Execute one repository-scoped operation bound to the selected
52
+ # target. The operation must have an observed argv form; the host
53
+ # flag is stamped here so no caller can omit selection.
54
+ #
55
+ # @param target [RepositoryBinding::Target] validated selected repository
56
+ # @param operation [Symbol] repository operation (RepositoryBinding::FORMS)
57
+ # @param arguments [Array<Object>] per-operation arguments
58
+ # @return [Hash] {success:, stdout:, stderr:, exit_code:}
59
+ # @raise [Ace::Git::ConfigError] when the target is missing/invalid
60
+ # @raise [Ace::Git::ProviderUnsupportedCapabilityError] when the
61
+ # operation has no observed form
62
+ # @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
63
+ # @raise [Ace::Git::ProviderUnreachableError] when the call times out
64
+ def execute_repository(target:, operation:, arguments: [], timeout: nil, runner: nil)
65
+ unless target.is_a?(RepositoryBinding::Target)
66
+ raise Ace::Git::ConfigError,
67
+ "Repository commands require a validated selected Forgejo target"
68
+ end
69
+
70
+ argv = RepositoryBinding.argv_for(operation, target, arguments)
71
+ # `-H` carries the full selected URL: fj otherwise assumes HTTPS,
72
+ # which would drop a configured http authority (observed v0.6.0).
73
+ execute(["-H", target.host_url] + argv, timeout: timeout, runner: runner)
35
74
  end
36
75
 
37
76
  # Probe whether the `fj` binary is installed and runnable.
38
77
  # `fj` has no `--version` flag (it rejects it with "unexpected
39
78
  # argument"); the supported presence probe is `fj version`.
40
79
  def installed?(runner: nil)
41
- result = execute(["version"], runner: runner)
42
- result != :timeout && result[:success]
80
+ result = execute_control(:version, runner: runner)
81
+ result[:success]
43
82
  rescue Ace::Git::ProviderCliMissingError
44
83
  false
45
84
  end
46
85
 
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]
86
+ # @return [String, nil] installed forgejo-cli version (e.g. "v0.6.0"),
87
+ # or nil when `fj version` output is unparseable
88
+ # @raise [Ace::Git::ProviderCliMissingError] when `fj` is missing
89
+ # or the version probe fails
90
+ def installed_version(runner: nil)
91
+ result = execute_control(:version, runner: runner)
92
+ unless result[:success]
93
+ raise Ace::Git::ProviderCliMissingError,
94
+ "Forgejo CLI (fj) is not installed or not runnable; install the Forgejo CLI and retry"
95
+ end
53
96
 
54
- hosts = result[:stdout].to_s.lines.map(&:strip).reject(&:empty?)
55
- return hosts.any? unless host
97
+ match = result[:stdout].to_s.match(/\bfj\s+(v?\d+\.\d+\.\d+)\b/)
98
+ match && match[1].sub(/\Av?/, "v")
99
+ end
56
100
 
57
- hosts.any? { |line| line.include?(host.to_s) }
101
+ # @raise [Ace::Git::ProviderUnsupportedCapabilityError] when the
102
+ # installed version was never observed; it must not inherit the
103
+ # capability table of an observed version
104
+ def check_version_supported!(runner: nil)
105
+ version = installed_version(runner: runner)
106
+ return version if RepositoryBinding::OBSERVED_VERSIONS.include?(version)
107
+
108
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
109
+ "Installed forgejo-cli version #{version || "of unknown version"} was never " \
110
+ "observed; observed versions: #{RepositoryBinding::OBSERVED_VERSIONS.join(", ")}. " \
111
+ "Observe its real surface before repository operations are allowed " \
112
+ "(see the ace-git-forgejo repository binding evidence)"
113
+ end
114
+
115
+ # Probe whether the resolved server authority has a configured `fj`
116
+ # login. `fj auth list` prints "No logins." (stderr, exit 0) when no
117
+ # instance is configured; configured hosts are listed one per line.
118
+ # Comparison is exact against the selected authority — never a
119
+ # substring of an unrelated line.
120
+ def authenticated?(authority, runner: nil)
121
+ result = execute_control(:auth_list, runner: runner)
122
+ return false unless result[:success]
123
+
124
+ lines = "#{result[:stdout]}\n#{result[:stderr]}".lines
125
+ lines.map { |line| Parsers.clean(line) }.any? do |line|
126
+ !line.empty? && line.casecmp(authority.to_s)&.zero?
127
+ end
58
128
  rescue Ace::Git::ProviderCliMissingError
59
129
  false
60
130
  end
@@ -68,18 +138,31 @@ module Ace
68
138
  end
69
139
 
70
140
  # @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
71
- def check_authenticated!(host: nil, runner: nil)
72
- return true if authenticated?(host, runner: runner)
141
+ def check_authenticated!(authority:, runner: nil)
142
+ return true if authenticated?(authority, runner: runner)
73
143
 
74
144
  raise Ace::Git::ProviderAuthenticationError,
75
- "Forgejo CLI (fj) has no usable login for #{host || "the target host"}; " \
145
+ "Forgejo CLI (fj) has no usable login for #{authority || "the target host"}; " \
76
146
  "check fj auth list"
77
147
  end
78
148
 
79
149
  # Run a full argument vector against `fj`.
80
150
  #
81
- # @return [Hash] result hash, or :timeout sentinel on timeout
82
- def run_command(command, timeout_seconds, runner = nil)
151
+ # @return [Hash] result hash
152
+ def execute(args, timeout: nil, runner: nil)
153
+ timeout_seconds = timeout || Ace::Git.network_timeout || DEFAULT_TIMEOUT
154
+ result = run_command([BINARY] + args, timeout_seconds, runner)
155
+ if result == :timeout
156
+ raise Ace::Git::ProviderUnreachableError,
157
+ "fj command timed out after #{timeout_seconds} seconds: #{args.join(" ")}"
158
+ end
159
+
160
+ result
161
+ end
162
+
163
+ private
164
+
165
+ def run_command(command, timeout_seconds, runner)
83
166
  if runner
84
167
  call_runner(runner, command, timeout_seconds)
85
168
  else
@@ -87,8 +170,6 @@ module Ace
87
170
  end
88
171
  end
89
172
 
90
- private
91
-
92
173
  def call_runner(runner, command, timeout_seconds)
93
174
  runner.call(args: command, timeout: timeout_seconds, env: {"LC_ALL" => "C"})
94
175
  rescue StandardError => e
@@ -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
@@ -116,18 +116,37 @@ module Ace
116
116
  #
117
117
  # @return [Array<Hash>] {name:, state:, sha:}
118
118
  def self.parse_actions_tasks(text)
119
- text.to_s.lines.map { |line| clean(line) }.filter_map do |line|
119
+ tasks = []
120
+ declared_count = nil
121
+ text.to_s.lines.map { |line| clean(line) }.reject(&:empty?).each do |line|
122
+ # `fj` emits a leading "<n> tasks" count header before task lines.
123
+ if (header = line.match(/\A(\d+) tasks?\z/i))
124
+ declared_count = header[1].to_i
125
+ next
126
+ end
127
+
120
128
  match = line.match(
121
- /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?:push|pull_request|schedule)\)/
129
+ /\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
122
130
  )
123
- next nil unless match
131
+ # Any other nonempty line that cannot be parsed must not silently
132
+ # shrink check evidence; fail closed instead.
133
+ raise Ace::Git::ProviderMalformedOutputError,
134
+ "Unrecognized `fj actions tasks` output line: #{line[0, 80]}" unless match
124
135
 
125
- {
136
+ tasks << {
126
137
  name: match[:name],
127
138
  state: match[:state].downcase.to_sym,
128
139
  sha: match[:sha]
129
140
  }
130
141
  end
142
+ # A declared count that disagrees with the parsed rows means
143
+ # truncated output; fail closed instead of accepting partial
144
+ # check evidence.
145
+ if declared_count && declared_count != tasks.length
146
+ raise Ace::Git::ProviderMalformedOutputError,
147
+ "Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
148
+ end
149
+ tasks
131
150
  end
132
151
 
133
152
  # Parse `fj repo view` output.