ace-git-forgejo 0.2.0 → 0.3.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: c8a35836d33fbf6fc14e52f4c27ef396244d1f809bc919ceca6ac0202db5e583
4
+ data.tar.gz: 548b2f7734d7f98ef2594e9630ddeb499ae9ead7f8c4297498ebe8726217c4a2
5
5
  SHA512:
6
- metadata.gz: 6cddccbd90c235fe84a052c58dfe3030a6c3b826527da34681d4316579c46e798bc1ca212ee438f5020be3d47ac77d51fc99729ca2805d1f7c3e3f0c0046b30a
7
- data.tar.gz: 621fa2ebff13f4e344dba8500ad40b2e41604a97c39282979424d9eaf942c32c2f266be1f3aaa74037f1244924dd73518067f74d19a93162d0378f3781dcc487
6
+ metadata.gz: c19a8e1f44853f9c173e51118a42f1de9fd6ab04d77c01371c68f16d867f654b938f4cf5b252d8f7d4300c4bdcca826c53ed7dafddc6e616051fab8292aa457d
7
+ data.tar.gz: b312fbb4a37d5e871e55148f40e90951ebc2692c474d87e0052adb6beb0647fb24347b3599b914c7a4c7a93f9ca4726f201bd67301f4ba84ded528db979168da
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-29
11
+
12
+ ### Added
13
+ - Selected-repository binding for every `fj` repository command: the
14
+ resolved server's host/owner/repository is validated once (malformed
15
+ selections raise `ConfigError` before any subprocess) and stamped onto
16
+ every call via `-H` plus qualified `owner/repo#N` ids or `-r owner/repo`,
17
+ so a named server selection can never be silently retargeted by cwd,
18
+ remotes, default login, or another checkout.
19
+ - Capability table grounded in observed forgejo-cli v0.6.0 help/output
20
+ (upstream release binary, plus real-server read-only probes); operations
21
+ without an observed argv form and installed versions outside the observed
22
+ set refuse with `ProviderUnsupportedCapabilityError` before launch.
23
+ - Returned-identity validation: PR/issue numbers and repository view
24
+ full-name/URL must agree with the selection or evidence fails closed
25
+ with `ProviderIdentityMismatchError`.
26
+
27
+ ### Changed
28
+ - All `fj` subprocess launches now go through a two-path executor boundary:
29
+ an allowlisted control path (`fj version`, `fj auth list`) and the
30
+ repository boundary. Authentication compares `fj auth list` lines exactly
31
+ against the selected authority (substring hosts no longer count) and
32
+ scans both output streams (observed v0.6.0 prints "No logins." on stderr
33
+ with exit 0).
34
+ - Unauthenticated-API failures are classified as authentication errors
35
+ (observed Lab text "Only signed in user is allowed to call APIs.").
36
+ - `merge_commit_sha` stays empty: observed fj v0.6.0 exposes no
37
+ authoritative merge-commit field for a selected PR, so cleanup evidence
38
+ never claims one.
39
+
40
+ ### Fixed
41
+ - Selected-host fidelity: `-H` now carries the selected `scheme://authority`
42
+ (fj assumes HTTPS for a bare host, so an http authority was silently
43
+ upgraded), non-http(s) server schemes are rejected as configuration
44
+ errors before any subprocess, and the provider refuses to launch
45
+ repository commands when the readable fj keys file aliases the selected
46
+ host to a different endpoint (read-only check; absent/unreadable keys
47
+ file is allowed, never modified).
48
+
10
49
  ## [0.2.0] - 2026-09-28
11
50
 
12
51
  ### 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
@@ -11,9 +11,14 @@ module Ace
11
11
  # Forgejo provider implementation of the shared provider contract.
12
12
  #
13
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.
14
+ # normalized evidence types defined by the ace-git core. Every
15
+ # repository-scoped call is bound to the selected server's exact
16
+ # host/owner/repository through the {CliExecutor} repository boundary:
17
+ # the selected identity is mandatory, unobserved operations refuse
18
+ # before launch, and returned evidence is checked for conflicting
19
+ # identity. Every failure is classified with the shared taxonomy;
20
+ # `fj` failures are never hidden behind ad-hoc curl calls or silent
21
+ # fallbacks.
17
22
  class Provider < Ace::Git::Providers::Base
18
23
  class << self
19
24
  def display_name
@@ -31,14 +36,15 @@ module Ace
31
36
  CliExecutor.check_installed!(runner: runner)
32
37
  end
33
38
 
34
- # @return [Boolean] true when the server host has a configured `fj` login
39
+ # @return [Boolean] true when the selected server's host has a
40
+ # configured `fj` login
35
41
  def authenticated?
36
- CliExecutor.authenticated?(server_host, runner: runner)
42
+ CliExecutor.authenticated?(repository_target.authority, runner: runner)
37
43
  end
38
44
 
39
45
  # @raise [Ace::Git::ProviderAuthenticationError] when unauthenticated
40
46
  def check_authenticated!
41
- CliExecutor.check_authenticated!(host: server_host, runner: runner)
47
+ CliExecutor.check_authenticated!(authority: repository_target.authority, runner: runner)
42
48
  end
43
49
 
44
50
  # @return [ProviderPullRequest] normalized pull request evidence
@@ -51,13 +57,13 @@ module Ace
51
57
 
52
58
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
53
59
  def pull_request_for_branch(branch:)
54
- # `fj pr search` has no --limit flag; fetch all and order newest-first.
55
- listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
60
+ # `fj pr search` has no --limit flag (observed v0.6.0); fetch all
61
+ # and order newest-first.
62
+ listing = fj(:pr_search, "all")
56
63
  entries = Parsers.parse_search(listing).sort_by { |entry| -entry[:number] }
57
64
 
58
65
  entries.each do |entry|
59
- view = fj(["--style", "minimal", "pr", "view", entry[:number].to_s])
60
- parsed = Parsers.parse_pr_view(view)
66
+ parsed = view_pr(entry[:number])
61
67
  next unless parsed && parsed[:head_ref] == branch
62
68
 
63
69
  parsed[:head_sha] = head_sha_of(entry[:number])
@@ -69,7 +75,7 @@ module Ace
69
75
 
70
76
  # @return [String] unified diff text for the pull request
71
77
  def pull_request_diff(number:)
72
- fj(["pr", "view", number.to_s, "diff"])
78
+ fj(:pr_diff, request_number!(number))
73
79
  end
74
80
 
75
81
  # `fj pr search` has no --limit flag; cap client-side after sorting
@@ -77,7 +83,7 @@ module Ace
77
83
  #
78
84
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
79
85
  def recent_pull_requests(limit:)
80
- listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
86
+ listing = fj(:pr_search, "all")
81
87
  Parsers.parse_search(listing)
82
88
  .map { |entry| normalize_search_entry(entry) }
83
89
  .sort_by { |pr| -pr.number }
@@ -86,10 +92,11 @@ module Ace
86
92
 
87
93
  # @return [ProviderIssue] normalized issue evidence
88
94
  def issue(number:)
89
- view = fj(["--style", "minimal", "issue", "view", number.to_s])
95
+ view = fj(:issue_view, request_number!(number))
90
96
  parsed = Parsers.parse_issue_view(view) ||
91
97
  raise(Ace::Git::ProviderMalformedOutputError,
92
98
  "Unrecognized `fj issue view #{number}` output; update the Forgejo provider parser")
99
+ verify_returned_number!(parsed, number, "issue")
93
100
 
94
101
  Ace::Git::ProviderIssue.new(
95
102
  server_name: server.name,
@@ -104,7 +111,7 @@ module Ace
104
111
 
105
112
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
106
113
  def checks(ref:)
107
- tasks = fj(["--style", "minimal", "actions", "tasks"])
114
+ tasks = fj(:actions_tasks)
108
115
  Parsers.parse_actions_tasks(tasks)
109
116
  .select { |task| task[:sha].start_with?(ref.to_s) || ref.to_s.start_with?(task[:sha]) }
110
117
  .map do |task|
@@ -119,15 +126,19 @@ module Ace
119
126
  end
120
127
 
121
128
  # @return [ProviderRepository] normalized repository evidence
129
+ # @raise [ProviderIdentityMismatchError] when the CLI reports a
130
+ # repository other than the selected one
122
131
  def repository
123
- view = fj(["--style", "minimal", "repo", "view"])
132
+ target = repository_target
133
+ view = fj(:repo_view)
124
134
  parsed = Parsers.parse_repo_view(view)
135
+ verify_returned_repository!(parsed, target)
125
136
 
126
137
  Ace::Git::ProviderRepository.new(
127
138
  server_name: server.name,
128
139
  full_name: parsed[:full_name],
129
140
  default_branch: nil,
130
- url: parsed[:url]
141
+ url: parsed[:url] || target.url
131
142
  )
132
143
  end
133
144
 
@@ -137,7 +148,7 @@ module Ace
137
148
  # base/head identity, with head SHA provenance
138
149
  def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
139
150
  require_base_on_server!(base_repository_url)
140
- listing = fj(["--style", "minimal", "pr", "search", "--state", "open"])
151
+ listing = fj(:pr_search, "open")
141
152
  Parsers.parse_search(listing).filter_map do |entry|
142
153
  parsed = view_pr(entry[:number])
143
154
  next unless parsed && parsed[:state] == :open
@@ -150,14 +161,14 @@ module Ace
150
161
 
151
162
  # Create a PR via `fj pr create`, reconciling to an exact open match
152
163
  # first and proving the resulting head against `expected_head`.
153
- # `fj` cannot create draft PRs; the receipt reports the provider's
154
- # real draft state.
164
+ # `fj` cannot encode an explicit fork head; the receipt reports the
165
+ # provider's real draft state.
155
166
  #
156
167
  # @return [ProviderMutationReceipt] idempotency :created/:existing
157
168
  def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
158
- # `fj pr create` operates on the checkout's repository and cannot
159
- # encode an explicit fork head; refusing before any command (even
160
- # lookups) beats creating the wrong PR.
169
+ # `fj pr create -r` binds the base repository, but no observed form
170
+ # encodes a cross-repository fork head; refusing before any command
171
+ # (even lookups) beats creating the wrong PR.
161
172
  unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
162
173
  raise Ace::Git::ProviderUnsupportedCapabilityError,
163
174
  "Forgejo CLI (fj) pr create cannot target head repository " \
@@ -178,9 +189,8 @@ module Ace
178
189
  return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
179
190
  end
180
191
 
181
- args = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
182
- args += ["--body", body] if body
183
- send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
192
+ send_mutation(:pr_create, title, head_ref, base_ref, body,
193
+ ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
184
194
 
185
195
  created = reconcile_created(
186
196
  head_repository_url: head_repository_url, head_ref: head_ref,
@@ -195,9 +205,10 @@ module Ace
195
205
  #
196
206
  # @return [ProviderMutationReceipt] operation :update
197
207
  def update_pull_request(number:, expected_head:, title: nil, body: nil)
208
+ number = request_number!(number)
198
209
  verify_expected_head!(fetch_pr(number), expected_head)
199
- send_mutation(["pr", "edit", number.to_s, "title", title], ambiguous: false) if title
200
- send_mutation(["pr", "edit", number.to_s, "body", body], ambiguous: false) if body
210
+ send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
211
+ send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
201
212
  receipt(:update, fetch_pr(number), nil)
202
213
  end
203
214
 
@@ -222,23 +233,39 @@ module Ace
222
233
 
223
234
  private
224
235
 
225
- # Run one `fj` command, classifying every failure with the taxonomy.
226
- def fj(args)
227
- result = CliExecutor.execute(args, timeout: timeout, runner: runner)
236
+ # Validated selected repository identity. Built once; malformed
237
+ # selections fail as configuration errors before any subprocess.
238
+ def repository_target
239
+ @repository_target ||= RepositoryBinding::Target.resolve(server.url)
240
+ end
241
+
242
+ # Refuse before any repository subprocess when the installed fj was
243
+ # never observed; a different version must not inherit capabilities.
244
+ def ensure_observed_version!
245
+ return if @version_checked
246
+
247
+ CliExecutor.check_version_supported!(runner: runner)
248
+ @version_checked = true
249
+ end
250
+
251
+ # Run one repository-scoped `fj` command bound to the selected
252
+ # target, classifying every failure with the taxonomy.
253
+ def fj(operation, *arguments)
254
+ result = execute_repository(operation, arguments)
228
255
  return result[:stdout] if result[:success]
229
256
 
230
- classify_failure(result[:stderr], context: args.join(" "))
257
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
231
258
  end
232
259
 
233
- # Send one mutating `fj` command. When `ambiguous` is true (create),
234
- # any transport-level failure becomes an unknown outcome: the request
235
- # may have mutated the forge, so callers must reconcile by exact
236
- # identity instead of retrying blindly.
237
- def send_mutation(args, ambiguous:, identity: nil)
238
- result = CliExecutor.execute(args, timeout: timeout, runner: runner)
260
+ # Send one mutating repository operation. When `ambiguous` is true
261
+ # (create), any transport-level failure becomes an unknown outcome:
262
+ # the request may have mutated the forge, so callers must reconcile
263
+ # by exact identity instead of retrying blindly.
264
+ def send_mutation(operation, *arguments, ambiguous:, identity: nil)
265
+ result = execute_repository(operation, arguments)
239
266
  return result if result[:success]
240
267
 
241
- classify_failure(result[:stderr], context: args.join(" "))
268
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
242
269
  rescue Ace::Git::ProviderUnreachableError => e
243
270
  raise e unless ambiguous
244
271
 
@@ -247,12 +274,36 @@ module Ace
247
274
  "reconcile by exact identity before repeating: #{identity}"
248
275
  end
249
276
 
277
+ def execute_repository(operation, arguments)
278
+ target = repository_target
279
+ ensure_observed_version!
280
+ ensure_not_redirecting!(target)
281
+ CliExecutor.execute_repository(
282
+ target: target, operation: operation,
283
+ arguments: arguments, timeout: timeout, runner: runner
284
+ )
285
+ end
286
+
287
+ # Refuse before any subprocess when the readable fj configuration
288
+ # aliases the selected host to a different endpoint: `-H` cannot
289
+ # prevent that redirect, and returned-identity checks cannot see it.
290
+ def ensure_not_redirecting!(target)
291
+ return if @redirect_checked
292
+
293
+ RepositoryBinding.reject_selected_host_redirect!(target.host_url)
294
+ @redirect_checked = true
295
+ end
296
+
297
+ def operation_context(operation, arguments)
298
+ "#{operation} #{arguments.compact.join(" ")}".strip
299
+ end
300
+
250
301
  def classify_failure(message, context:)
251
302
  message = message.to_s
252
303
  case message
253
304
  when /not found|does not exist|no pull request|no issue/i
254
305
  raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{context}): #{message}"
255
- when /access denied|unauthorized|token/i
306
+ when /access denied|unauthorized|token|only signed in|not logged in|log ?in required/i
256
307
  raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
257
308
  else
258
309
  raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{context}): #{message}"
@@ -270,12 +321,17 @@ module Ace
270
321
  normalize_pr(parsed)
271
322
  end
272
323
 
273
- # View one PR and parse its stable minimal-style output.
324
+ # View one PR and parse its stable minimal-style output. The
325
+ # qualified `owner/repo#N` reference binds the read to the selected
326
+ # repository; a returned number that disagrees with the request is a
327
+ # routed mismatch and fails closed.
274
328
  def view_pr(number)
275
- view = fj(["--style", "minimal", "pr", "view", number.to_s])
276
- Parsers.parse_pr_view(view) ||
329
+ view = fj(:pr_view, number)
330
+ parsed = Parsers.parse_pr_view(view) ||
277
331
  raise(Ace::Git::ProviderMalformedOutputError,
278
332
  "Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
333
+ verify_returned_number!(parsed, number, "pull request")
334
+ parsed
279
335
  end
280
336
 
281
337
  # Re-fetch and verify the PR created by an accepted create request.
@@ -319,7 +375,7 @@ module Ace
319
375
  end
320
376
 
321
377
  def head_sha_of(number)
322
- commits = fj(["--style", "minimal", "pr", "view", number.to_s, "commits"])
378
+ commits = fj(:pr_commits, number)
323
379
  Parsers.parse_head_sha(commits)
324
380
  end
325
381
 
@@ -337,7 +393,10 @@ module Ace
337
393
  draft: nil,
338
394
  merged_at: nil,
339
395
  head_repository_url: head_repository_url_of(parsed),
340
- base_repository_url: server.url,
396
+ base_repository_url: repository_target.url,
397
+ # fj v0.6.0 exposes no authoritative merge-commit field for a
398
+ # selected PR (observed; see the task capability evidence), so
399
+ # merge evidence stays empty and cleanup retains the checkout.
341
400
  merge_commit_sha: nil
342
401
  )
343
402
  end
@@ -347,14 +406,14 @@ module Ace
347
406
  # base repository; canonical PRs use the configured repository.
348
407
  def head_repository_url_of(parsed)
349
408
  repo = parsed[:head_repository]
350
- return server.url if repo.nil? || repo.empty?
409
+ return repository_target.url if repo.nil? || repo.empty?
351
410
 
352
411
  "#{server_host_root}/#{repo}"
353
412
  end
354
413
 
355
414
  def server_host_root
356
415
  @server_host_root ||= begin
357
- uri = URI.parse(server.url.to_s)
416
+ uri = URI.parse(repository_target.url)
358
417
  "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
359
418
  end
360
419
  end
@@ -373,27 +432,58 @@ module Ace
373
432
  draft: nil,
374
433
  merged_at: nil,
375
434
  head_repository_url: nil,
376
- base_repository_url: nil,
435
+ base_repository_url: repository_target.url,
377
436
  merge_commit_sha: nil
378
437
  )
379
438
  end
380
439
 
381
- def server_host
382
- @server_host ||= begin
383
- uri = URI.parse(server.url.to_s)
384
- uri.host
385
- rescue URI::Error
386
- nil
440
+ # Reject non-numeric or non-positive PR/issue numbers as
441
+ # configuration failures before any subprocess.
442
+ def request_number!(number)
443
+ text = number.to_s
444
+ unless /\A\d+\z/.match?(text) && text.to_i.positive?
445
+ raise Ace::Git::ConfigError,
446
+ "Pull request and issue numbers must be positive integers, got #{number.inspect}"
387
447
  end
448
+
449
+ text.to_i
450
+ end
451
+
452
+ # PR and issue numbers are not globally unique: evidence whose
453
+ # number disagrees with the selected reference was not routed to the
454
+ # selected repository and must never be relabelled.
455
+ def verify_returned_number!(parsed, requested, kind)
456
+ return if parsed[:number] == requested.to_i
457
+
458
+ raise Ace::Git::ProviderIdentityMismatchError,
459
+ "Forgejo returned #{kind} ##{parsed[:number]} for selected #{kind} " \
460
+ "##{requested} on #{repository_target.repo}; refusing misrouted evidence"
461
+ end
462
+
463
+ # `fj repo view` was bound to the selected owner/repository, so
464
+ # returned identity fields must agree with the selection.
465
+ def verify_returned_repository!(parsed, target)
466
+ full_name = parsed[:full_name].to_s.sub(%r{\.git\z}i, "")
467
+ unless !full_name.empty? && full_name.casecmp(target.repo).zero?
468
+ raise Ace::Git::ProviderIdentityMismatchError,
469
+ "Forgejo repo view returned #{full_name.inspect} for selected repository " \
470
+ "#{target.repo}; refusing misrouted evidence"
471
+ end
472
+ return if parsed[:url].nil? || parsed[:url].empty? ||
473
+ Ace::Git::Atoms::ServerUrl.match?(parsed[:url], target.url)
474
+
475
+ raise Ace::Git::ProviderIdentityMismatchError,
476
+ "Forgejo repo view returned URL #{parsed[:url].inspect} for selected repository " \
477
+ "#{target.url}; refusing misrouted evidence"
388
478
  end
389
479
 
390
480
  def pr_url(number)
391
- base = server.url.to_s.chomp("/")
481
+ base = repository_target.url.to_s.chomp("/")
392
482
  "#{base}/pulls/#{number}"
393
483
  end
394
484
 
395
485
  def issue_url(number)
396
- base = server.url.to_s.chomp("/")
486
+ base = repository_target.url.to_s.chomp("/")
397
487
  "#{base}/issues/#{number}"
398
488
  end
399
489
  end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Ace
7
+ module Git
8
+ module Forgejo
9
+ # Selected-server identity binding for `fj` repository commands.
10
+ #
11
+ # Forgejo CLI v0.6.0 exposes two explicit-target mechanisms and no
12
+ # others (see the task evidence file): `-H HOST` plus either
13
+ # `-r OWNER/REPO` (search, create, issue/actions repo selection) or a
14
+ # qualified `OWNER/REPO#N` object id (view/edit/status family). A
15
+ # repository command may only launch through {RepositoryBinding.argv_for},
16
+ # which exists for operations whose argv form was observed on a real
17
+ # binary; everything else refuses before any subprocess.
18
+ module RepositoryBinding
19
+ # forgejo-cli versions whose help/output this table was observed on.
20
+ # A different installed version must not inherit these capabilities;
21
+ # the executor refuses it until the table is re-observed and extended.
22
+ OBSERVED_VERSIONS = ["v0.6.0"].freeze
23
+
24
+ # The validated selected repository. Built once from the resolved
25
+ # server URL; malformed selections fail with ConfigError before any
26
+ # subprocess launch.
27
+ class Target
28
+ attr_reader :host, :authority, :host_url, :repo, :url
29
+
30
+ SUPPORTED_SCHEMES = ["http", "https"].freeze
31
+
32
+ # @param server_url [String] resolved server URL (https://host/owner/repo)
33
+ # @raise [Ace::Git::ConfigError] when host, scheme, or owner/repository is missing
34
+ def self.resolve(server_url)
35
+ uri = URI.parse(server_url.to_s)
36
+ raise_config_error!(server_url, "missing host") if uri.host.nil? || uri.host.empty?
37
+ unless SUPPORTED_SCHEMES.include?(uri.scheme.to_s.downcase)
38
+ raise_config_error!(server_url, "scheme must be http or https")
39
+ end
40
+
41
+ authority = if uri.port && uri.port != uri.default_port
42
+ "#{uri.host}:#{uri.port}"
43
+ else
44
+ uri.host
45
+ end
46
+ path = uri.path.to_s.sub(%r{/+\z}, "").sub(/\.git\z/i, "")
47
+ segments = path.split("/").reject(&:empty?)
48
+ unless segments.length == 2
49
+ raise_config_error!(server_url, "path must be exactly owner/repository")
50
+ end
51
+
52
+ repo = "#{segments[0]}/#{segments[1]}"
53
+ # `fj -H` accepts a full URL and otherwise assumes HTTPS (observed
54
+ # v0.6.0); passing scheme + authority preserves the configured
55
+ # endpoint exactly.
56
+ host_url = "#{uri.scheme.downcase}://#{authority}"
57
+ new(
58
+ host: uri.host, authority: authority, host_url: host_url,
59
+ repo: repo, url: "#{host_url}/#{repo}"
60
+ )
61
+ rescue URI::Error
62
+ raise_config_error!(server_url, "unparseable URL")
63
+ end
64
+
65
+ def self.raise_config_error!(server_url, reason)
66
+ raise Ace::Git::ConfigError,
67
+ "Selected Forgejo server URL #{server_url.inspect} is unusable (#{reason}); " \
68
+ "configure the server as scheme://host/owner/repository"
69
+ end
70
+
71
+ def initialize(host:, authority:, host_url:, repo:, url:)
72
+ @host = host
73
+ @authority = authority
74
+ @host_url = host_url
75
+ @repo = repo
76
+ @url = url
77
+ end
78
+ private_class_method :new
79
+
80
+ # Qualified object id form accepted by `fj pr/issue view|edit` (v0.6.0).
81
+ def qualified_ref(number)
82
+ "#{repo}##{number}"
83
+ end
84
+ end
85
+
86
+ # Capability table: repository operations with an argv form observed
87
+ # on real forgejo-cli v0.6.0 (task evidence file). Forms receive the
88
+ # validated Target plus the provider's per-call arguments and return
89
+ # the argv after the binary name; the executor stamps `-H host`.
90
+ FORMS = {
91
+ pr_view: ->(target, number) do
92
+ ["--style", "minimal", "pr", "view", target.qualified_ref(number)]
93
+ end,
94
+ pr_commits: ->(target, number) do
95
+ ["--style", "minimal", "pr", "view", target.qualified_ref(number), "commits"]
96
+ end,
97
+ pr_diff: ->(target, number) do
98
+ ["pr", "view", target.qualified_ref(number), "diff"]
99
+ end,
100
+ pr_search: ->(target, state) do
101
+ ["--style", "minimal", "pr", "search", "--state", state, "-r", target.repo]
102
+ end,
103
+ pr_create: ->(target, title, head_ref, base_ref, body) do
104
+ argv = ["pr", "create", title, "--head", head_ref, "--base", base_ref]
105
+ argv += ["--body", body] if body
106
+ argv + ["-r", target.repo]
107
+ end,
108
+ pr_edit_title: ->(target, number, title) do
109
+ ["pr", "edit", target.qualified_ref(number), "title", title]
110
+ end,
111
+ pr_edit_body: ->(target, number, body) do
112
+ ["pr", "edit", target.qualified_ref(number), "body", body]
113
+ end,
114
+ issue_view: ->(target, number) do
115
+ ["--style", "minimal", "issue", "view", target.qualified_ref(number)]
116
+ end,
117
+ actions_tasks: ->(target) do
118
+ ["--style", "minimal", "actions", "tasks", "-r", target.repo]
119
+ end,
120
+ repo_view: ->(target) do
121
+ ["--style", "minimal", "repo", "view", target.repo]
122
+ end
123
+ }.freeze
124
+
125
+ class << self
126
+ # Resolve one repository operation to its explicitly bound argv.
127
+ #
128
+ # @param operation [Symbol] key in {FORMS}
129
+ # @param target [Target] validated selected repository
130
+ # @param arguments [Array] per-operation arguments
131
+ # @raise [Ace::Git::ProviderUnsupportedCapabilityError] when the
132
+ # operation has no observed form
133
+ def argv_for(operation, target, arguments)
134
+ form = FORMS.fetch(operation) do
135
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
136
+ "forgejo-cli #{OBSERVED_VERSIONS.join("/")} exposes no repository-bound " \
137
+ "argv form for #{operation.inspect}; refusing to run it against #{target.repo}"
138
+ end
139
+ form.call(target, *arguments)
140
+ end
141
+
142
+ # Refuse to launch repository commands when the readable fj keys
143
+ # file contains an alias that redirects the selected host. fj
144
+ # v0.6.0 applies its `aliases` map to `-H` values (observed: an
145
+ # alias silently rewrites the endpoint), and no CLI command prints
146
+ # aliases, so ACE verifies the one configuration file it must
147
+ # agree with — read-only, never modified. An absent or unreadable
148
+ # keys file (other fj versions, moved storage) cannot be verified
149
+ # and is allowed; misdirected runtime requests still classify
150
+ # through the shared taxonomy.
151
+ #
152
+ # @param host_url [String] selected scheme://authority
153
+ # @param config_path [String, nil] explicit keys-file path (tests)
154
+ # @raise [Ace::Git::ConfigError] on a conflicting redirect
155
+ def reject_selected_host_redirect!(host_url, config_path: nil)
156
+ path = config_path || default_keys_path
157
+ return unless path && File.exist?(path)
158
+
159
+ data = JSON.parse(File.read(path))
160
+ aliases = data.is_a?(Hash) ? data["aliases"] : nil
161
+ return unless aliases.is_a?(Hash)
162
+
163
+ selected = strip_scheme(host_url)
164
+ redirect = aliases[selected] || aliases[host_url.to_s]
165
+ return if redirect.nil? || redirect.to_s.empty?
166
+
167
+ redirect_authority = strip_scheme(redirect)
168
+ return if redirect_authority.casecmp(selected).zero?
169
+
170
+ raise Ace::Git::ConfigError,
171
+ "fj configuration redirects selected host #{selected} to #{redirect} " \
172
+ "(#{path}); remove or fix the conflicting fj alias — ACE will not " \
173
+ "operate on a redirected endpoint"
174
+ rescue JSON::ParserError, IOError, SystemCallError
175
+ nil
176
+ end
177
+
178
+ # Observed forgejo-cli v0.6.0 keys-file locations (data_dir of the
179
+ # `directories` crate, plus the legacy Cyborus org path).
180
+ def default_keys_path
181
+ candidates = []
182
+ xdg = ENV["XDG_DATA_HOME"]
183
+ candidates << File.join(xdg, "forgejo-cli", "keys.json") if xdg && !xdg.empty?
184
+ home = Dir.home rescue nil
185
+ if home
186
+ candidates << File.join(home, ".local", "share", "forgejo-cli", "keys.json")
187
+ candidates << File.join(home, "Library", "Application Support", "forgejo-cli", "keys.json")
188
+ candidates << File.join(home, ".local", "share", "Cyborus", "forgejo-cli", "keys.json")
189
+ candidates << File.join(home, "Library", "Application Support", "Cyborus", "forgejo-cli", "keys.json")
190
+ end
191
+ candidates.find { |path| File.exist?(path) }
192
+ end
193
+
194
+ private
195
+
196
+ def strip_scheme(url)
197
+ url.to_s.sub(%r{\A[a-z][a-z0-9+.\-]*://}i, "").chomp("/")
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Git
5
5
  module Forgejo
6
- VERSION = "0.2.0"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
9
9
  end
@@ -3,6 +3,7 @@
3
3
  require "ace/git"
4
4
 
5
5
  require_relative "forgejo/version"
6
+ require_relative "forgejo/repository_binding"
6
7
  require_relative "forgejo/cli_executor"
7
8
  require_relative "forgejo/pr_identifier"
8
9
  require_relative "forgejo/parsers"
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-git
@@ -42,6 +42,7 @@ files:
42
42
  - lib/ace/git/forgejo/parsers.rb
43
43
  - lib/ace/git/forgejo/pr_identifier.rb
44
44
  - lib/ace/git/forgejo/provider.rb
45
+ - lib/ace/git/forgejo/repository_binding.rb
45
46
  - lib/ace/git/forgejo/version.rb
46
47
  homepage: https://forgejo.tail6c0887.ts.net/cs3b/ace
47
48
  licenses: