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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +35 -2
- data/lib/ace/git/forgejo/cli_executor.rb +112 -31
- data/lib/ace/git/forgejo/parsers.rb +11 -3
- data/lib/ace/git/forgejo/provider.rb +317 -41
- data/lib/ace/git/forgejo/repository_binding.rb +203 -0
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8a35836d33fbf6fc14e52f4c27ef396244d1f809bc919ceca6ac0202db5e583
|
|
4
|
+
data.tar.gz: 548b2f7734d7f98ef2594e9630ddeb499ae9ead7f8c4297498ebe8726217c4a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
9
|
-
|
|
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.
|
|
12
|
-
#
|
|
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
|
|
37
|
+
# Execute an allowlisted control command with timeout.
|
|
19
38
|
#
|
|
20
|
-
# @param
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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 =
|
|
42
|
-
result
|
|
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
|
-
#
|
|
48
|
-
# `fj
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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!(
|
|
72
|
-
return true if authenticated?(
|
|
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 #{
|
|
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
|
|
82
|
-
def
|
|
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
|
-
#
|
|
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(
|
|
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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
|
39
|
+
# @return [Boolean] true when the selected server's host has a
|
|
40
|
+
# configured `fj` login
|
|
35
41
|
def authenticated?
|
|
36
|
-
CliExecutor.authenticated?(
|
|
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!(
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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 (#{
|
|
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 (#{
|
|
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(
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/ace/git/forgejo.rb
CHANGED
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.
|
|
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-
|
|
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:
|