ace-git-github 0.1.1 → 0.2.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 +35 -0
- data/lib/ace/git/github/issue_sync.rb +6 -0
- data/lib/ace/git/github/pr_fetcher.rb +8 -4
- data/lib/ace/git/github/provider.rb +186 -7
- data/lib/ace/git/github/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 998ca300a7c6610ff632dac2a6051fe84daa68a4e8daac13181e10fd86223798
|
|
4
|
+
data.tar.gz: 8cc1cf649582eed6600fc53bb0d70ac8bdc73226c02072400f8cf6c5de450d4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f8d10b98f72f43767031476e9a980658462834374ec26af019a7d7b753c5bb3d5e78257b2a6ee7261856069c72db234993541223b4f14c52ac84e83ba9a28f0
|
|
7
|
+
data.tar.gz: d1399752f36d349a36fa0bbb21406d62c820806ad78ff62eefe3ec0423b0bfc28f75964ac389f45e99b7ed599c148b8c638152ecf0bef8e51d77b596ea5a0ec8
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- PR lifecycle mutations with atomic provider-side expected-head enforcement:
|
|
14
|
+
exact-match lookup (`find_open_pull_requests`), idempotent draft-create
|
|
15
|
+
reconciliation, expected-head merge via the gh match-head-commit flag,
|
|
16
|
+
head-verified update/ready.
|
|
17
|
+
- Cross-repository PR provenance: head repository URL resolved from gh
|
|
18
|
+
evidence and returned in normalized PR evidence.
|
|
19
|
+
- Classified lifecycle failures (conflicting matches, expected-head conflict,
|
|
20
|
+
unknown create outcome with reconciliation identity).
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- `gh pr view`/`list` JSON field sets extended with head-repository fields.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- PR lifecycle mutations with atomic provider-side expected-head enforcement:
|
|
28
|
+
exact-match lookup (`find_open_pull_requests`), idempotent draft-create
|
|
29
|
+
reconciliation, expected-head merge via the gh match-head-commit flag,
|
|
30
|
+
head-verified update/ready.
|
|
31
|
+
- Cross-repository PR provenance: head repository URL resolved from gh
|
|
32
|
+
evidence and returned in normalized PR evidence.
|
|
33
|
+
- Classified lifecycle failures (conflicting matches, expected-head conflict,
|
|
34
|
+
unknown create outcome with reconciliation identity).
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
- `gh pr view`/`list` JSON field sets extended with head-repository fields.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
## [0.1.2] - 2026-09-26
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `IssueSync.available?` probe: true when the `gh` CLI is installed and authenticated, letting callers treat GitHub sync as best-effort.
|
|
44
|
+
|
|
10
45
|
## [0.1.1] - 2026-09-24
|
|
11
46
|
|
|
12
47
|
### Fixed
|
|
@@ -14,6 +14,12 @@ module Ace
|
|
|
14
14
|
TRACKED_LABEL = "ace:tracked"
|
|
15
15
|
TERMINAL_STATUSES = %w[done completed shipped closed cancelled skipped archived].freeze
|
|
16
16
|
|
|
17
|
+
# True when GitHub sync can run: the gh CLI is installed and authenticated.
|
|
18
|
+
# GitHub sync is best-effort; callers skip it silently when this is false.
|
|
19
|
+
def self.available?
|
|
20
|
+
CliExecutor.authenticated?
|
|
21
|
+
end
|
|
22
|
+
|
|
17
23
|
def self.sync_task(task_id:, task_title:, task_status:, task_path:, issue_ids:, reason:, previous: nil,
|
|
18
24
|
current_issue_ids: nil)
|
|
19
25
|
current_ids = if current_issue_ids.nil?
|
|
@@ -53,13 +53,15 @@ module Ace
|
|
|
53
53
|
# @param timeout [Integer] Timeout in seconds
|
|
54
54
|
# @param runner [Proc, nil] injectable command runner for tests
|
|
55
55
|
# @return [Hash] Result with :success, :diff, :identifier, :source
|
|
56
|
-
def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil)
|
|
56
|
+
def fetch_diff(identifier, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
|
|
57
57
|
parsed = PrIdentifier.parse(identifier)
|
|
58
58
|
raise ArgumentError, "Invalid PR identifier: #{identifier}" if parsed.nil?
|
|
59
59
|
|
|
60
60
|
validate_identifier_characters(parsed.gh_format)
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
args = ["diff", *parsed.cli_target_args]
|
|
63
|
+
args += ["--repo", repo] if repo
|
|
64
|
+
result = CliExecutor.execute("pr", args, timeout: timeout, runner: runner)
|
|
63
65
|
|
|
64
66
|
if result[:success]
|
|
65
67
|
{
|
|
@@ -182,9 +184,11 @@ module Ace
|
|
|
182
184
|
# @param timeout [Integer] Timeout in seconds
|
|
183
185
|
# @param runner [Proc, nil] injectable command runner for tests
|
|
184
186
|
# @return [Hash] Result with :success, :prs array, or :error
|
|
185
|
-
def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil)
|
|
187
|
+
def fetch_all_prs(limit: 15, timeout: Ace::Git.network_timeout, runner: nil, repo: nil)
|
|
188
|
+
args = ["list", "--state", "all", "--limit", limit.to_s, "--json", LIST_FIELDS]
|
|
189
|
+
args += ["--repo", repo] if repo
|
|
186
190
|
result = CliExecutor.execute(
|
|
187
|
-
"pr",
|
|
191
|
+
"pr", args,
|
|
188
192
|
timeout: timeout, runner: runner
|
|
189
193
|
)
|
|
190
194
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Git
|
|
@@ -25,11 +26,14 @@ module Ace
|
|
|
25
26
|
"skipping" => :skipped
|
|
26
27
|
}.freeze
|
|
27
28
|
|
|
28
|
-
PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt"
|
|
29
|
+
PR_FIELDS = "number,state,isDraft,title,author,headRefName,baseRefName,url,headRefOid,mergeCommit,mergedAt,headRepositoryOwner,headRepository"
|
|
29
30
|
LIST_FIELDS = PrFetcher::LIST_FIELDS
|
|
30
31
|
REPO_FIELDS = "nameWithOwner,defaultBranchRef,url"
|
|
31
32
|
ISSUE_FIELDS = "number,title,state,author,url"
|
|
32
33
|
|
|
34
|
+
# Fields needed to match pull requests by exact base/head identity.
|
|
35
|
+
LIFECYCLE_LIST_FIELDS = "number,title,state,isDraft,author,headRefName,baseRefName,url,headRefOid,headRepositoryOwner,headRepository"
|
|
36
|
+
|
|
33
37
|
class << self
|
|
34
38
|
# Human-facing provider type name used in failure messages.
|
|
35
39
|
def display_name
|
|
@@ -66,6 +70,13 @@ module Ace
|
|
|
66
70
|
normalize_pr(data, server.name)
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
# @return [String] HOST/OWNER/REPO for the resolved server; appended
|
|
74
|
+
# to every data/mutation command so a `--server` selection can never
|
|
75
|
+
# be silently retargeted to the checkout's repository.
|
|
76
|
+
def repo_target
|
|
77
|
+
@repo_target ||= Ace::Git::Atoms::ServerUrl.normalize(server.url)
|
|
78
|
+
end
|
|
79
|
+
|
|
69
80
|
# @return [ProviderPullRequest, nil] evidence for the branch's PR
|
|
70
81
|
def pull_request_for_branch(branch:)
|
|
71
82
|
candidates = recent_pull_requests(limit: 30)
|
|
@@ -77,13 +88,13 @@ module Ace
|
|
|
77
88
|
|
|
78
89
|
# @return [String] unified diff text for the pull request
|
|
79
90
|
def pull_request_diff(number:)
|
|
80
|
-
result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner)
|
|
91
|
+
result = PrFetcher.fetch_diff(number.to_s, timeout: timeout, runner: runner, repo: repo_target)
|
|
81
92
|
result[:diff]
|
|
82
93
|
end
|
|
83
94
|
|
|
84
95
|
# @return [Array<ProviderPullRequest>] recent PRs, newest first
|
|
85
96
|
def recent_pull_requests(limit:)
|
|
86
|
-
result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner)
|
|
97
|
+
result = PrFetcher.fetch_all_prs(limit: limit, timeout: timeout, runner: runner, repo: repo_target)
|
|
87
98
|
result[:prs]
|
|
88
99
|
.map { |pr| normalize_pr(pr, server.name) }
|
|
89
100
|
.sort_by { |pr| -pr.number.to_i }
|
|
@@ -112,7 +123,8 @@ module Ace
|
|
|
112
123
|
|
|
113
124
|
# @return [ProviderRepository] normalized repository evidence
|
|
114
125
|
def repository
|
|
115
|
-
|
|
126
|
+
# `gh repo view` rejects `--repo`; the repository is positional.
|
|
127
|
+
output = gh_json(["repo", "view", repo_target, "--json", REPO_FIELDS], bind_repo: false)
|
|
116
128
|
Ace::Git::ProviderRepository.new(
|
|
117
129
|
server_name: server.name,
|
|
118
130
|
full_name: output["nameWithOwner"],
|
|
@@ -121,13 +133,156 @@ module Ace
|
|
|
121
133
|
)
|
|
122
134
|
end
|
|
123
135
|
|
|
136
|
+
# ---- PR lifecycle mutations ----
|
|
137
|
+
|
|
138
|
+
# @return [Array<ProviderPullRequest>] open PRs matching the exact
|
|
139
|
+
# base/head identity (head repository URL/ref plus base URL/ref)
|
|
140
|
+
def find_open_pull_requests(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
141
|
+
require_base_on_server!(base_repository_url)
|
|
142
|
+
data = gh_json(["pr", "list", "--state", "open", "--limit", "200", "--json", LIFECYCLE_LIST_FIELDS])
|
|
143
|
+
data.filter_map { |entry| normalize_pr(entry, server.name) }
|
|
144
|
+
.select do |pr|
|
|
145
|
+
pr.head_ref == head_ref &&
|
|
146
|
+
pr.base_ref == base_ref &&
|
|
147
|
+
Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, head_repository_url)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Create a PR, reconciling to an exact open match first; proves the
|
|
152
|
+
# created/reconciled head against `expected_head`.
|
|
153
|
+
#
|
|
154
|
+
# @return [ProviderMutationReceipt] idempotency :created/:existing
|
|
155
|
+
def create_pull_request(head_ref:, head_repository_url:, base_ref:, expected_head:, title:, body: nil, draft: true)
|
|
156
|
+
matches = find_open_pull_requests(
|
|
157
|
+
head_repository_url: head_repository_url, head_ref: head_ref,
|
|
158
|
+
base_repository_url: server.url, base_ref: base_ref
|
|
159
|
+
)
|
|
160
|
+
if matches.size > 1
|
|
161
|
+
raise Ace::Git::ProviderConflictingMatchesError,
|
|
162
|
+
"Multiple open pull requests match #{head_repository_url}@#{head_ref} -> " \
|
|
163
|
+
"#{base_ref}: #{matches.map(&:number).join(", ")}; resolve the conflict first"
|
|
164
|
+
end
|
|
165
|
+
if matches.size == 1
|
|
166
|
+
existing = matches.first
|
|
167
|
+
return receipt(:create, verify_head!(existing, expected_head), :existing)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
args = ["pr", "create", "--head", gh_head_arg(head_ref, head_repository_url), "--base", base_ref, "--title", title]
|
|
171
|
+
args += ["--body", body] if body
|
|
172
|
+
args << "--draft" if draft
|
|
173
|
+
|
|
174
|
+
result = send_mutation(args, ambiguous: true, identity: identity_text(head_repository_url, head_ref, base_ref))
|
|
175
|
+
number = result[:stdout].to_s.strip[%r{/pull/(\d+)\z}, 1]
|
|
176
|
+
unless number
|
|
177
|
+
raise Ace::Git::ProviderMalformedOutputError,
|
|
178
|
+
"gh pr create did not return a pull request URL: #{result[:stdout].to_s.strip}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
created = verify_head!(pull_request(number: number), expected_head)
|
|
182
|
+
receipt(:create, created, :created)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @return [ProviderMutationReceipt] operation :update
|
|
186
|
+
def update_pull_request(number:, expected_head:, title: nil, body: nil)
|
|
187
|
+
verify_head!(pull_request(number: number), expected_head)
|
|
188
|
+
args = ["pr", "edit", number.to_s]
|
|
189
|
+
args += ["--title", title] if title
|
|
190
|
+
args += ["--body", body] if body
|
|
191
|
+
send_mutation(args, ambiguous: false)
|
|
192
|
+
receipt(:update, pull_request(number: number), nil)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [ProviderMutationReceipt] operation :ready
|
|
196
|
+
def ready_pull_request(number:, expected_head:)
|
|
197
|
+
verify_head!(pull_request(number: number), expected_head)
|
|
198
|
+
send_mutation(["pr", "ready", number.to_s], ambiguous: false)
|
|
199
|
+
receipt(:ready, pull_request(number: number), nil)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Merge with atomic provider-side expected-head enforcement via
|
|
203
|
+
# `gh pr merge --match-head-commit`.
|
|
204
|
+
#
|
|
205
|
+
# @return [ProviderMutationReceipt] operation :merge
|
|
206
|
+
def merge_pull_request(number:, expected_head:, method:)
|
|
207
|
+
unless %i[squash merge rebase].include?(method)
|
|
208
|
+
raise ArgumentError, "Invalid merge method #{method.inspect}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
send_mutation(
|
|
212
|
+
["pr", "merge", number.to_s, "--#{method}", "--match-head-commit", expected_head],
|
|
213
|
+
ambiguous: false
|
|
214
|
+
)
|
|
215
|
+
receipt(:merge, pull_request(number: number), nil)
|
|
216
|
+
end
|
|
217
|
+
|
|
124
218
|
private
|
|
125
219
|
|
|
126
220
|
STATE_ORDER = {open: 0, merged: 1, closed: 2}.freeze
|
|
127
221
|
|
|
222
|
+
# Send one mutating `gh` command. When `ambiguous` is true (create),
|
|
223
|
+
# any transport-level failure is reported as an unknown outcome: the
|
|
224
|
+
# request may have mutated the forge, so callers must reconcile by
|
|
225
|
+
# exact identity instead of retrying blindly.
|
|
226
|
+
def send_mutation(args, ambiguous:, identity: nil)
|
|
227
|
+
result = CliExecutor.execute(args.first, (args[1..] || []) + ["--repo", repo_target], timeout: timeout, runner: runner)
|
|
228
|
+
return result if result[:success]
|
|
229
|
+
|
|
230
|
+
classify_failure(result[:stderr], context: args.join(" "))
|
|
231
|
+
result
|
|
232
|
+
rescue Ace::Git::ProviderUnreachableError => e
|
|
233
|
+
raise e unless ambiguous
|
|
234
|
+
|
|
235
|
+
raise Ace::Git::ProviderUnknownOutcomeError,
|
|
236
|
+
"PR create outcome unknown after transport failure (#{e.message}); " \
|
|
237
|
+
"reconcile by exact identity before repeating: #{identity}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Exact base/head identity text for reconciliation contexts.
|
|
241
|
+
def identity_text(head_repository_url, head_ref, base_ref)
|
|
242
|
+
"#{head_repository_url}@#{head_ref} -> #{server.url}@#{base_ref}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Refuse when the live head differs from the caller's expected head
|
|
246
|
+
# (shared guard from Providers::Base).
|
|
247
|
+
def verify_head!(pr, expected_head)
|
|
248
|
+
verify_expected_head!(pr, expected_head)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# `gh` head selector: "user:branch" for fork sources, plain ref for
|
|
252
|
+
# the base repository. The owner comes from the declared URL only.
|
|
253
|
+
def gh_head_arg(head_ref, head_repository_url)
|
|
254
|
+
return head_ref if head_repository_url.nil? ||
|
|
255
|
+
Ace::Git::Atoms::ServerUrl.match?(head_repository_url, server.url)
|
|
256
|
+
|
|
257
|
+
path = Ace::Git::Atoms::ServerUrl.normalize(head_repository_url).split("/", 2)[1]
|
|
258
|
+
owner = path.to_s.split("/").first
|
|
259
|
+
raise ArgumentError, "Cannot derive fork owner from #{head_repository_url}" if owner.nil? || owner.empty?
|
|
260
|
+
|
|
261
|
+
"#{owner}:#{head_ref}"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def require_base_on_server!(base_repository_url)
|
|
265
|
+
return if Ace::Git::Atoms::ServerUrl.match?(base_repository_url, server.url)
|
|
266
|
+
|
|
267
|
+
raise Ace::Git::ProviderUnsupportedCapabilityError,
|
|
268
|
+
"GitHub provider operates on the resolved server repository (#{server.url}); " \
|
|
269
|
+
"refusing base repository #{base_repository_url}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def receipt(operation, pull_request, idempotency)
|
|
273
|
+
Ace::Git::ProviderMutationReceipt.new(
|
|
274
|
+
server_name: server.name, operation: operation,
|
|
275
|
+
pull_request: pull_request, idempotency: idempotency
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
128
279
|
# Run a `gh` command expecting JSON output; classify all failures.
|
|
129
|
-
|
|
130
|
-
|
|
280
|
+
# Every command is bound to the resolved server repository via
|
|
281
|
+
# `--repo`, except commands that take the repository positionally.
|
|
282
|
+
def gh_json(args, bind_repo: true)
|
|
283
|
+
argv = args[1..] || []
|
|
284
|
+
argv += ["--repo", repo_target] if bind_repo
|
|
285
|
+
result = CliExecutor.execute(args.first, argv, timeout: timeout, runner: runner)
|
|
131
286
|
unless result[:success]
|
|
132
287
|
classify_failure(result[:stderr], context: args.join(" "))
|
|
133
288
|
end
|
|
@@ -152,10 +307,31 @@ module Ace
|
|
|
152
307
|
author: normalize_author(data["author"]),
|
|
153
308
|
url: data["url"],
|
|
154
309
|
draft: data["isDraft"],
|
|
155
|
-
merged_at: merged_at_of(data)
|
|
310
|
+
merged_at: merged_at_of(data),
|
|
311
|
+
head_repository_url: head_repository_url_of(data),
|
|
312
|
+
base_repository_url: server.url,
|
|
313
|
+
merge_commit_sha: data.dig("mergeCommit", "oid")
|
|
156
314
|
)
|
|
157
315
|
end
|
|
158
316
|
|
|
317
|
+
# Source repository URL for the PR head: the fork's owner/name against
|
|
318
|
+
# the server host for cross-repository PRs, the configured repository
|
|
319
|
+
# otherwise. Never inferred beyond what `gh` reported.
|
|
320
|
+
def head_repository_url_of(data)
|
|
321
|
+
owner = data.dig("headRepositoryOwner", "login")
|
|
322
|
+
name = data.dig("headRepository", "name")
|
|
323
|
+
return server.url if owner.nil? || name.nil?
|
|
324
|
+
|
|
325
|
+
"#{server_host_root}/#{owner}/#{name}"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def server_host_root
|
|
329
|
+
@server_host_root ||= begin
|
|
330
|
+
uri = URI.parse(server.url.to_s)
|
|
331
|
+
"#{uri.scheme || "https"}://#{uri.host}#{":#{uri.port}" if uri.port && uri.port != uri.default_port}"
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
159
335
|
def merged_at_of(data)
|
|
160
336
|
data["mergedAt"]
|
|
161
337
|
end
|
|
@@ -184,6 +360,9 @@ module Ace
|
|
|
184
360
|
raise Ace::Git::ProviderObjectNotFoundError, "Object not found: #{context}: #{message}"
|
|
185
361
|
elsif message.match?(PrFetcher::AUTH_ERROR_PATTERN)
|
|
186
362
|
raise Ace::Git::ProviderAuthenticationError, "Not authenticated with GitHub: #{message}"
|
|
363
|
+
elsif message.match?(/match[- ]head[- ]commit|head commit/i)
|
|
364
|
+
raise Ace::Git::ProviderExpectedHeadConflictError,
|
|
365
|
+
"GitHub refused the merge: pull request head does not match --match-head-commit (#{context}): #{message}"
|
|
187
366
|
else
|
|
188
367
|
raise Ace::Git::ProviderUnreachableError, "GitHub request failed (#{context}): #{message}"
|
|
189
368
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git-github
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-git
|