ace-git 0.23.1 → 0.24.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/.ace-defaults/git/config.yml +16 -1
- data/CHANGELOG.md +17 -0
- data/docs/getting-started.md +4 -4
- data/docs/usage.md +13 -44
- data/lib/ace/git/atoms/command_executor.rb +1 -1
- data/lib/ace/git/atoms/pr_identifier.rb +66 -0
- data/lib/ace/git/atoms/server_url.rb +56 -0
- data/lib/ace/git/atoms/status_formatter.rb +22 -27
- data/lib/ace/git/cli/commands/status.rb +16 -6
- data/lib/ace/git/cli.rb +1 -4
- data/lib/ace/git/errors.rb +56 -0
- data/lib/ace/git/models/diff_config.rb +2 -2
- data/lib/ace/git/models/repo_status.rb +1 -1
- data/lib/ace/git/molecules/branch_reader.rb +13 -7
- data/lib/ace/git/molecules/config_loader.rb +1 -1
- data/lib/ace/git/organisms/repo_status_loader.rb +72 -136
- data/lib/ace/git/providers/base.rb +115 -0
- data/lib/ace/git/providers/evidence.rb +26 -0
- data/lib/ace/git/providers.rb +92 -0
- data/lib/ace/git/resolved_server.rb +20 -0
- data/lib/ace/git/server_registry.rb +156 -0
- data/lib/ace/git/version.rb +1 -1
- data/lib/ace/git.rb +13 -14
- metadata +17 -14
- data/lib/ace/git/atoms/pr_identifier_parser.rb +0 -88
- data/lib/ace/git/cli/commands/pr.rb +0 -119
- data/lib/ace/git/molecules/gh_cli_executor.rb +0 -77
- data/lib/ace/git/molecules/github_issue_sync.rb +0 -248
- data/lib/ace/git/molecules/pr_metadata_fetcher.rb +0 -288
|
@@ -4,12 +4,17 @@ module Ace
|
|
|
4
4
|
module Git
|
|
5
5
|
module Organisms
|
|
6
6
|
# Orchestrates loading complete repository status
|
|
7
|
-
# Combines branch info, task pattern detection, and PR
|
|
7
|
+
# Combines branch info, task pattern detection, and PR evidence
|
|
8
|
+
#
|
|
9
|
+
# PR enrichment is provider-optional: it resolves the default forge
|
|
10
|
+
# server and its registered provider via the core contract. With no
|
|
11
|
+
# server configured, no provider registered, or a classified provider
|
|
12
|
+
# failure, status stays purely local and the PR sections are skipped.
|
|
8
13
|
class RepoStatusLoader
|
|
9
14
|
class << self
|
|
10
15
|
# Load complete repository status
|
|
11
16
|
# @param options [Hash] Options for status loading
|
|
12
|
-
# @option options [Boolean] :include_pr Whether to fetch PR
|
|
17
|
+
# @option options [Boolean] :include_pr Whether to fetch PR evidence (default: true)
|
|
13
18
|
# @option options [Boolean] :include_pr_activity Whether to fetch PR activity (default: true)
|
|
14
19
|
# @option options [Boolean] :include_commits Whether to fetch recent commits (default: true)
|
|
15
20
|
# @option options [Integer] :commits_limit Number of recent commits to fetch (default: 3)
|
|
@@ -53,28 +58,18 @@ module Ace
|
|
|
53
58
|
recent_commits = fetch_recent_commits(limit: commits_limit)
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
# Fetch PR
|
|
61
|
+
# Fetch PR evidence (metadata and activity) via the provider contract
|
|
57
62
|
pr_metadata = nil
|
|
58
63
|
pr_activity = nil
|
|
59
64
|
if !branch_info[:detached]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
elsif include_pr
|
|
69
|
-
# Only PR metadata requested
|
|
70
|
-
pr_metadata = fetch_pr_metadata(timeout: timeout)
|
|
71
|
-
elsif include_pr_activity
|
|
72
|
-
# Only activity requested
|
|
73
|
-
pr_activity = fetch_pr_activity(
|
|
74
|
-
current_branch: branch_info[:name],
|
|
75
|
-
timeout: timeout
|
|
76
|
-
)
|
|
77
|
-
end
|
|
65
|
+
pr_sections = fetch_pr_sections(
|
|
66
|
+
current_branch: branch_info[:name],
|
|
67
|
+
include_metadata: include_pr,
|
|
68
|
+
include_activity: include_pr_activity,
|
|
69
|
+
timeout: timeout
|
|
70
|
+
)
|
|
71
|
+
pr_metadata = pr_sections[:pr_metadata]
|
|
72
|
+
pr_activity = pr_sections[:pr_activity]
|
|
78
73
|
end
|
|
79
74
|
|
|
80
75
|
# Build and return status
|
|
@@ -100,13 +95,8 @@ module Ace
|
|
|
100
95
|
# Get basic status (skip PR activity since we're fetching a specific PR)
|
|
101
96
|
status = load(include_pr: false, include_pr_activity: false)
|
|
102
97
|
|
|
103
|
-
# Fetch specific PR
|
|
104
|
-
|
|
105
|
-
result = Molecules::PrMetadataFetcher.fetch_metadata(pr_identifier, timeout: timeout)
|
|
106
|
-
pr_metadata = result[:success] ? result[:metadata] : nil
|
|
107
|
-
rescue Ace::Git::Error
|
|
108
|
-
pr_metadata = nil
|
|
109
|
-
end
|
|
98
|
+
# Fetch specific PR evidence
|
|
99
|
+
pr_metadata = resolve_pr_metadata(pr_identifier, timeout: timeout)
|
|
110
100
|
|
|
111
101
|
# Return status with PR data
|
|
112
102
|
Models::RepoStatus.from_data(
|
|
@@ -131,6 +121,60 @@ module Ace
|
|
|
131
121
|
|
|
132
122
|
private
|
|
133
123
|
|
|
124
|
+
# Resolve a specific PR via the provider contract
|
|
125
|
+
# @return [ProviderPullRequest, nil] PR evidence or nil
|
|
126
|
+
def resolve_pr_metadata(pr_identifier, timeout:)
|
|
127
|
+
provider = active_provider(timeout: timeout)
|
|
128
|
+
return nil unless provider
|
|
129
|
+
|
|
130
|
+
provider.pull_request(number: pr_identifier)
|
|
131
|
+
rescue Ace::Git::Error
|
|
132
|
+
# Classified provider failure: keep status local, skip PR evidence
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fetch PR evidence sections via the provider contract
|
|
137
|
+
# @return [Hash] {:pr_metadata => ProviderPullRequest, :pr_activity => Hash}
|
|
138
|
+
def fetch_pr_sections(current_branch:, include_metadata:, include_activity:, timeout:)
|
|
139
|
+
provider = active_provider(timeout: timeout)
|
|
140
|
+
return {pr_metadata: nil, pr_activity: nil} unless provider
|
|
141
|
+
|
|
142
|
+
metadata = include_metadata ? provider.pull_request_for_branch(branch: current_branch) : nil
|
|
143
|
+
activity = include_activity ? fetch_activity(provider, current_branch: current_branch) : nil
|
|
144
|
+
|
|
145
|
+
{pr_metadata: metadata, pr_activity: activity}
|
|
146
|
+
rescue Ace::Git::Error
|
|
147
|
+
# Classified provider failure: keep status local, skip PR evidence
|
|
148
|
+
{pr_metadata: nil, pr_activity: nil}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Build merged/open activity lists from recent PR evidence
|
|
152
|
+
def fetch_activity(provider, current_branch:)
|
|
153
|
+
prs = provider.recent_pull_requests(limit: 30)
|
|
154
|
+
merged = prs
|
|
155
|
+
.select { |pr| pr.state == :merged }
|
|
156
|
+
.first(Ace::Git.merged_prs_limit)
|
|
157
|
+
open_prs = prs
|
|
158
|
+
.select { |pr| pr.state == :open && pr.head_ref != current_branch }
|
|
159
|
+
.first(Ace::Git.open_prs_limit)
|
|
160
|
+
|
|
161
|
+
return nil if merged.empty? && open_prs.empty?
|
|
162
|
+
|
|
163
|
+
{merged: merged, open: open_prs}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Resolve the default server's registered provider, or nil.
|
|
167
|
+
# Resolution failures are classified; enrichment is optional, so any
|
|
168
|
+
# of them simply skips the PR sections.
|
|
169
|
+
def active_provider(timeout:)
|
|
170
|
+
server = ServerRegistry.resolve_default
|
|
171
|
+
return nil unless server
|
|
172
|
+
|
|
173
|
+
Providers.for(server, timeout: timeout)
|
|
174
|
+
rescue Ace::Git::Error
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
134
178
|
# Fetch git status in short branch format
|
|
135
179
|
# @return [String, nil] Git status output or nil
|
|
136
180
|
def fetch_git_status
|
|
@@ -149,114 +193,6 @@ module Ace
|
|
|
149
193
|
rescue
|
|
150
194
|
nil
|
|
151
195
|
end
|
|
152
|
-
|
|
153
|
-
# Fetch PR metadata for current branch
|
|
154
|
-
# @param timeout [Integer] Timeout in seconds
|
|
155
|
-
# @return [Hash, nil] PR metadata or nil
|
|
156
|
-
def fetch_pr_metadata(timeout:)
|
|
157
|
-
# First try to find PR for current branch
|
|
158
|
-
pr_number = Molecules::PrMetadataFetcher.find_pr_for_branch(timeout: timeout)
|
|
159
|
-
return nil unless pr_number
|
|
160
|
-
|
|
161
|
-
# Then fetch full metadata
|
|
162
|
-
result = Molecules::PrMetadataFetcher.fetch_metadata(pr_number, timeout: timeout)
|
|
163
|
-
result[:success] ? result[:metadata] : nil
|
|
164
|
-
rescue Ace::Git::GhNotInstalledError, Ace::Git::GhAuthenticationError
|
|
165
|
-
# gh not available, skip PR metadata
|
|
166
|
-
nil
|
|
167
|
-
rescue Ace::Git::PrNotFoundError
|
|
168
|
-
# No PR for this branch
|
|
169
|
-
nil
|
|
170
|
-
rescue Ace::Git::TimeoutError
|
|
171
|
-
# Timeout, skip PR metadata
|
|
172
|
-
nil
|
|
173
|
-
rescue
|
|
174
|
-
# Any other error, skip PR metadata
|
|
175
|
-
nil
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
# Fetch PR activity (recently merged and open PRs)
|
|
179
|
-
# @param current_branch [String] Current branch name to exclude from open PRs
|
|
180
|
-
# @param timeout [Integer] Timeout in seconds
|
|
181
|
-
# @return [Hash, nil] PR activity with :merged and :open arrays (symbol keys), or nil
|
|
182
|
-
# Each PR in the arrays has string keys from JSON parsing: "number", "title", etc.
|
|
183
|
-
def fetch_pr_activity(current_branch:, timeout:)
|
|
184
|
-
merged_result = Molecules::PrMetadataFetcher.fetch_recently_merged(
|
|
185
|
-
limit: Ace::Git.merged_prs_limit,
|
|
186
|
-
timeout: timeout
|
|
187
|
-
)
|
|
188
|
-
open_result = Molecules::PrMetadataFetcher.fetch_open_prs(
|
|
189
|
-
exclude_branch: current_branch,
|
|
190
|
-
timeout: timeout
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
# Return nil if both failed
|
|
194
|
-
return nil unless merged_result[:success] || open_result[:success]
|
|
195
|
-
|
|
196
|
-
# Use symbol keys for outer hash, string keys for PR data (from JSON)
|
|
197
|
-
# This is documented behavior - consumers should access via pr_activity[:merged]
|
|
198
|
-
# and individual PRs via pr["number"], pr["title"], etc.
|
|
199
|
-
{
|
|
200
|
-
merged: merged_result[:success] ? merged_result[:prs] : [],
|
|
201
|
-
open: open_result[:success] ? open_result[:prs] : []
|
|
202
|
-
}
|
|
203
|
-
rescue
|
|
204
|
-
# Any error, skip PR activity
|
|
205
|
-
nil
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Fetch PR metadata and activity in a single API call for optimal performance
|
|
209
|
-
# Uses gh pr list --state all to get all PRs, then filters locally
|
|
210
|
-
# @param current_branch [String] Current branch name to match/exclude
|
|
211
|
-
# @param timeout [Integer] Timeout in seconds
|
|
212
|
-
# @return [Hash] Result with :pr_metadata and :pr_activity keys
|
|
213
|
-
def fetch_pr_data_parallel(current_branch:, timeout:)
|
|
214
|
-
# Single API call gets all recent PRs
|
|
215
|
-
result = Molecules::PrMetadataFetcher.fetch_all_prs(
|
|
216
|
-
limit: 15, # Enough for current + merged + open
|
|
217
|
-
timeout: timeout
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
return {pr_metadata: nil, pr_activity: nil} unless result[:success]
|
|
221
|
-
|
|
222
|
-
prs = result[:prs]
|
|
223
|
-
|
|
224
|
-
# Find current PR (matching branch, prefer OPEN > MERGED > CLOSED)
|
|
225
|
-
branch_prs = prs.select { |pr| pr["headRefName"] == current_branch }
|
|
226
|
-
current_pr = branch_prs.min_by { |pr|
|
|
227
|
-
case pr["state"]
|
|
228
|
-
when "OPEN" then 0
|
|
229
|
-
when "MERGED" then 1
|
|
230
|
-
when "CLOSED" then 2
|
|
231
|
-
else 3
|
|
232
|
-
end
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
# Get merged PRs (sorted by mergedAt descending, limited)
|
|
236
|
-
merged_prs = prs
|
|
237
|
-
.select { |pr| pr["state"] == "MERGED" }
|
|
238
|
-
.sort_by { |pr| pr["mergedAt"] || "" }
|
|
239
|
-
.reverse
|
|
240
|
-
.take(Ace::Git.merged_prs_limit)
|
|
241
|
-
|
|
242
|
-
# Get open PRs (exclude current branch, limited)
|
|
243
|
-
open_prs = prs
|
|
244
|
-
.select { |pr| pr["state"] == "OPEN" && pr["headRefName"] != current_branch }
|
|
245
|
-
.take(Ace::Git.open_prs_limit)
|
|
246
|
-
|
|
247
|
-
# Build activity hash (nil if no activity to show)
|
|
248
|
-
pr_activity = nil
|
|
249
|
-
if merged_prs.any? || open_prs.any?
|
|
250
|
-
pr_activity = {merged: merged_prs, open: open_prs}
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
{
|
|
254
|
-
pr_metadata: current_pr,
|
|
255
|
-
pr_activity: pr_activity
|
|
256
|
-
}
|
|
257
|
-
rescue
|
|
258
|
-
{pr_metadata: nil, pr_activity: nil}
|
|
259
|
-
end
|
|
260
196
|
end
|
|
261
197
|
end
|
|
262
198
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
module Providers
|
|
6
|
+
# Abstract provider contract implemented by provider packages.
|
|
7
|
+
#
|
|
8
|
+
# The core defines the interface and the normalized evidence types;
|
|
9
|
+
# implementations own all provider CLI invocation, output parsing, and
|
|
10
|
+
# authentication verification. The core itself never executes a provider
|
|
11
|
+
# CLI and never parses provider output.
|
|
12
|
+
#
|
|
13
|
+
# Subclasses receive the resolved server identity and may accept two
|
|
14
|
+
# optional constructor keywords (see {Providers.for}):
|
|
15
|
+
# - timeout: operation timeout in seconds
|
|
16
|
+
# - runner: injectable command runner for tests; a callable receiving
|
|
17
|
+
# keyword arguments (args:, timeout:, env:) and returning a Hash with
|
|
18
|
+
# :stdout, :stderr, :exit_code
|
|
19
|
+
class Base
|
|
20
|
+
attr_reader :server, :timeout
|
|
21
|
+
|
|
22
|
+
# @param server [ResolvedServer] resolved server identity
|
|
23
|
+
# @param timeout [Integer, nil] operation timeout (default: config)
|
|
24
|
+
# @param runner [Proc, nil] injectable command runner for tests
|
|
25
|
+
def initialize(server:, timeout: nil, runner: nil)
|
|
26
|
+
@server = server
|
|
27
|
+
@timeout = timeout || Ace::Git.network_timeout
|
|
28
|
+
@runner = runner
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true when the provider CLI binary is installed
|
|
32
|
+
def available?
|
|
33
|
+
raise NotImplementedError, "Providers must implement #{self.class}#available?"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @raise [ProviderCliMissingError] when the provider CLI is missing
|
|
37
|
+
def check_available!
|
|
38
|
+
raise NotImplementedError, "Providers must implement #{self.class}#check_available!"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Boolean] true when the provider CLI is authenticated
|
|
42
|
+
def authenticated?
|
|
43
|
+
raise NotImplementedError, "Providers must implement #{self.class}#authenticated?"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @raise [ProviderAuthenticationError] when unauthenticated
|
|
47
|
+
def check_authenticated!
|
|
48
|
+
raise NotImplementedError, "Providers must implement #{self.class}#check_authenticated!"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fetch one pull request.
|
|
52
|
+
#
|
|
53
|
+
# @param number [Integer, String] pull request number
|
|
54
|
+
# @return [ProviderPullRequest] normalized pull request evidence
|
|
55
|
+
def pull_request(number:)
|
|
56
|
+
raise NotImplementedError, "Providers must implement #{self.class}#pull_request"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Find the pull request associated with a branch.
|
|
60
|
+
#
|
|
61
|
+
# @param branch [String] branch name
|
|
62
|
+
# @return [ProviderPullRequest, nil] evidence, or nil when no pull
|
|
63
|
+
# request is associated with the branch
|
|
64
|
+
def pull_request_for_branch(branch:)
|
|
65
|
+
raise NotImplementedError, "Providers must implement #{self.class}#pull_request_for_branch"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Fetch the full diff of one pull request.
|
|
69
|
+
#
|
|
70
|
+
# @param number [Integer, String] pull request number
|
|
71
|
+
# @return [String] unified diff text
|
|
72
|
+
def pull_request_diff(number:)
|
|
73
|
+
raise NotImplementedError, "Providers must implement #{self.class}#pull_request_diff"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# List recent pull requests across states (newest first).
|
|
77
|
+
#
|
|
78
|
+
# @param limit [Integer] maximum number of pull requests
|
|
79
|
+
# @return [Array<ProviderPullRequest>] normalized pull requests
|
|
80
|
+
def recent_pull_requests(limit:)
|
|
81
|
+
raise NotImplementedError, "Providers must implement #{self.class}#recent_pull_requests"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Fetch one issue.
|
|
85
|
+
#
|
|
86
|
+
# @param number [Integer, String] issue number
|
|
87
|
+
# @return [ProviderIssue] normalized issue evidence
|
|
88
|
+
def issue(number:)
|
|
89
|
+
raise NotImplementedError, "Providers must implement #{self.class}#issue"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Fetch check/CI status evidence for a reference.
|
|
93
|
+
#
|
|
94
|
+
# @param ref [String] commit sha or branch name
|
|
95
|
+
# @return [Array<ProviderCheck>] normalized check evidence
|
|
96
|
+
def checks(ref:)
|
|
97
|
+
raise NotImplementedError, "Providers must implement #{self.class}#checks"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Fetch repository metadata for the resolved server.
|
|
101
|
+
#
|
|
102
|
+
# @return [ProviderRepository] normalized repository evidence
|
|
103
|
+
def repository
|
|
104
|
+
raise NotImplementedError, "Providers must implement #{self.class}#repository"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Command runner: injected fake or nil (implementations use their own
|
|
110
|
+
# executor when nil).
|
|
111
|
+
attr_reader :runner
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
# Normalized evidence types produced by every provider implementation.
|
|
6
|
+
#
|
|
7
|
+
# Provider packages translate their CLI responses into exactly these
|
|
8
|
+
# shapes, so consumers can stay forge-neutral. All fields except the
|
|
9
|
+
# identifying ones may be nil when a provider cannot supply them.
|
|
10
|
+
#
|
|
11
|
+
# Pull request state values: :open, :merged, :closed. Issue state values:
|
|
12
|
+
# :open, :closed.
|
|
13
|
+
ProviderPullRequest = Data.define(
|
|
14
|
+
:server_name, :number, :title, :state, :head_ref, :base_ref,
|
|
15
|
+
:head_sha, :author, :url, :draft, :merged_at
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
ProviderIssue = Data.define(:server_name, :number, :title, :state, :author, :url, :labels)
|
|
19
|
+
|
|
20
|
+
# Normalized check/CI evidence for one check run.
|
|
21
|
+
ProviderCheck = Data.define(:server_name, :name, :state, :conclusion, :url)
|
|
22
|
+
|
|
23
|
+
# Normalized repository evidence.
|
|
24
|
+
ProviderRepository = Data.define(:server_name, :full_name, :default_branch, :url)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "providers/base"
|
|
5
|
+
require_relative "providers/evidence"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Git
|
|
9
|
+
# Registry of provider implementations owned by provider packages.
|
|
10
|
+
#
|
|
11
|
+
# The core never executes provider CLIs. Provider packages register their
|
|
12
|
+
# contract implementation here when loaded; downstream consumers then
|
|
13
|
+
# obtain a provider via {Providers.for}.
|
|
14
|
+
#
|
|
15
|
+
# @example Registering (inside a provider package)
|
|
16
|
+
# Ace::Git::Providers.register(:acme, Ace::Git::Acme::Provider)
|
|
17
|
+
#
|
|
18
|
+
# @example Resolving (in consumers)
|
|
19
|
+
# server = Ace::Git::ServerRegistry.resolve_default
|
|
20
|
+
# provider = Ace::Git::Providers.for(server)
|
|
21
|
+
# pr = provider.pull_request(number: 25)
|
|
22
|
+
module Providers
|
|
23
|
+
@registry = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Register a provider implementation for a provider type.
|
|
28
|
+
#
|
|
29
|
+
# @param type [Symbol, String] provider type (e.g. :acme)
|
|
30
|
+
# @param provider_class [Class] class implementing Providers::Base;
|
|
31
|
+
# must respond to .new
|
|
32
|
+
# @raise [ArgumentError] when type or provider_class is invalid
|
|
33
|
+
def register(type, provider_class)
|
|
34
|
+
key = normalize_type(type)
|
|
35
|
+
unless provider_class.respond_to?(:new)
|
|
36
|
+
raise ArgumentError, "Provider for #{key.inspect} must be a class (got #{provider_class.inspect})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@mutex.synchronize { @registry[key] = provider_class }
|
|
40
|
+
provider_class
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build the provider implementation for a resolved server.
|
|
44
|
+
#
|
|
45
|
+
# @param server [ResolvedServer] exactly-resolved server identity
|
|
46
|
+
# @param timeout [Integer, nil] provider operation timeout in seconds
|
|
47
|
+
# @param runner [Proc, nil] optional command runner injection for tests;
|
|
48
|
+
# see Providers::Base
|
|
49
|
+
# @return [Providers::Base] provider instance bound to the server
|
|
50
|
+
# @raise [UnknownProviderError] when no provider is registered for the
|
|
51
|
+
# server's provider type
|
|
52
|
+
def for(server, timeout: nil, runner: nil)
|
|
53
|
+
key = normalize_type(server.provider)
|
|
54
|
+
provider_class = @mutex.synchronize { @registry[key] }
|
|
55
|
+
unless provider_class
|
|
56
|
+
raise UnknownProviderError,
|
|
57
|
+
"No provider registered for type #{key.inspect} (server '#{server.name}'); " \
|
|
58
|
+
"install and require the provider package that owns #{key.inspect}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
provider_class.new(server: server, timeout: timeout, runner: runner)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param type [Symbol, String] provider type
|
|
65
|
+
# @return [Boolean] true when a provider is registered for the type
|
|
66
|
+
def registered?(type)
|
|
67
|
+
key = normalize_type(type)
|
|
68
|
+
@mutex.synchronize { @registry.key?(key) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Array<Symbol>] registered provider types, sorted
|
|
72
|
+
def types
|
|
73
|
+
@mutex.synchronize { @registry.keys.sort }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Clear all registrations (test seam).
|
|
77
|
+
def reset!
|
|
78
|
+
@mutex.synchronize { @registry.clear }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def normalize_type(type)
|
|
84
|
+
normalized = type.to_s.strip.downcase
|
|
85
|
+
raise ArgumentError, "Provider type must be a non-empty symbol or string" if normalized.empty?
|
|
86
|
+
|
|
87
|
+
normalized.to_sym
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Git
|
|
5
|
+
# An exactly-resolved forge server identity.
|
|
6
|
+
#
|
|
7
|
+
# Produced by ServerRegistry (by explicit name, by default resolution, or by
|
|
8
|
+
# remote URL matching). Carries the configured server name, the provider
|
|
9
|
+
# type that owns its behavior, and the configured base URL.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# ResolvedServer.new(name: "forge-lab", provider: :acme, url: "https://forge.example.com")
|
|
13
|
+
ResolvedServer = Data.define(:name, :provider, :url) do
|
|
14
|
+
# @return [Hash] Plain hash representation
|
|
15
|
+
def to_h
|
|
16
|
+
{name: name, provider: provider, url: url}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "atoms/command_executor"
|
|
4
|
+
require_relative "atoms/server_url"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "resolved_server"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Git
|
|
10
|
+
# Resolves forge server identity from configuration, deterministically.
|
|
11
|
+
#
|
|
12
|
+
# Configuration lives under the `git.servers` key (see
|
|
13
|
+
# `.ace-defaults/git/config.yml`). Each entry is a uniquely named server:
|
|
14
|
+
#
|
|
15
|
+
# ```yaml
|
|
16
|
+
# servers:
|
|
17
|
+
# - name: forge-lab
|
|
18
|
+
# provider: acme
|
|
19
|
+
# url: https://forge.example.com/owner/repo
|
|
20
|
+
# default: true
|
|
21
|
+
# - name: forge-mirror
|
|
22
|
+
# provider: other
|
|
23
|
+
# url: https://other.example.com/owner/repo
|
|
24
|
+
# ```
|
|
25
|
+
#
|
|
26
|
+
# At most one server may be marked `default: true`. Resolution never falls
|
|
27
|
+
# back silently: every failure raises a classified error from the failure
|
|
28
|
+
# taxonomy.
|
|
29
|
+
class ServerRegistry
|
|
30
|
+
# Internal configured entry: resolved identity plus its default flag.
|
|
31
|
+
Entry = Data.define(:server, :default)
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# All configured servers, in configuration order.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<ResolvedServer>]
|
|
37
|
+
# @raise [DuplicateServerNameError] when two servers share a name
|
|
38
|
+
# @raise [ConfigError] when an entry is malformed
|
|
39
|
+
def servers
|
|
40
|
+
entries.map(&:server)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resolve a server by its explicit, configured name.
|
|
44
|
+
#
|
|
45
|
+
# @param name [String, Symbol] configured server name
|
|
46
|
+
# @return [ResolvedServer]
|
|
47
|
+
# @raise [UnknownServerNameError] when no server has that name
|
|
48
|
+
def resolve(name)
|
|
49
|
+
wanted = name.to_s
|
|
50
|
+
entries.map(&:server).find { |server| server.name == wanted } ||
|
|
51
|
+
raise(UnknownServerNameError, "No server named '#{wanted}' is configured " \
|
|
52
|
+
"(configured: #{servers.map(&:name).join(", ")}); add it to git.servers")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resolve the explicitly configured default server.
|
|
56
|
+
#
|
|
57
|
+
# @return [ResolvedServer]
|
|
58
|
+
# @raise [NoDefaultServerConfiguredError] when zero servers are default
|
|
59
|
+
# @raise [MultipleDefaultServersError] when more than one is default
|
|
60
|
+
def resolve_default
|
|
61
|
+
defaults = entries.select(&:default)
|
|
62
|
+
if defaults.empty?
|
|
63
|
+
raise NoDefaultServerConfiguredError,
|
|
64
|
+
"No default server configured; mark exactly one entry in git.servers with default: true"
|
|
65
|
+
end
|
|
66
|
+
if defaults.size > 1
|
|
67
|
+
raise MultipleDefaultServersError,
|
|
68
|
+
"Multiple servers marked as default: #{defaults.map { |e| e.server.name }.join(", ")}; " \
|
|
69
|
+
"keep exactly one"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
defaults.first.server
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Resolve server identity from a git remote URL, matching configured
|
|
76
|
+
# servers deterministically (no hostname assumptions).
|
|
77
|
+
#
|
|
78
|
+
# @param remote_name [String, nil] git remote name (default: configured
|
|
79
|
+
# `remote`, then "origin")
|
|
80
|
+
# @return [ResolvedServer] the single matching configured server
|
|
81
|
+
# @raise [AmbiguousRemoteError] when the remote matches no configured
|
|
82
|
+
# server, or more than one
|
|
83
|
+
def resolve_remote(remote_name = nil)
|
|
84
|
+
remote_name ||= Ace::Git.config["remote"] || "origin"
|
|
85
|
+
remote_url = read_remote_url(remote_name)
|
|
86
|
+
matches = entries.map(&:server).select { |server| Atoms::ServerUrl.match?(server.url, remote_url) }
|
|
87
|
+
|
|
88
|
+
if matches.empty?
|
|
89
|
+
raise AmbiguousRemoteError,
|
|
90
|
+
"Remote '#{remote_name}' (#{remote_url || "missing"}) matches no configured server " \
|
|
91
|
+
"(configured: #{servers.map(&:name).join(", ")})"
|
|
92
|
+
end
|
|
93
|
+
if matches.size > 1
|
|
94
|
+
raise AmbiguousRemoteError,
|
|
95
|
+
"Remote '#{remote_name}' (#{remote_url}) matches multiple configured servers: " \
|
|
96
|
+
"#{matches.map(&:name).join(", ")}; make server URLs distinct"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
matches.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Validated configured entries, in configuration order.
|
|
105
|
+
def entries
|
|
106
|
+
raw = Ace::Git.config["servers"]
|
|
107
|
+
return [] if raw.nil?
|
|
108
|
+
|
|
109
|
+
raise ConfigError, "servers config must be a list" unless raw.is_a?(Array)
|
|
110
|
+
|
|
111
|
+
list = raw.map { |entry| build_entry(entry) }
|
|
112
|
+
assert_unique_names!(list)
|
|
113
|
+
list
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Read a remote URL with local git only (no network, no forge CLI).
|
|
117
|
+
def read_remote_url(remote_name)
|
|
118
|
+
result = Atoms::CommandExecutor.execute("git", "remote", "get-url", remote_name.to_s)
|
|
119
|
+
result[:success] ? result[:output].to_s.strip : nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build one internal entry from a config entry, validating structure.
|
|
123
|
+
def build_entry(entry)
|
|
124
|
+
unless entry.is_a?(Hash)
|
|
125
|
+
raise ConfigError, "Each git.servers entry must be a mapping with name, provider, url"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
normalized = entry.transform_keys(&:to_s)
|
|
129
|
+
%w[name provider url].each do |key|
|
|
130
|
+
next unless normalized[key].nil? || normalized[key].to_s.empty?
|
|
131
|
+
|
|
132
|
+
raise ConfigError, "git.servers entry is missing '#{key}': #{entry.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Entry.new(
|
|
136
|
+
server: ResolvedServer.new(
|
|
137
|
+
name: normalized["name"].to_s,
|
|
138
|
+
provider: normalized["provider"].to_s.to_sym,
|
|
139
|
+
url: normalized["url"].to_s
|
|
140
|
+
),
|
|
141
|
+
default: normalized["default"] == true || normalized["default"].to_s == "true"
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def assert_unique_names!(list)
|
|
146
|
+
names = list.map { |entry| entry.server.name }
|
|
147
|
+
duplicates = names.tally.select { |_name, count| count > 1 }.keys
|
|
148
|
+
return if duplicates.empty?
|
|
149
|
+
|
|
150
|
+
raise DuplicateServerNameError,
|
|
151
|
+
"Duplicate server names in git.servers: #{duplicates.join(", ")}; server names must be unique"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|