ace-git-forgejo 0.4.0 → 0.6.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 +21 -14
- data/README.md +52 -10
- data/lib/ace/git/forgejo/http_client.rb +84 -20
- data/lib/ace/git/forgejo/issue_api.rb +171 -0
- data/lib/ace/git/forgejo/parsers.rb +20 -3
- data/lib/ace/git/forgejo/provider.rb +541 -147
- data/lib/ace/git/forgejo/pull_request_api.rb +195 -0
- data/lib/ace/git/forgejo/repository_binding.rb +4 -13
- data/lib/ace/git/forgejo/version.rb +1 -1
- data/lib/ace/git/forgejo.rb +1 -0
- metadata +32 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf55e32720105f61ee776ab2e828cc52eff70422d40169c3ec473faa8918852e
|
|
4
|
+
data.tar.gz: d53c30cfdd65d9efd401846871c8572a48442c68d280a0615e7d9b04c8792b45
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c3d888812cbbec6e223ddfca72514bbee53f3281374825b4147c4f1ebc3fd415528f4fc5c08eafadb6cfed35df6acac4220955f629e15b90dc593d91ceee963c
|
|
7
|
+
data.tar.gz: 9dd8b2e6fed777a0c038b111e448c536c0a960c7a7ff53e6d0d9ca1e200bd083e591cb35bf6d7943ec552e206b006021a7314b9bb615e93a9df1a34e179c1f98
|
data/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-10-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Complete PR delivery lifecycle over the repository-bound Forgejo API v1: create with canonical and same-server fork heads and exact draft state, mark-ready, and merge with the server-enforced `head_commit_id` expected-head precondition (minimum supported server: Forgejo 8.0 — first line enforcing the merge precondition and reporting the API draft field — probed before mutations).
|
|
14
|
+
- Authoritative pull request reads from the API: exact head provenance, draft state, and merge-commit evidence replace the fj view/commits pair for PR identity.
|
|
15
|
+
- Duplicate-create races reconcile to the exact open match; already-merged refusals are reusable only with authoritative merged evidence at the expected source SHA.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- PR reads, lifecycle matching, and updates ride the Faraday HTTP transport (ADR-010) with retries limited to safe GETs and no redirect following; lifecycle mutations are gated on a documented server capability floor and fail closed without it.
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
- The fj-era refusals for fork creation, draft-to-ready, and expected-head merging; the conservative `fj pr merge`/`pr edit` paths are replaced by the API lifecycle.
|
|
22
|
+
|
|
23
|
+
## [0.5.0] - 2026-10-03
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Forgejo issue tracking through the shared provider contract with selected-server authority checks and parsed issue evidence.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Missing tracking labels are created on demand before attachment, forbidden organization-label listings fall back to repository labels, and fj credentials are found in the real macOS bundle directories (forgejo-cli.forgejo-cli, legacy Cyborus.forgejo-cli).
|
|
30
|
+
|
|
10
31
|
## [0.4.0] - 2026-10-02
|
|
11
32
|
|
|
12
33
|
### Added
|
|
13
34
|
- Repository-bound Forgejo PR comment and review retrieval and comment create/update through the shared provider contract.
|
|
14
35
|
- Correlation-based reconciliation after uncertain posts and explicit unsupported thread resolution.
|
|
15
36
|
|
|
16
|
-
|
|
17
37
|
## [0.3.0] - 2026-09-29
|
|
18
38
|
|
|
19
39
|
### Added
|
|
@@ -68,19 +88,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
68
88
|
precondition atomically, so both refuse instead of racing.
|
|
69
89
|
- PR view parser captures the fork repository prefix of the `From` segment.
|
|
70
90
|
|
|
71
|
-
### Added
|
|
72
|
-
- PR lifecycle mutations matching the shared provider contract: exact-match
|
|
73
|
-
lookup via `fj pr search`, idempotent create reconciliation, head-verified
|
|
74
|
-
title/body updates.
|
|
75
|
-
- Fork provenance: the `fj pr view` head-repository segment is parsed and
|
|
76
|
-
returned in normalized PR evidence.
|
|
77
|
-
|
|
78
|
-
### Changed
|
|
79
|
-
- `pr ready` and `pr merge` are classified unsupported capabilities: `fj`
|
|
80
|
-
offers no draft-to-ready command and cannot enforce an expected-head merge
|
|
81
|
-
precondition atomically, so both refuse instead of racing.
|
|
82
|
-
- PR view parser captures the fork repository prefix of the `From` segment.
|
|
83
|
-
|
|
84
91
|
## [0.1.1] - 2026-09-28
|
|
85
92
|
|
|
86
93
|
|
data/README.md
CHANGED
|
@@ -36,16 +36,15 @@ forbidden; there is no silent fallback of any kind.
|
|
|
36
36
|
|
|
37
37
|
## Supported operations (observed on forgejo-cli v0.6.0)
|
|
38
38
|
|
|
39
|
-
PR view/diff/head-commits,
|
|
40
|
-
window — `fj` has no
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`8wr.t.uj0` task folder (`evidence/fj-capabilities.md`).
|
|
39
|
+
Through `fj` (observed on forgejo-cli v0.6.0): PR view/diff/head-commits,
|
|
40
|
+
PR search (all/open, client-side newest-first window — `fj` has no
|
|
41
|
+
`--limit`), issue view, actions tasks, repository view, version and
|
|
42
|
+
`auth list` probes. Through the repository-bound API v1: authoritative PR
|
|
43
|
+
reads, PR create (canonical and same-server fork heads, exact draft
|
|
44
|
+
state), edit, ready transitions, and expected-head-guarded merges — see
|
|
45
|
+
the delivery lifecycle section below. The per-surface capability evidence
|
|
46
|
+
and its provenance live in the `8wr.t.uj0` (`evidence/fj-capabilities.md`)
|
|
47
|
+
and `8x2.t.z78` (`capability-evidence-2026-10-04.md`) task folders.
|
|
49
48
|
|
|
50
49
|
Endpoint fidelity: `-H` carries the selected `scheme://authority` (fj
|
|
51
50
|
otherwise assumes HTTPS), and because fj v0.6.0 silently applies its
|
|
@@ -54,3 +53,46 @@ observed keys-file locations and refuses a repository command when an
|
|
|
54
53
|
alias redirects the selected host — never modifying user fj
|
|
55
54
|
configuration; an absent or unreadable keys file is allowed.
|
|
56
55
|
|
|
56
|
+
## PR delivery lifecycle (Forgejo API v1)
|
|
57
|
+
|
|
58
|
+
The delivery lifecycle — create (canonical and same-server fork sources,
|
|
59
|
+
exact draft state), mark-ready, and merge with a server-enforced
|
|
60
|
+
expected-head precondition — rides the repository-bound Forgejo REST API
|
|
61
|
+
v1 through the same selected host and `fj` token as the review transport.
|
|
62
|
+
The `fj` v0.6 CLI cannot encode a fork head, an explicit draft state, a
|
|
63
|
+
ready transition, or an atomic merge precondition, so those operations do
|
|
64
|
+
not approximate it with check-then-act sequencing.
|
|
65
|
+
|
|
66
|
+
Minimum supported server: **Forgejo 8.0** (the first line that both
|
|
67
|
+
enforces the documented `MergePullRequestOption.head_commit_id` merge
|
|
68
|
+
precondition server-side and reports the API `draft` field; the provider
|
|
69
|
+
probes `/api/v1/version` and refuses lifecycle mutations below the
|
|
70
|
+
floor). Official capability evidence lives in the
|
|
71
|
+
`8x2.t.z78` task folder (`capability-evidence-2026-10-04.md`).
|
|
72
|
+
|
|
73
|
+
Forgejo-specific conventions the provider translates:
|
|
74
|
+
|
|
75
|
+
- **Drafts**: the create/edit API forms have no writable draft field —
|
|
76
|
+
a draft is a WIP-prefixed title (server defaults `WIP:`, `[WIP]`;
|
|
77
|
+
compared case-insensitively). The server reports the computed boolean
|
|
78
|
+
in the API `draft` response field, which this provider treats as the
|
|
79
|
+
only draft truth on reads. Create with
|
|
80
|
+
`draft: true` prefixes the title `WIP: `; `draft: false` refuses a
|
|
81
|
+
WIP-prefixed title instead of silently publishing it as a draft. Ready
|
|
82
|
+
strips one leading prefix and proves the resulting `draft: false` by
|
|
83
|
+
read-back. A draft whose title carries no known prefix refuses before
|
|
84
|
+
the mutation (the server likely uses configured prefixes).
|
|
85
|
+
- **Merge** sends the full expected SHA in `head_commit_id`; Forgejo
|
|
86
|
+
re-resolves the head ref inside the merge and refuses with 409 when it
|
|
87
|
+
moved. The provider classifies that as
|
|
88
|
+
`ProviderExpectedHeadConflictError` and never pre-reads as a substitute
|
|
89
|
+
guard. A 409 that reads back as already merged at the expected source
|
|
90
|
+
SHA, with a merge commit, is reusable authoritative evidence.
|
|
91
|
+
- **Reconciliation**: a transport failure on any mutation raises
|
|
92
|
+
`ProviderUnknownOutcomeError` carrying the exact identity; nothing is
|
|
93
|
+
retried automatically. A duplicate-create 409 reconciles to the exact
|
|
94
|
+
open match; mutation outcomes are proven by authoritative read-backs
|
|
95
|
+
(head unchanged, requested state, merged evidence).
|
|
96
|
+
|
|
97
|
+
Identical lifecycle assertions run against every provider through the
|
|
98
|
+
shared `Ace::TestSupport::PullRequestLifecycleContract` parity suite.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
|
-
require "
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/retry"
|
|
6
7
|
require "openssl"
|
|
7
|
-
require "uri"
|
|
8
8
|
require_relative "repository_binding"
|
|
9
9
|
|
|
10
10
|
module Ace
|
|
@@ -12,6 +12,13 @@ module Ace
|
|
|
12
12
|
module Forgejo
|
|
13
13
|
# Authenticated, repository-bound JSON transport for Forgejo API
|
|
14
14
|
# capabilities whose fj v0.6 output omits stable object IDs.
|
|
15
|
+
#
|
|
16
|
+
# Live traffic uses Faraday (ADR-010): no redirect middleware is
|
|
17
|
+
# installed (a redirect must never carry credentials or a write to
|
|
18
|
+
# another host; the configured endpoint is verified up front by
|
|
19
|
+
# {RepositoryBinding.reject_selected_host_redirect!}), and retries are
|
|
20
|
+
# limited to the safe GET verb — a mutation is sent at most once per
|
|
21
|
+
# call, so a lost response stays an unknown outcome.
|
|
15
22
|
class HttpClient
|
|
16
23
|
def initialize(server:, timeout:, runner: nil)
|
|
17
24
|
@target = RepositoryBinding::Target.resolve(server.url)
|
|
@@ -44,40 +51,97 @@ module Ace
|
|
|
44
51
|
entries
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# Send one request and classify every non-success status with the
|
|
55
|
+
# shared taxonomy.
|
|
56
|
+
#
|
|
57
|
+
# @return [Object, nil] parsed JSON body (nil when empty)
|
|
47
58
|
def request(method, suffix, body: nil)
|
|
59
|
+
status, payload = exchange(method, suffix, body: body)
|
|
60
|
+
classify_status!(status, method, exchange_path(suffix))
|
|
61
|
+
payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Send one request and return the raw HTTP outcome so endpoint-aware
|
|
65
|
+
# callers can classify statuses the generic map cannot distinguish
|
|
66
|
+
# (e.g. a 409 that may mean "already exists" on create or "head out
|
|
67
|
+
# of date" on merge). Transport failures still classify by method:
|
|
68
|
+
# reads are unreachable, mutations are unknown outcomes.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array(Integer, Object, nil)] status, parsed JSON body
|
|
71
|
+
# (nil when empty), and the server-supplied error message for
|
|
72
|
+
# non-success statuses (sanitized, truncated)
|
|
73
|
+
def exchange(method, suffix, body: nil)
|
|
48
74
|
RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
|
|
49
|
-
path =
|
|
75
|
+
path = exchange_path(suffix)
|
|
50
76
|
if @runner
|
|
51
77
|
result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
|
|
52
78
|
timeout: @timeout, env: {})
|
|
53
79
|
status = result[:status] || (result[:success] ? 200 : 503)
|
|
54
80
|
payload = result[:stdout].to_s
|
|
55
81
|
else
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
req["Content-Type"] = "application/json" if body
|
|
62
|
-
req.body = JSON.generate(body) if body
|
|
63
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
64
|
-
open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
|
|
65
|
-
status = response.code.to_i
|
|
82
|
+
response = connection(method).send(method, path) do |req|
|
|
83
|
+
req.headers["Content-Type"] = "application/json" if body
|
|
84
|
+
req.body = JSON.generate(body) if body
|
|
85
|
+
end
|
|
86
|
+
status = response.status
|
|
66
87
|
payload = response.body.to_s
|
|
67
88
|
end
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
JSON.parse(payload)
|
|
72
|
-
rescue JSON::ParserError => e
|
|
73
|
-
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo response: #{e.message}"
|
|
74
|
-
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, IOError, SocketError, OpenSSL::SSL::SSLError, SystemCallError => e
|
|
89
|
+
[status, parse_payload(payload), server_error_message(payload)]
|
|
90
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, IOError, SocketError,
|
|
91
|
+
OpenSSL::SSL::SSLError, SystemCallError => e
|
|
75
92
|
error = (method == :get) ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
|
|
76
93
|
raise error, "Forgejo #{method} #{path} outcome unknown: #{e.class}"
|
|
77
94
|
end
|
|
78
95
|
|
|
79
96
|
private
|
|
80
97
|
|
|
98
|
+
# Suffixes already carrying the API root are used verbatim (the
|
|
99
|
+
# server-level /api/v1/version probe); everything else is
|
|
100
|
+
# repository-scoped to the selected owner/repo.
|
|
101
|
+
def exchange_path(suffix)
|
|
102
|
+
return suffix if suffix.to_s.start_with?("/api/v1/")
|
|
103
|
+
|
|
104
|
+
"/api/v1/repos/#{@target.repo}/#{suffix}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A non-JSON body is an absent payload, not a parse failure: the
|
|
108
|
+
# status carries the outcome, and every consumer validates the shape
|
|
109
|
+
# of what it reads, so malformed evidence still fails closed.
|
|
110
|
+
def parse_payload(payload)
|
|
111
|
+
return nil if payload.nil? || payload.empty?
|
|
112
|
+
|
|
113
|
+
JSON.parse(payload)
|
|
114
|
+
rescue JSON::ParserError
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The forge's own explanation for a failed status, sanitized: only
|
|
119
|
+
# the JSON `message` string survives, truncated, so tokens and raw
|
|
120
|
+
# response bodies never reach an error message.
|
|
121
|
+
def server_error_message(payload)
|
|
122
|
+
parsed = parse_payload(payload)
|
|
123
|
+
return nil unless parsed.is_a?(Hash) && parsed["message"].is_a?(String)
|
|
124
|
+
|
|
125
|
+
message = parsed["message"].strip
|
|
126
|
+
message.empty? ? nil : message[0, 300]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Connection per HTTP method: the retry middleware is installed only
|
|
130
|
+
# for the safe verb so a mutation can never be silently re-sent.
|
|
131
|
+
def connection(method)
|
|
132
|
+
@connections = {} unless @connections.is_a?(Hash)
|
|
133
|
+
@connections[method] ||= Faraday.new(url: @target.host_url) do |faraday|
|
|
134
|
+
faraday.options.timeout = @timeout
|
|
135
|
+
faraday.options.open_timeout = @timeout
|
|
136
|
+
faraday.headers["Accept"] = "application/json"
|
|
137
|
+
faraday.headers["Authorization"] = "token #{access_token!}"
|
|
138
|
+
if method == :get
|
|
139
|
+
faraday.request :retry, max: 2, methods: [:get], retry_if: ->(_env, _result) { false }
|
|
140
|
+
end
|
|
141
|
+
faraday.adapter Faraday.default_adapter
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
81
145
|
def access_token!
|
|
82
146
|
path = RepositoryBinding.default_keys_path
|
|
83
147
|
data = path && JSON.parse(File.read(path))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "repository_binding"
|
|
8
|
+
require "ace/git/atoms/server_url"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Git
|
|
12
|
+
module Forgejo
|
|
13
|
+
# Structured issue operations unavailable through fj's output contract.
|
|
14
|
+
# Uses the same exact selected host and the token from fj's keys file.
|
|
15
|
+
class IssueApi
|
|
16
|
+
def initialize(server:, timeout:, runner: nil)
|
|
17
|
+
# Clone URLs may be SSH-style; the HTTP issue client always talks
|
|
18
|
+
# to the verified HTTPS web endpoint derived from that identity.
|
|
19
|
+
@target = RepositoryBinding::Target.resolve(Ace::Git::Atoms::ServerUrl.web_base(server.url))
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@runner = runner
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def issue(number)
|
|
25
|
+
request(:get, "issues/#{number}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def comments(number)
|
|
29
|
+
paginate("issues/#{number}/comments")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create_comment(number, body)
|
|
33
|
+
request(:post, "issues/#{number}/comments", body: {body: body})
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update_comment(comment_id, body)
|
|
37
|
+
request(:patch, "issues/comments/#{comment_id}", body: {body: body})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete_comment(comment_id)
|
|
41
|
+
request(:delete, "issues/comments/#{comment_id}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_label(number, label_id)
|
|
45
|
+
request(:post, "issues/#{number}/labels", body: {labels: [label_id]})
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remove_label(number, label_id)
|
|
49
|
+
request(:delete, "issues/#{number}/labels/#{label_id}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def repository_labels(wanted: nil)
|
|
53
|
+
labels = paginate("labels")
|
|
54
|
+
# Repository labels satisfy the common case; the organization label
|
|
55
|
+
# listing is an extra request that can fail (403 org scope) and must
|
|
56
|
+
# not block linking when the wanted label is already available.
|
|
57
|
+
return labels if wanted && labels.any? { |entry| entry["name"] == wanted }
|
|
58
|
+
|
|
59
|
+
owner = @target.repo.split("/", 2).first
|
|
60
|
+
begin
|
|
61
|
+
labels + paginate("/api/v1/orgs/#{owner}/labels")
|
|
62
|
+
rescue Ace::Git::ProviderObjectNotFoundError, Ace::Git::ProviderAuthenticationError
|
|
63
|
+
# Organization labels are an optional supplement: 404 means no org
|
|
64
|
+
# scope exists and 401/403 mean the token cannot read it. Both
|
|
65
|
+
# leave the repository labels authoritative; errors from the
|
|
66
|
+
# repository-label request above still propagate.
|
|
67
|
+
labels
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def create_repo_label(name:, color:)
|
|
72
|
+
request(:post, "labels", body: {name: name, color: color})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def set_state(number, state)
|
|
76
|
+
request(:patch, "issues/#{number}", body: {state: state.to_s})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def paginate(suffix)
|
|
82
|
+
page = 1
|
|
83
|
+
entries = []
|
|
84
|
+
seen = []
|
|
85
|
+
loop do
|
|
86
|
+
batch = request(:get, "#{suffix}?page=#{page}&limit=50")
|
|
87
|
+
unless batch.is_a?(Array)
|
|
88
|
+
raise Ace::Git::ProviderMalformedOutputError, "Forgejo #{suffix} did not return a list"
|
|
89
|
+
end
|
|
90
|
+
before = entries.length
|
|
91
|
+
# Stop when a page adds no new items (unique ids): instances
|
|
92
|
+
# may cap pages below the requested limit, so a short page alone
|
|
93
|
+
# does not prove the end of the collection.
|
|
94
|
+
fresh = batch.reject { |item| seen.include?(item["id"]) }
|
|
95
|
+
entries.concat(fresh)
|
|
96
|
+
fresh.each { |item| seen << item["id"] }
|
|
97
|
+
break if fresh.empty?
|
|
98
|
+
|
|
99
|
+
page += 1
|
|
100
|
+
end
|
|
101
|
+
entries
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def request(method, suffix, body: nil)
|
|
105
|
+
RepositoryBinding.reject_selected_host_redirect!(@target.host_url)
|
|
106
|
+
path = suffix.start_with?("/api/v1/") ? suffix : "/api/v1/repos/#{@target.repo}/#{suffix}"
|
|
107
|
+
if @runner
|
|
108
|
+
result = @runner.call(args: ["forgejo-http", method.to_s.upcase, "#{@target.host_url}#{path}", body],
|
|
109
|
+
timeout: @timeout, env: {})
|
|
110
|
+
status = result[:status] || (result[:success] ? 200 : 503)
|
|
111
|
+
payload = result[:stdout].to_s
|
|
112
|
+
else
|
|
113
|
+
uri = URI.parse("#{@target.host_url}#{path}")
|
|
114
|
+
token = access_token!
|
|
115
|
+
klass = {get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch,
|
|
116
|
+
delete: Net::HTTP::Delete}.fetch(method)
|
|
117
|
+
req = klass.new(uri)
|
|
118
|
+
req["Authorization"] = "token #{token}"
|
|
119
|
+
req["Accept"] = "application/json"
|
|
120
|
+
req["Content-Type"] = "application/json" if body
|
|
121
|
+
req.body = JSON.generate(body) if body
|
|
122
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
123
|
+
open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
|
|
124
|
+
status = response.code.to_i
|
|
125
|
+
payload = response.body.to_s
|
|
126
|
+
end
|
|
127
|
+
classify_status!(status, method, path)
|
|
128
|
+
return nil if payload.empty?
|
|
129
|
+
|
|
130
|
+
JSON.parse(payload)
|
|
131
|
+
rescue JSON::ParserError => e
|
|
132
|
+
raise Ace::Git::ProviderMalformedOutputError, "Malformed Forgejo issue response: #{e.message}"
|
|
133
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, IOError, SocketError, SystemCallError,
|
|
134
|
+
OpenSSL::SSL::SSLError => e
|
|
135
|
+
error = method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
|
|
136
|
+
raise error, "Forgejo issue #{method} #{path} outcome unknown: #{e.class}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def access_token!
|
|
140
|
+
path = RepositoryBinding.default_keys_path
|
|
141
|
+
data = path && JSON.parse(File.read(path))
|
|
142
|
+
login = data.is_a?(Hash) && data.fetch("hosts", {})[@target.authority]
|
|
143
|
+
token = login.is_a?(Hash) && login["token"]
|
|
144
|
+
return token if token.is_a?(String) && !token.empty?
|
|
145
|
+
|
|
146
|
+
raise Ace::Git::ProviderAuthenticationError,
|
|
147
|
+
"No fj token for selected host #{@target.host_url}; authenticate with fj first"
|
|
148
|
+
rescue Errno::ENOENT, Errno::EACCES, JSON::ParserError
|
|
149
|
+
raise Ace::Git::ProviderAuthenticationError,
|
|
150
|
+
"Cannot read fj authentication for selected host #{@target.host_url}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def classify_status!(status, method, path)
|
|
154
|
+
return if (200..299).cover?(status.to_i)
|
|
155
|
+
|
|
156
|
+
error = case status.to_i
|
|
157
|
+
when 401, 403 then Ace::Git::ProviderAuthenticationError
|
|
158
|
+
when 404 then Ace::Git::ProviderObjectNotFoundError
|
|
159
|
+
# A conflict on a mutation does not prove ownership was rejected
|
|
160
|
+
# before the write: earlier requests in the same sync may already
|
|
161
|
+
# have committed (e.g. the tracking comment). Uncertainty keeps the
|
|
162
|
+
# local recovery identity instead of deleting the task.
|
|
163
|
+
when 409, 422 then method == :get ? Ace::Git::ProviderIdentityMismatchError : Ace::Git::ProviderUnknownOutcomeError
|
|
164
|
+
else method == :get ? Ace::Git::ProviderUnreachableError : Ace::Git::ProviderUnknownOutcomeError
|
|
165
|
+
end
|
|
166
|
+
raise error, "Forgejo issue #{method} #{path} returned HTTP #{status}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -119,19 +119,31 @@ module Ace
|
|
|
119
119
|
tasks = []
|
|
120
120
|
declared_count = nil
|
|
121
121
|
text.to_s.lines.map { |line| clean(line) }.reject(&:empty?).each do |line|
|
|
122
|
-
# `fj` emits
|
|
122
|
+
# `fj` emits exactly one leading "<n> tasks" count header
|
|
123
|
+
# before task lines; require it (including 0 tasks).
|
|
123
124
|
if (header = line.match(/\A(\d+) tasks?\z/i))
|
|
125
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
126
|
+
"Duplicate `fj actions tasks` count header" if declared_count
|
|
124
127
|
declared_count = header[1].to_i
|
|
125
128
|
next
|
|
126
129
|
end
|
|
130
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
131
|
+
"Missing `fj actions tasks` count header" unless declared_count
|
|
127
132
|
|
|
128
133
|
match = line.match(
|
|
129
134
|
/\A#(?<task>\d+)\s+\((?<sha>[0-9a-f]+)\)\s+(?<state>\w+)\s+(?<name>.+?)\s+[\dhms.]+\s+\((?<event>[^)]+)\)/
|
|
130
135
|
)
|
|
131
136
|
# Any other nonempty line that cannot be parsed must not silently
|
|
132
|
-
# shrink check evidence; fail closed instead.
|
|
137
|
+
# shrink check evidence; fail closed instead. The SHA may be
|
|
138
|
+
# abbreviated; the provider binds it to the reviewed head by
|
|
139
|
+
# prefix, so anything shorter than Git's conventional minimum
|
|
140
|
+
# abbreviation cannot establish commit identity and is rejected.
|
|
133
141
|
raise Ace::Git::ProviderMalformedOutputError,
|
|
134
142
|
"Unrecognized `fj actions tasks` output line: #{line[0, 80]}" unless match
|
|
143
|
+
unless match[:sha].length.between?(7, 40)
|
|
144
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
145
|
+
"Unbindable `fj actions tasks` SHA (need 7-40 hex chars): #{match[:sha]}"
|
|
146
|
+
end
|
|
135
147
|
|
|
136
148
|
tasks << {
|
|
137
149
|
name: match[:name],
|
|
@@ -139,10 +151,15 @@ module Ace
|
|
|
139
151
|
sha: match[:sha]
|
|
140
152
|
}
|
|
141
153
|
end
|
|
154
|
+
# Empty or whitespace-only output never reaches the in-loop header
|
|
155
|
+
# check; the contract requires the count header even for zero tasks,
|
|
156
|
+
# so fail closed instead of returning an empty task list.
|
|
157
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
158
|
+
"Missing `fj actions tasks` count header" unless declared_count
|
|
142
159
|
# A declared count that disagrees with the parsed rows means
|
|
143
160
|
# truncated output; fail closed instead of accepting partial
|
|
144
161
|
# check evidence.
|
|
145
|
-
if declared_count
|
|
162
|
+
if declared_count != tasks.length
|
|
146
163
|
raise Ace::Git::ProviderMalformedOutputError,
|
|
147
164
|
"Incomplete `fj actions tasks` output: declared #{declared_count}, parsed #{tasks.length}"
|
|
148
165
|
end
|