ace-git-forgejo 0.1.1 → 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: f652620ca5f68a59c0809d789d57b13fbcebda51a12d93863f24359bd316ad2b
4
- data.tar.gz: 843e555799aac783ae018d8676314a6f3681f4836854a085a0c2d2232b10183c
3
+ metadata.gz: c8a35836d33fbf6fc14e52f4c27ef396244d1f809bc919ceca6ac0202db5e583
4
+ data.tar.gz: 548b2f7734d7f98ef2594e9630ddeb499ae9ead7f8c4297498ebe8726217c4a2
5
5
  SHA512:
6
- metadata.gz: 58efabf1f68159415d2f0908cdb11f5a0bd15e32e3e299ada2049d12af79b0794d8090ca26098f0f6dedfc5b6f8da800a9071bfdac7afacb58038f44faeb5287
7
- data.tar.gz: 6ac53befa67d15633f0fc19c4d431a65d8357cdd96051b16295a927c5de09f434a1b667bfdfe2aa0148429d2205db5651a12569511171df07c78bfe19eeabfab
6
+ metadata.gz: c19a8e1f44853f9c173e51118a42f1de9fd6ab04d77c01371c68f16d867f654b938f4cf5b252d8f7d4300c4bdcca826c53ed7dafddc6e616051fab8292aa457d
7
+ data.tar.gz: b312fbb4a37d5e871e55148f40e90951ebc2692c474d87e0052adb6beb0647fb24347b3599b914c7a4c7a93f9ca4726f201bd67301f4ba84ded528db979168da
data/CHANGELOG.md CHANGED
@@ -5,8 +5,78 @@ All notable changes to ace-git-forgejo will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
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
+
49
+ ## [0.2.0] - 2026-09-28
50
+
51
+ ### Added
52
+ - PR lifecycle mutations matching the shared provider contract: exact-match
53
+ lookup via `fj pr search`, idempotent create reconciliation, head-verified
54
+ title/body updates.
55
+ - Fork provenance: the `fj pr view` head-repository segment is parsed and
56
+ returned in normalized PR evidence.
57
+
58
+ ### Changed
59
+ - `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
60
+ offers no draft-to-ready command and cannot enforce an expected-head merge
61
+ precondition atomically, so both refuse instead of racing.
62
+ - PR view parser captures the fork repository prefix of the `From` segment.
63
+
64
+ ### Added
65
+ - PR lifecycle mutations matching the shared provider contract: exact-match
66
+ lookup via `fj pr search`, idempotent create reconciliation, head-verified
67
+ title/body updates.
68
+ - Fork provenance: the `fj pr view` head-repository segment is parsed and
69
+ returned in normalized PR evidence.
70
+
71
+ ### Changed
72
+ - `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
73
+ offers no draft-to-ready command and cannot enforce an expected-head merge
74
+ precondition atomically, so both refuse instead of racing.
75
+ - PR view parser captures the fork repository prefix of the `From` segment.
76
+
8
77
  ## [0.1.1] - 2026-09-28
9
78
 
79
+
10
80
  ### Fixed
11
81
 
12
82
  - 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.
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
@@ -29,7 +29,11 @@ module Ace
29
29
  # By lab-builder - Merged - +252 -8
30
30
  # From `owner/repo:head-branch` into `main`
31
31
  #
32
- # @return [Hash, nil] {number:, title:, author:, state:, head_ref:, base_ref:}
32
+ # The `From` segment carries an optional `owner/repo:` prefix before
33
+ # the head branch; it is present only for fork (cross-repository)
34
+ # pull requests.
35
+ #
36
+ # @return [Hash, nil] {number:, title:, author:, state:, head_ref:, base_ref:, head_repository:}
33
37
  def self.parse_pr_view(text)
34
38
  lines = text.to_s.lines.map { |line| clean(line) }.reject(&:empty?)
35
39
  return nil if lines.empty?
@@ -44,7 +48,8 @@ module Ace
44
48
  author: nil,
45
49
  state: nil,
46
50
  head_ref: nil,
47
- base_ref: nil
51
+ base_ref: nil,
52
+ head_repository: nil
48
53
  }
49
54
 
50
55
  byline = lines[1].to_s
@@ -54,9 +59,12 @@ module Ace
54
59
  end
55
60
 
56
61
  refs = lines.find { |line| line.start_with?("From ") }
57
- if (match = refs.to_s.match(/\AFrom\s+`(?:[^`:]+:)?(?<head>[^`]+)`\s+into\s+`(?<base>[^`]+)`\z/))
62
+ if (match = refs.to_s.match(
63
+ /\AFrom\s+`(?:(?<repo>[^`:]+):)?(?<head>[^`]+)`\s+into\s+`(?<base>[^`]+)`\z/
64
+ ))
58
65
  info[:head_ref] = match[:head]
59
66
  info[:base_ref] = match[:base]
67
+ info[:head_repository] = match[:repo]
60
68
  end
61
69
 
62
70
  info
@@ -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
@@ -46,24 +52,18 @@ module Ace
46
52
  # @raise [ProviderMalformedOutputError] when `fj` output is unexpected
47
53
  # @raise [ProviderUnreachableError] when the endpoint is unreachable
48
54
  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)
55
+ fetch_pr(number)
56
56
  end
57
57
 
58
58
  # @return [ProviderPullRequest, nil] evidence for the branch's PR
59
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"])
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")
62
63
  entries = Parsers.parse_search(listing).sort_by { |entry| -entry[:number] }
63
64
 
64
65
  entries.each do |entry|
65
- view = fj(["--style", "minimal", "pr", "view", entry[:number].to_s])
66
- parsed = Parsers.parse_pr_view(view)
66
+ parsed = view_pr(entry[:number])
67
67
  next unless parsed && parsed[:head_ref] == branch
68
68
 
69
69
  parsed[:head_sha] = head_sha_of(entry[:number])
@@ -75,7 +75,7 @@ module Ace
75
75
 
76
76
  # @return [String] unified diff text for the pull request
77
77
  def pull_request_diff(number:)
78
- fj(["pr", "view", number.to_s, "diff"])
78
+ fj(:pr_diff, request_number!(number))
79
79
  end
80
80
 
81
81
  # `fj pr search` has no --limit flag; cap client-side after sorting
@@ -83,7 +83,7 @@ module Ace
83
83
  #
84
84
  # @return [Array<ProviderPullRequest>] recent PRs, newest first
85
85
  def recent_pull_requests(limit:)
86
- listing = fj(["--style", "minimal", "pr", "search", "--state", "all"])
86
+ listing = fj(:pr_search, "all")
87
87
  Parsers.parse_search(listing)
88
88
  .map { |entry| normalize_search_entry(entry) }
89
89
  .sort_by { |pr| -pr.number }
@@ -92,10 +92,11 @@ module Ace
92
92
 
93
93
  # @return [ProviderIssue] normalized issue evidence
94
94
  def issue(number:)
95
- view = fj(["--style", "minimal", "issue", "view", number.to_s])
95
+ view = fj(:issue_view, request_number!(number))
96
96
  parsed = Parsers.parse_issue_view(view) ||
97
97
  raise(Ace::Git::ProviderMalformedOutputError,
98
98
  "Unrecognized `fj issue view #{number}` output; update the Forgejo provider parser")
99
+ verify_returned_number!(parsed, number, "issue")
99
100
 
100
101
  Ace::Git::ProviderIssue.new(
101
102
  server_name: server.name,
@@ -110,7 +111,7 @@ module Ace
110
111
 
111
112
  # @return [Array<ProviderCheck>] normalized check evidence for a ref
112
113
  def checks(ref:)
113
- tasks = fj(["--style", "minimal", "actions", "tasks"])
114
+ tasks = fj(:actions_tasks)
114
115
  Parsers.parse_actions_tasks(tasks)
115
116
  .select { |task| task[:sha].start_with?(ref.to_s) || ref.to_s.start_with?(task[:sha]) }
116
117
  .map do |task|
@@ -125,38 +126,256 @@ module Ace
125
126
  end
126
127
 
127
128
  # @return [ProviderRepository] normalized repository evidence
129
+ # @raise [ProviderIdentityMismatchError] when the CLI reports a
130
+ # repository other than the selected one
128
131
  def repository
129
- view = fj(["--style", "minimal", "repo", "view"])
132
+ target = repository_target
133
+ view = fj(:repo_view)
130
134
  parsed = Parsers.parse_repo_view(view)
135
+ verify_returned_repository!(parsed, target)
131
136
 
132
137
  Ace::Git::ProviderRepository.new(
133
138
  server_name: server.name,
134
139
  full_name: parsed[:full_name],
135
140
  default_branch: nil,
136
- url: parsed[:url]
141
+ url: parsed[:url] || target.url
142
+ )
143
+ end
144
+
145
+ # ---- PR lifecycle mutations ----
146
+
147
+ # @return [Array<ProviderPullRequest>] open PRs matching the exact
148
+ # base/head identity, with head SHA provenance
149
+ def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
150
+ require_base_on_server!(base_repository_url)
151
+ listing = fj(:pr_search, "open")
152
+ Parsers.parse_search(listing).filter_map do |entry|
153
+ parsed = view_pr(entry[:number])
154
+ next unless parsed && parsed[:state] == :open
155
+ next unless parsed[:head_ref] == head_ref && parsed[:base_ref] == base_ref
156
+ next unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url_of(parsed), head_repository_url)
157
+
158
+ with_head_sha(parsed)
159
+ end
160
+ end
161
+
162
+ # Create a PR via `fj pr create`, reconciling to an exact open match
163
+ # first and proving the resulting head against `expected_head`.
164
+ # `fj` cannot encode an explicit fork head; the receipt reports the
165
+ # provider's real draft state.
166
+ #
167
+ # @return [ProviderMutationReceipt] idempotency :created/:existing
168
+ def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
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.
172
+ unless Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
173
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
174
+ "Forgejo CLI (fj) pr create cannot target head repository " \
175
+ "#{head_repository_url} (resolved server repository: #{server.url}); " \
176
+ "run from the fork checkout or use the forge web UI"
177
+ end
178
+
179
+ matches = find_open_pull_requests(
180
+ head_repository_url: head_repository_url, head_ref: head_ref,
181
+ base_repository_url: server.url, base_ref: base_ref
137
182
  )
183
+ if matches.size > 1
184
+ raise Ace::Git::ProviderConflictingMatchesError,
185
+ "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
186
+ "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
187
+ end
188
+ if matches.size == 1
189
+ return receipt(:create, verify_expected_head!(matches.first, expected_head), :existing)
190
+ end
191
+
192
+ send_mutation(:pr_create, title, head_ref, base_ref, body,
193
+ ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
194
+
195
+ created = reconcile_created(
196
+ head_repository_url: head_repository_url, head_ref: head_ref,
197
+ base_ref: base_ref, expected_head: expected_head
198
+ )
199
+ receipt(:create, created, :created)
200
+ end
201
+
202
+ # `fj pr edit` takes one field subcommand per invocation (title|body),
203
+ # so multi-field updates are separate commands with final evidence
204
+ # fetched only after every field update succeeded.
205
+ #
206
+ # @return [ProviderMutationReceipt] operation :update
207
+ def update_pull_request(number:, expected_head:, title: nil, body: nil)
208
+ number = request_number!(number)
209
+ verify_expected_head!(fetch_pr(number), expected_head)
210
+ send_mutation(:pr_edit_title, number, title, ambiguous: false) if title
211
+ send_mutation(:pr_edit_body, number, body, ambiguous: false) if body
212
+ receipt(:update, fetch_pr(number), nil)
213
+ end
214
+
215
+ # `fj` offers no draft-to-ready command; classified as an unsupported
216
+ # capability instead of guessing at provider behavior.
217
+ def ready_pull_request(number:, expected_head:)
218
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
219
+ "Forgejo CLI (fj) cannot mark a draft pull request ready; use the forge web UI"
220
+ end
221
+
222
+ # `fj pr merge` cannot enforce an expected-head precondition
223
+ # atomically, so merging is refused rather than racing a stale head.
224
+ def merge_pull_request(number:, expected_head:, method:)
225
+ unless %i[squash merge rebase].include?(method)
226
+ raise ArgumentError, "Invalid merge method #{method.inspect}"
227
+ end
228
+
229
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
230
+ "Forgejo CLI (fj) pr merge cannot enforce expected-head (#{expected_head}) " \
231
+ "atomically; refusing unsafe merge"
138
232
  end
139
233
 
140
234
  private
141
235
 
142
- # Run one `fj` command, classifying every failure with the taxonomy.
143
- def fj(args)
144
- 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)
145
255
  return result[:stdout] if result[:success]
146
256
 
147
- message = result[:stderr].to_s
257
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
258
+ end
259
+
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)
266
+ return result if result[:success]
267
+
268
+ classify_failure(result[:stderr], context: operation_context(operation, arguments))
269
+ rescue Ace::Git::ProviderUnreachableError => e
270
+ raise e unless ambiguous
271
+
272
+ raise Ace::Git::ProviderUnknownOutcomeError,
273
+ "PR create outcome unknown after transport failure (#{e.message}); " \
274
+ "reconcile by exact identity before repeating: #{identity}"
275
+ end
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
+
301
+ def classify_failure(message, context:)
302
+ message = message.to_s
148
303
  case message
149
304
  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
305
+ raise Ace::Git::ProviderObjectNotFoundError, "Object not found (#{context}): #{message}"
306
+ when /access denied|unauthorized|token|only signed in|not logged in|log ?in required/i
152
307
  raise Ace::Git::ProviderAuthenticationError, "Not authenticated with Forgejo: #{message}"
153
308
  else
154
- raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{args.join(" ")}): #{message}"
309
+ raise Ace::Git::ProviderUnreachableError, "Forgejo request failed (#{context}): #{message}"
310
+ end
311
+ end
312
+
313
+ # Fetch one PR as normalized evidence with exact head SHA.
314
+ def fetch_pr(number)
315
+ with_head_sha(view_pr(number))
316
+ end
317
+
318
+ # Complete a parsed PR view with its exact head SHA evidence.
319
+ def with_head_sha(parsed)
320
+ parsed[:head_sha] = head_sha_of(parsed[:number])
321
+ normalize_pr(parsed)
322
+ end
323
+
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.
328
+ def view_pr(number)
329
+ view = fj(:pr_view, number)
330
+ parsed = Parsers.parse_pr_view(view) ||
331
+ raise(Ace::Git::ProviderMalformedOutputError,
332
+ "Unrecognized `fj pr view #{number}` output; update the Forgejo provider parser")
333
+ verify_returned_number!(parsed, number, "pull request")
334
+ parsed
335
+ end
336
+
337
+ # Re-fetch and verify the PR created by an accepted create request.
338
+ def reconcile_created(head_repository_url:, head_ref:, base_ref:, expected_head:)
339
+ matches = find_open_pull_requests(
340
+ head_repository_url: head_repository_url, head_ref: head_ref,
341
+ base_repository_url: server.url, base_ref: base_ref
342
+ )
343
+ if matches.empty?
344
+ raise Ace::Git::ProviderUnknownOutcomeError,
345
+ "PR create accepted but no open pull request matches " \
346
+ "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}; " \
347
+ "reconcile by exact identity before repeating"
348
+ end
349
+ if matches.size > 1
350
+ raise Ace::Git::ProviderConflictingMatchesError,
351
+ "Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
352
+ "#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
155
353
  end
354
+
355
+ verify_expected_head!(matches.first, expected_head)
356
+ end
357
+
358
+ def require_base_on_server!(base_repository_url)
359
+ return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
360
+
361
+ raise Ace::Git::ProviderUnsupportedCapabilityError,
362
+ "Forgejo provider operates on the resolved server repository (#{server.url}); " \
363
+ "refusing base repository #{base_repository_url}"
364
+ end
365
+
366
+ def identity_text(head_repository_url, head_ref, base_ref)
367
+ "#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}"
368
+ end
369
+
370
+ def receipt(operation, pull_request, idempotency)
371
+ Ace::Git::ProviderMutationReceipt.new(
372
+ server_name: server.name, operation: operation,
373
+ pull_request: pull_request, idempotency: idempotency
374
+ )
156
375
  end
157
376
 
158
377
  def head_sha_of(number)
159
- commits = fj(["--style", "minimal", "pr", "view", number.to_s, "commits"])
378
+ commits = fj(:pr_commits, number)
160
379
  Parsers.parse_head_sha(commits)
161
380
  end
162
381
 
@@ -172,10 +391,33 @@ module Ace
172
391
  author: parsed[:author],
173
392
  url: pr_url(parsed[:number]),
174
393
  draft: nil,
175
- merged_at: nil
394
+ merged_at: nil,
395
+ head_repository_url: head_repository_url_of(parsed),
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.
400
+ merge_commit_sha: nil
176
401
  )
177
402
  end
178
403
 
404
+ # Source repository URL for the PR head: forks are reported by the
405
+ # `From `owner/repo:branch`` segment and live on the same host as the
406
+ # base repository; canonical PRs use the configured repository.
407
+ def head_repository_url_of(parsed)
408
+ repo = parsed[:head_repository]
409
+ return repository_target.url if repo.nil? || repo.empty?
410
+
411
+ "#{server_host_root}/#{repo}"
412
+ end
413
+
414
+ def server_host_root
415
+ @server_host_root ||= begin
416
+ uri = URI.parse(repository_target.url)
417
+ "#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
418
+ end
419
+ end
420
+
179
421
  def normalize_search_entry(entry)
180
422
  Ace::Git::ProviderPullRequest.new(
181
423
  server_name: server.name,
@@ -188,26 +430,60 @@ module Ace
188
430
  author: entry[:author],
189
431
  url: pr_url(entry[:number]),
190
432
  draft: nil,
191
- merged_at: nil
433
+ merged_at: nil,
434
+ head_repository_url: nil,
435
+ base_repository_url: repository_target.url,
436
+ merge_commit_sha: nil
192
437
  )
193
438
  end
194
439
 
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
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}"
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"
201
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"
202
478
  end
203
479
 
204
480
  def pr_url(number)
205
- base = server.url.to_s.chomp("/")
481
+ base = repository_target.url.to_s.chomp("/")
206
482
  "#{base}/pulls/#{number}"
207
483
  end
208
484
 
209
485
  def issue_url(number)
210
- base = server.url.to_s.chomp("/")
486
+ base = repository_target.url.to_s.chomp("/")
211
487
  "#{base}/issues/#{number}"
212
488
  end
213
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.1.1"
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.1.1
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-28 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: