ace-git 0.28.1 → 0.30.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 +33 -0
- data/README.md +1 -1
- data/docs/handbook.md +4 -4
- data/docs/usage.md +17 -0
- data/handbook/guides/version-control-system-git.g.md +11 -4
- data/handbook/skills/as-git-pr-create/SKILL.md +23 -0
- data/handbook/skills/as-git-pr-update/SKILL.md +23 -0
- data/handbook/workflow-instructions/git/pr/create.wf.md +70 -0
- data/handbook/workflow-instructions/git/pr/update.wf.md +62 -0
- data/lib/ace/git/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/git/atoms/service_merge_evidence.rb +71 -0
- data/lib/ace/git/atoms/service_pr_evidence.rb +76 -0
- data/lib/ace/git/atoms/service_pr_input.rb +56 -0
- data/lib/ace/git/cli/commands/service_merge.rb +29 -0
- data/lib/ace/git/cli.rb +10 -1
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +49 -6
- data/lib/ace/git/organisms/service_merge.rb +214 -0
- data/lib/ace/git/providers/base.rb +6 -0
- data/lib/ace/git/server_registry.rb +8 -6
- data/lib/ace/git/version.rb +1 -1
- metadata +12 -6
- data/handbook/skills/as-github-pr-create/SKILL.md +0 -72
- data/handbook/skills/as-github-pr-update/SKILL.md +0 -41
- data/handbook/workflow-instructions/github/pr/create.wf.md +0 -282
- data/handbook/workflow-instructions/github/pr/update.wf.md +0 -203
data/lib/ace/git/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "cli/commands/diff"
|
|
|
7
7
|
require_relative "cli/commands/status"
|
|
8
8
|
require_relative "cli/commands/branch"
|
|
9
9
|
require_relative "cli/commands/pr"
|
|
10
|
+
require_relative "cli/commands/service_merge"
|
|
10
11
|
|
|
11
12
|
module Ace
|
|
12
13
|
module Git
|
|
@@ -52,7 +53,11 @@ module Ace
|
|
|
52
53
|
["diff", "Show filtered git diff output"],
|
|
53
54
|
["status", "Show repository status and PR context"],
|
|
54
55
|
["branch", "Show current branch information"],
|
|
55
|
-
["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"]
|
|
56
|
+
["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"],
|
|
57
|
+
["service merge", "Fixed receiver-owned exact-head merge envelope"],
|
|
58
|
+
["service create", "Fixed receiver-owned draft PR creation"],
|
|
59
|
+
["service update", "Fixed receiver-owned draft PR update"],
|
|
60
|
+
["service ready", "Fixed receiver-owned reviewed PR readiness"]
|
|
56
61
|
].freeze
|
|
57
62
|
|
|
58
63
|
HELP_EXAMPLES = [
|
|
@@ -70,6 +75,10 @@ module Ace
|
|
|
70
75
|
register "pr update", Commands::Pr::Update.new
|
|
71
76
|
register "pr ready", Commands::Pr::Ready.new
|
|
72
77
|
register "pr merge", Commands::Pr::Merge.new
|
|
78
|
+
register "service merge", Commands::ServiceMerge.new
|
|
79
|
+
%w[create update ready].each do |operation|
|
|
80
|
+
register "service #{operation}", Commands::ServiceMerge.new(operation: operation)
|
|
81
|
+
end
|
|
73
82
|
|
|
74
83
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
75
84
|
gem_name: "ace-git",
|
|
@@ -29,8 +29,10 @@ module Ace
|
|
|
29
29
|
# @param remote_name [String, nil] git remote used when nothing selected
|
|
30
30
|
# @param timeout [Integer, nil] provider operation timeout
|
|
31
31
|
# @param runner [Proc, nil] injectable provider command runner (tests)
|
|
32
|
-
def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil)
|
|
32
|
+
def initialize(server_name: nil, use_default: false, remote_name: nil, timeout: nil, runner: nil, repo_root: nil, resolved_server: nil)
|
|
33
|
+
@pinned_server = resolved_server
|
|
33
34
|
@selection = {server_name: server_name, use_default: use_default, remote_name: remote_name}
|
|
35
|
+
@selection[:repo_root] = repo_root if repo_root
|
|
34
36
|
@timeout = timeout
|
|
35
37
|
@runner = runner
|
|
36
38
|
end
|
|
@@ -194,6 +196,11 @@ module Ace
|
|
|
194
196
|
)
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
# Pure provider-owned encoding; selection is pinned by the caller.
|
|
200
|
+
def draft_title(title)
|
|
201
|
+
provider_for(resolve_selected_server).class.pull_request_title(title: title, draft: true)
|
|
202
|
+
end
|
|
203
|
+
|
|
197
204
|
# Update title/body after exact-head verification.
|
|
198
205
|
#
|
|
199
206
|
# @return [ProviderMutationReceipt]
|
|
@@ -234,6 +241,37 @@ module Ace
|
|
|
234
241
|
)
|
|
235
242
|
end
|
|
236
243
|
|
|
244
|
+
# Freeze the selected provider identity for an assignment attempt.
|
|
245
|
+
def resolved_identity(identifier = nil)
|
|
246
|
+
selected = identifier.nil? ? resolve_selected_server : resolve_server_for(parse_identifier(identifier))
|
|
247
|
+
if @pinned_server && selected != @pinned_server
|
|
248
|
+
raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
|
|
249
|
+
end
|
|
250
|
+
selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Protected create has no PR identifier or materialized origin remote.
|
|
254
|
+
# Resolve the retained base repository through the same exact registry.
|
|
255
|
+
def resolved_repository_identity(repository_url)
|
|
256
|
+
Atoms::DeliveryParameters.validate_url!(repository_url)
|
|
257
|
+
selected = resolve_matching_server(ServerRegistry.matching_servers(repository_url), repository_url)
|
|
258
|
+
if @pinned_server && selected != @pinned_server
|
|
259
|
+
raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
|
|
260
|
+
end
|
|
261
|
+
selected.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Read-only reconciliation of a possibly completed create. Absence
|
|
265
|
+
# does not authorize another write; the assignment owns that policy.
|
|
266
|
+
def reconcile_create(head_repository_url:, head_ref:, base_repository_url:, base_ref:)
|
|
267
|
+
server = resolve_selected_server
|
|
268
|
+
unless Atoms::ServerUrl.match?(server.url, base_repository_url)
|
|
269
|
+
raise ProviderIdentityMismatchError, "Create reconciliation base does not match selected server"
|
|
270
|
+
end
|
|
271
|
+
provider_for(server).find_open_pull_requests(head_repository_url: head_repository_url,
|
|
272
|
+
head_ref: head_ref, base_repository_url: base_repository_url, base_ref: base_ref)
|
|
273
|
+
end
|
|
274
|
+
|
|
237
275
|
def resolved_server_url(identifier)
|
|
238
276
|
reference = parse_identifier(identifier)
|
|
239
277
|
resolve_server_for(reference).url
|
|
@@ -255,7 +293,11 @@ module Ace
|
|
|
255
293
|
end
|
|
256
294
|
|
|
257
295
|
def resolve_selected_server
|
|
258
|
-
ServerRegistry.resolve_for(**@selection)
|
|
296
|
+
selected = ServerRegistry.resolve_for(**@selection)
|
|
297
|
+
if @pinned_server && selected != @pinned_server
|
|
298
|
+
raise ProviderIdentityMismatchError, "Configured server identity changed from the pinned delivery identity"
|
|
299
|
+
end
|
|
300
|
+
@pinned_server || selected
|
|
259
301
|
end
|
|
260
302
|
|
|
261
303
|
# Resolve exactly one server for a parsed reference, validating any
|
|
@@ -269,10 +311,13 @@ module Ace
|
|
|
269
311
|
ServerRegistry.servers_for_owner_repo(reference.owner_repo)
|
|
270
312
|
end
|
|
271
313
|
|
|
314
|
+
resolve_matching_server(candidates, reference.repository_url || reference.owner_repo)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def resolve_matching_server(candidates, identity)
|
|
272
318
|
if @selection[:server_name] || @selection[:use_default]
|
|
273
|
-
selected =
|
|
319
|
+
selected = resolve_selected_server
|
|
274
320
|
unless candidates.any? { |candidate| candidate.name == selected.name }
|
|
275
|
-
identity = reference.repository_url || reference.owner_repo
|
|
276
321
|
raise ProviderIdentityMismatchError,
|
|
277
322
|
"PR identifier (#{identity}) does not match the selected server '#{selected.name}'"
|
|
278
323
|
end
|
|
@@ -281,12 +326,10 @@ module Ace
|
|
|
281
326
|
elsif candidates.size == 1
|
|
282
327
|
candidates.first
|
|
283
328
|
elsif candidates.empty?
|
|
284
|
-
identity = reference.repository_url || reference.owner_repo
|
|
285
329
|
raise AmbiguousRemoteError,
|
|
286
330
|
"PR repository '#{identity}' matches no configured server (configured: " \
|
|
287
331
|
"#{ServerRegistry.servers.map(&:name).join(", ")})"
|
|
288
332
|
else
|
|
289
|
-
identity = reference.repository_url || reference.owner_repo
|
|
290
333
|
raise AmbiguousRemoteError,
|
|
291
334
|
"PR repository '#{identity}' matches multiple configured servers " \
|
|
292
335
|
"(#{candidates.map(&:name).join(", ")}); select one explicitly"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "pull_request_lifecycle"
|
|
5
|
+
require_relative "../atoms/delivery_parameters"
|
|
6
|
+
require_relative "../atoms/service_pr_input"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Git
|
|
10
|
+
module Organisms
|
|
11
|
+
# A fixed receiver-side operation, never a grant or a worker fallback.
|
|
12
|
+
class ServiceMerge
|
|
13
|
+
MAX_INPUT = 128 * 1024
|
|
14
|
+
MAX_SERVICE_INPUT = 64 * 1024
|
|
15
|
+
MAX_ARTIFACT = 64 * 1024
|
|
16
|
+
ARTIFACT = "pr-result.txt"
|
|
17
|
+
REQUEST = %w[request_id assignment_id attempt_id project_id operation input_digest target candidate_head
|
|
18
|
+
executor_uid transport authorization service_id caller_uid].freeze
|
|
19
|
+
EXECUTION = %w[executor_uid authority_id claim_binding candidate_generation head staging_id].freeze
|
|
20
|
+
TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
21
|
+
SHA = /\A[0-9a-f]{40}\z/
|
|
22
|
+
DIGEST = /\A[0-9a-f]{64}\z/
|
|
23
|
+
|
|
24
|
+
def initialize(operation: "merge", lifecycle_factory: nil, uid: -> { Process.uid }, euid: -> { Process.euid })
|
|
25
|
+
raise ArgumentError, "unsupported fixed PR operation" unless Atoms::ServicePrInput::OPERATIONS.include?(operation)
|
|
26
|
+
@operation = operation
|
|
27
|
+
@factory = lifecycle_factory || ->(**selection) { PullRequestLifecycle.new(**selection) }
|
|
28
|
+
@uid, @euid = uid, euid
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(bytes:, root:)
|
|
32
|
+
envelope = parse!(bytes)
|
|
33
|
+
request, execution, input = envelope.values_at("request", "execution", "input")
|
|
34
|
+
parameters = Atoms::DeliveryParameters.validate(input.fetch("delivery"))
|
|
35
|
+
Atoms::ServicePrInput.validate(input, operation: @operation)
|
|
36
|
+
directory, held = private_root!(root)
|
|
37
|
+
begin
|
|
38
|
+
lifecycle = @factory.call(server_name: parameters["forge_server"], use_default: parameters.fetch("forge_default"), repo_root: directory)
|
|
39
|
+
server = if @operation == "create"
|
|
40
|
+
lifecycle.resolved_repository_identity(request.fetch("target").fetch("resource"))
|
|
41
|
+
else
|
|
42
|
+
lifecycle.resolved_identity(request.fetch("target").fetch("resource"))
|
|
43
|
+
end
|
|
44
|
+
provenance = parameters.fetch("pr_provenance")
|
|
45
|
+
unless Atoms::ServerUrl.match?(server.fetch("url"), provenance.fetch("base_repository_url"))
|
|
46
|
+
raise ProviderIdentityMismatchError, "merge forge differs from accepted provenance"
|
|
47
|
+
end
|
|
48
|
+
pinned = ResolvedServer.new(name: server.fetch("name"), provider: server.fetch("provider").to_sym, url: server.fetch("url"))
|
|
49
|
+
lifecycle = @factory.call(server_name: pinned.name, use_default: false, repo_root: directory, resolved_server: pinned)
|
|
50
|
+
resource, head = request.fetch("target").fetch("resource"), execution.fetch("head")
|
|
51
|
+
if @operation != "create"
|
|
52
|
+
before = lifecycle.show(resource)
|
|
53
|
+
verify_pr!(before, resource, head, server, provenance)
|
|
54
|
+
if %w[update ready].include?(@operation) && before.state.to_s != "open"
|
|
55
|
+
raise ProviderExpectedHeadConflictError, "protected PR edit requires an open PR"
|
|
56
|
+
end
|
|
57
|
+
if @operation == "update" && !before.draft
|
|
58
|
+
raise ProviderExpectedHeadConflictError, "protected update requires a draft PR"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
title = lifecycle.draft_title(input.fetch("title")) if Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
|
|
62
|
+
receipt = case @operation
|
|
63
|
+
when "create"
|
|
64
|
+
lifecycle.create(head_ref: provenance.fetch("head_ref"), base_ref: provenance.fetch("base_ref"),
|
|
65
|
+
head_repository_url: provenance.fetch("head_repository_url"), expected_head: head,
|
|
66
|
+
title: title, body: input.fetch("body"), draft: true)
|
|
67
|
+
when "update"
|
|
68
|
+
lifecycle.update(resource, expected_head: head, title: title, body: input.fetch("body"))
|
|
69
|
+
when "ready" then lifecycle.ready(resource, expected_head: head)
|
|
70
|
+
when "merge" then lifecycle.merge(resource, expected_head: head, method: input.fetch("method"))
|
|
71
|
+
end
|
|
72
|
+
unless receipt.is_a?(ProviderMutationReceipt) && receipt.server_name == pinned.name && receipt.operation.to_s == @operation
|
|
73
|
+
raise ProviderUnknownOutcomeError, "PR response does not identify the selected operation"
|
|
74
|
+
end
|
|
75
|
+
after = receipt.pull_request
|
|
76
|
+
raise ProviderUnknownOutcomeError, "PR response lacks its exact result" unless after.is_a?(ProviderPullRequest)
|
|
77
|
+
verify_pr!(after, @operation == "create" ? after.url : resource, head, server, provenance)
|
|
78
|
+
if @operation == "merge"
|
|
79
|
+
unless after.state.to_s == "merged" && after.merge_commit_sha.is_a?(String) && after.merge_commit_sha.match?(SHA)
|
|
80
|
+
raise ProviderUnknownOutcomeError, "merge outcome is not verified"
|
|
81
|
+
end
|
|
82
|
+
payload = {"server" => server, "delivery" => parameters, "method" => input.fetch("method"),
|
|
83
|
+
"candidate_head" => head, "receipt" => receipt.to_h}
|
|
84
|
+
else
|
|
85
|
+
expected_draft = Atoms::ServicePrInput::DRAFT_OPERATIONS.include?(@operation)
|
|
86
|
+
unless after.state.to_s == "open" && after.draft == expected_draft &&
|
|
87
|
+
(!expected_draft || after.title == title && after.body == input.fetch("body"))
|
|
88
|
+
raise ProviderUnknownOutcomeError, "PR draft/content outcome is not verified"
|
|
89
|
+
end
|
|
90
|
+
payload = {"server" => server, "operation" => @operation, "input" => input,
|
|
91
|
+
"candidate_head" => head, "receipt" => receipt.to_h}
|
|
92
|
+
end
|
|
93
|
+
artifact = "ace-service-attestation request:#{request.fetch('request_id')} input:#{request.fetch('input_digest')} outcome:succeeded\n" + JSON.generate(payload) + "\n"
|
|
94
|
+
digest = publish!(directory, held, artifact)
|
|
95
|
+
{"request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
|
|
96
|
+
"outcome" => "succeeded", "evidence" => [{"ref" => ARTIFACT, "sha256" => digest}]}
|
|
97
|
+
ensure
|
|
98
|
+
held.close
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse!(bytes)
|
|
105
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_INPUT) && bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
|
|
106
|
+
raise ArgumentError, "merge envelope is invalid or oversized"
|
|
107
|
+
end
|
|
108
|
+
envelope = JSON.parse(bytes, max_nesting: 16, create_additions: false,
|
|
109
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false)
|
|
110
|
+
closed!(envelope, %w[version request input execution])
|
|
111
|
+
raise ArgumentError, "merge envelope version differs" unless envelope["version"].is_a?(Integer) && envelope["version"] == 1
|
|
112
|
+
request, execution, input = envelope.values_at("request", "execution", "input")
|
|
113
|
+
closed!(request, REQUEST)
|
|
114
|
+
closed!(execution, EXECUTION)
|
|
115
|
+
Atoms::ServicePrInput.validate(input, operation: @operation)
|
|
116
|
+
raise ArgumentError, "merge service input exceeds receiver bound" if JSON.generate(input).bytesize > MAX_SERVICE_INPUT
|
|
117
|
+
closed!(input["target"], %w[resource artifact_digest])
|
|
118
|
+
closed!(request["target"], %w[resource artifact_digest])
|
|
119
|
+
%w[request_id assignment_id attempt_id project_id authorization service_id].each do |key|
|
|
120
|
+
raise ArgumentError, "merge request identifier differs" unless request[key].is_a?(String) && request[key].match?(TOKEN)
|
|
121
|
+
end
|
|
122
|
+
%w[authority_id staging_id].each do |key|
|
|
123
|
+
raise ArgumentError, "merge execution identifier differs" unless execution[key].is_a?(String) && execution[key].match?(TOKEN)
|
|
124
|
+
end
|
|
125
|
+
unless request["operation"] == @operation && request["transport"] == "unix" &&
|
|
126
|
+
%w[executor_uid caller_uid].all? { |key| request[key].is_a?(Integer) && request[key].positive? } &&
|
|
127
|
+
request["executor_uid"] != request["caller_uid"] && request["executor_uid"] == execution["executor_uid"] &&
|
|
128
|
+
execution["executor_uid"].is_a?(Integer) &&
|
|
129
|
+
request["executor_uid"] == @uid.call && request["executor_uid"] == @euid.call &&
|
|
130
|
+
execution["candidate_generation"].is_a?(Integer) && execution["candidate_generation"].positive? &&
|
|
131
|
+
execution["head"].is_a?(String) && execution["head"].match?(SHA) && request["candidate_head"] == execution["head"] &&
|
|
132
|
+
execution["claim_binding"].is_a?(String) && execution["claim_binding"].match?(DIGEST) &&
|
|
133
|
+
request["input_digest"].is_a?(String) && request["input_digest"].match?(DIGEST) &&
|
|
134
|
+
request["input_digest"] == Digest::SHA256.hexdigest(JSON.generate(canonical(input))) && request["target"] == input["target"]
|
|
135
|
+
raise ArgumentError, "merge original claim/input/executor join differs"
|
|
136
|
+
end
|
|
137
|
+
target = input.fetch("target")
|
|
138
|
+
reference = target["resource"].is_a?(String) && Atoms::PrReference.parse(target["resource"])
|
|
139
|
+
unless (@operation == "create" || reference && reference.repository_url) && target["resource"].is_a?(String) && target["resource"].bytesize.between?(1, 256) &&
|
|
140
|
+
(target["artifact_digest"].nil? || target["artifact_digest"].is_a?(String) && target["artifact_digest"].match?(DIGEST))
|
|
141
|
+
raise ArgumentError, "merge target requires an exact PR URL"
|
|
142
|
+
end
|
|
143
|
+
envelope
|
|
144
|
+
rescue JSON::ParserError, JSON::NestingError, KeyError
|
|
145
|
+
raise ArgumentError, "merge envelope is malformed"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def closed!(value, fields)
|
|
149
|
+
raise ArgumentError, "merge fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def canonical(value)
|
|
153
|
+
case value
|
|
154
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
|
|
155
|
+
when Array then value.map { |item| canonical(item) }
|
|
156
|
+
else value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def verify_pr!(pr, resource, head, server, provenance)
|
|
161
|
+
reference = Atoms::PrReference.parse(resource)
|
|
162
|
+
unless pr.is_a?(ProviderPullRequest) && reference && reference.repository_url && pr.url == resource && pr.number == reference.number &&
|
|
163
|
+
pr.server_name == server.fetch("name") && pr.head_sha == head &&
|
|
164
|
+
pr.head_ref == provenance.fetch("head_ref") && pr.base_ref == provenance.fetch("base_ref") &&
|
|
165
|
+
Atoms::ServerUrl.match?(reference.repository_url, provenance.fetch("base_repository_url")) &&
|
|
166
|
+
Atoms::ServerUrl.match?(pr.head_repository_url, provenance.fetch("head_repository_url")) &&
|
|
167
|
+
Atoms::ServerUrl.match?(pr.base_repository_url, provenance.fetch("base_repository_url"))
|
|
168
|
+
raise ProviderExpectedHeadConflictError, "merge PR identity/head/provenance differs"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def private_root!(root)
|
|
173
|
+
raise ArgumentError, "merge staging root must be absolute" unless root.is_a?(String) && root.start_with?("/")
|
|
174
|
+
held = File.open(root, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
175
|
+
stat = held.stat
|
|
176
|
+
unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
|
|
177
|
+
[stat.dev, stat.ino] == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
178
|
+
raise ArgumentError, "merge staging root is not private"
|
|
179
|
+
end
|
|
180
|
+
[root, held]
|
|
181
|
+
rescue StandardError
|
|
182
|
+
held&.close
|
|
183
|
+
raise
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def publish!(root, held, bytes)
|
|
187
|
+
raise ProviderUnknownOutcomeError, "merge result exceeds evidence bound" unless bytes.bytesize <= MAX_ARTIFACT
|
|
188
|
+
stat = held.stat
|
|
189
|
+
identity = [stat.dev, stat.ino]
|
|
190
|
+
unless stat.directory? && stat.uid == @uid.call && (stat.mode & 0o7777) == 0o700 &&
|
|
191
|
+
identity == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
192
|
+
raise ProviderUnknownOutcomeError, "merge staging root changed"
|
|
193
|
+
end
|
|
194
|
+
File.open(File.join(root, ARTIFACT), File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600) do |file|
|
|
195
|
+
file.write(bytes); file.flush; file.fsync; file.chmod(0o400)
|
|
196
|
+
before = file.stat
|
|
197
|
+
file.rewind
|
|
198
|
+
captured = file.read(MAX_ARTIFACT + 1)
|
|
199
|
+
after = file.stat
|
|
200
|
+
unless before.file? && before.uid == @uid.call && captured == bytes &&
|
|
201
|
+
[before.dev, before.ino, before.size, before.mtime, before.ctime] == [after.dev, after.ino, after.size, after.mtime, after.ctime]
|
|
202
|
+
raise ProviderUnknownOutcomeError, "merge held evidence changed"
|
|
203
|
+
end
|
|
204
|
+
held.fsync
|
|
205
|
+
raise ProviderUnknownOutcomeError, "merge staging root changed" unless identity == File.lstat(root).then { |value| [value.dev, value.ino] }
|
|
206
|
+
Digest::SHA256.hexdigest(captured)
|
|
207
|
+
end
|
|
208
|
+
rescue SystemCallError, IOError
|
|
209
|
+
raise ProviderUnknownOutcomeError, "merge evidence publication unavailable"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -17,6 +17,12 @@ module Ace
|
|
|
17
17
|
# keyword arguments (args:, timeout:, env:) and returning a Hash with
|
|
18
18
|
# :stdout, :stderr, :exit_code
|
|
19
19
|
class Base
|
|
20
|
+
# Pure transport title for the requested draft state. Providers own
|
|
21
|
+
# server-specific draft encodings; callers retain their original title.
|
|
22
|
+
def self.pull_request_title(title:, draft:)
|
|
23
|
+
title
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
attr_reader :server, :timeout
|
|
21
27
|
|
|
22
28
|
# @param server [ResolvedServer] resolved server identity
|
|
@@ -80,9 +80,9 @@ module Ace
|
|
|
80
80
|
# @return [ResolvedServer] the single matching configured server
|
|
81
81
|
# @raise [AmbiguousRemoteError] when the remote matches no configured
|
|
82
82
|
# server, or more than one
|
|
83
|
-
def resolve_remote(remote_name = nil)
|
|
83
|
+
def resolve_remote(remote_name = nil, repo_root: nil)
|
|
84
84
|
remote_name ||= Ace::Git.config["remote"] || "origin"
|
|
85
|
-
remote_url = read_remote_url(remote_name)
|
|
85
|
+
remote_url = read_remote_url(remote_name, repo_root: repo_root)
|
|
86
86
|
matches = entries.map(&:server).select { |server| Atoms::ServerUrl.match?(server.url, remote_url) }
|
|
87
87
|
|
|
88
88
|
if matches.empty?
|
|
@@ -108,7 +108,7 @@ module Ace
|
|
|
108
108
|
# @raise [ConfigError] when explicit selection and default conflict
|
|
109
109
|
# @raise [UnknownServerNameError, NoDefaultServerConfiguredError,
|
|
110
110
|
# MultipleDefaultServersError, AmbiguousRemoteError] per resolution
|
|
111
|
-
def resolve_for(server_name: nil, use_default: false, remote_name: nil)
|
|
111
|
+
def resolve_for(server_name: nil, use_default: false, remote_name: nil, repo_root: nil)
|
|
112
112
|
if server_name && use_default
|
|
113
113
|
raise ConfigError,
|
|
114
114
|
"Server selection is ambiguous: an explicit server ('#{server_name}') and " \
|
|
@@ -120,7 +120,7 @@ module Ace
|
|
|
120
120
|
elsif use_default
|
|
121
121
|
resolve_default
|
|
122
122
|
else
|
|
123
|
-
resolve_remote(remote_name)
|
|
123
|
+
resolve_remote(remote_name, repo_root: repo_root)
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
@@ -173,8 +173,10 @@ module Ace
|
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
# Read a remote URL with local git only (no network, no forge CLI).
|
|
176
|
-
def read_remote_url(remote_name)
|
|
177
|
-
|
|
176
|
+
def read_remote_url(remote_name, repo_root: nil)
|
|
177
|
+
argv = ["git"]
|
|
178
|
+
argv += ["-C", repo_root] if repo_root
|
|
179
|
+
result = Atoms::CommandExecutor.execute(*argv, "remote", "get-url", remote_name.to_s)
|
|
178
180
|
result[:success] ? result[:output].to_s.strip : nil
|
|
179
181
|
end
|
|
180
182
|
|
data/lib/ace/git/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|
|
@@ -124,23 +124,24 @@ files:
|
|
|
124
124
|
- handbook/guides/version-control/ruby.md
|
|
125
125
|
- handbook/guides/version-control/rust.md
|
|
126
126
|
- handbook/guides/version-control/typescript.md
|
|
127
|
+
- handbook/skills/as-git-pr-create/SKILL.md
|
|
128
|
+
- handbook/skills/as-git-pr-update/SKILL.md
|
|
127
129
|
- handbook/skills/as-git-rebase/SKILL.md
|
|
128
130
|
- handbook/skills/as-git-reorganize-commits/SKILL.md
|
|
129
|
-
- handbook/skills/as-github-pr-create/SKILL.md
|
|
130
|
-
- handbook/skills/as-github-pr-update/SKILL.md
|
|
131
131
|
- handbook/skills/as-github-release-publish/SKILL.md
|
|
132
132
|
- handbook/templates/commit/squash.template.md
|
|
133
133
|
- handbook/templates/pr/bugfix.template.md
|
|
134
134
|
- handbook/templates/pr/default.template.md
|
|
135
135
|
- handbook/templates/pr/feature.template.md
|
|
136
|
+
- handbook/workflow-instructions/git/pr/create.wf.md
|
|
137
|
+
- handbook/workflow-instructions/git/pr/update.wf.md
|
|
136
138
|
- handbook/workflow-instructions/git/rebase.wf.md
|
|
137
139
|
- handbook/workflow-instructions/git/reorganize-commits.wf.md
|
|
138
|
-
- handbook/workflow-instructions/github/pr/create.wf.md
|
|
139
|
-
- handbook/workflow-instructions/github/pr/update.wf.md
|
|
140
140
|
- handbook/workflow-instructions/github/release-publish.wf.md
|
|
141
141
|
- lib/ace/git.rb
|
|
142
142
|
- lib/ace/git/atoms/command_executor.rb
|
|
143
143
|
- lib/ace/git/atoms/date_resolver.rb
|
|
144
|
+
- lib/ace/git/atoms/delivery_parameters.rb
|
|
144
145
|
- lib/ace/git/atoms/diff_numstat_parser.rb
|
|
145
146
|
- lib/ace/git/atoms/diff_parser.rb
|
|
146
147
|
- lib/ace/git/atoms/file_grouper.rb
|
|
@@ -154,6 +155,9 @@ files:
|
|
|
154
155
|
- lib/ace/git/atoms/repository_checker.rb
|
|
155
156
|
- lib/ace/git/atoms/repository_state_detector.rb
|
|
156
157
|
- lib/ace/git/atoms/server_url.rb
|
|
158
|
+
- lib/ace/git/atoms/service_merge_evidence.rb
|
|
159
|
+
- lib/ace/git/atoms/service_pr_evidence.rb
|
|
160
|
+
- lib/ace/git/atoms/service_pr_input.rb
|
|
157
161
|
- lib/ace/git/atoms/stale_lock_cleaner.rb
|
|
158
162
|
- lib/ace/git/atoms/status_formatter.rb
|
|
159
163
|
- lib/ace/git/atoms/task_pattern_extractor.rb
|
|
@@ -162,6 +166,7 @@ files:
|
|
|
162
166
|
- lib/ace/git/cli/commands/branch.rb
|
|
163
167
|
- lib/ace/git/cli/commands/diff.rb
|
|
164
168
|
- lib/ace/git/cli/commands/pr.rb
|
|
169
|
+
- lib/ace/git/cli/commands/service_merge.rb
|
|
165
170
|
- lib/ace/git/cli/commands/status.rb
|
|
166
171
|
- lib/ace/git/errors.rb
|
|
167
172
|
- lib/ace/git/models/diff_config.rb
|
|
@@ -177,6 +182,7 @@ files:
|
|
|
177
182
|
- lib/ace/git/organisms/issue_tracking.rb
|
|
178
183
|
- lib/ace/git/organisms/pull_request_lifecycle.rb
|
|
179
184
|
- lib/ace/git/organisms/repo_status_loader.rb
|
|
185
|
+
- lib/ace/git/organisms/service_merge.rb
|
|
180
186
|
- lib/ace/git/providers.rb
|
|
181
187
|
- lib/ace/git/providers/base.rb
|
|
182
188
|
- lib/ace/git/providers/evidence.rb
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: as-github-pr-create
|
|
3
|
-
description: Create GitHub pull request with generated description and summary
|
|
4
|
-
# bundle: wfi://github/pr/create
|
|
5
|
-
# agent: general-purpose
|
|
6
|
-
user-invocable: true
|
|
7
|
-
allowed-tools:
|
|
8
|
-
- Bash(ace-git:*)
|
|
9
|
-
- Bash(ace-bundle:*)
|
|
10
|
-
- Bash(gh:*)
|
|
11
|
-
- Read
|
|
12
|
-
argument-hint: pr-type
|
|
13
|
-
last_modified: 2026-01-10
|
|
14
|
-
source: ace-git
|
|
15
|
-
integration:
|
|
16
|
-
targets:
|
|
17
|
-
- claude
|
|
18
|
-
- codex
|
|
19
|
-
- gemini
|
|
20
|
-
- opencode
|
|
21
|
-
- pi
|
|
22
|
-
providers:
|
|
23
|
-
claude:
|
|
24
|
-
frontmatter:
|
|
25
|
-
context: fork
|
|
26
|
-
model: haiku
|
|
27
|
-
assign:
|
|
28
|
-
steps:
|
|
29
|
-
- name: create-pr
|
|
30
|
-
description: Create a pull request for the implemented changes
|
|
31
|
-
intent:
|
|
32
|
-
phrases:
|
|
33
|
-
- "create pr"
|
|
34
|
-
- "create a pr"
|
|
35
|
-
- "open pr"
|
|
36
|
-
- "open pull request"
|
|
37
|
-
prerequisites:
|
|
38
|
-
- name: work-on-task
|
|
39
|
-
strength: required
|
|
40
|
-
reason: "Must have code changes to create a PR"
|
|
41
|
-
produces: [pull-request]
|
|
42
|
-
consumes: [code-changes, commits]
|
|
43
|
-
context:
|
|
44
|
-
default: null
|
|
45
|
-
reason: "PR creation needs access to git state in main context"
|
|
46
|
-
when_to_skip:
|
|
47
|
-
- "PR already exists for this branch"
|
|
48
|
-
- "Changes don't warrant a PR (e.g., direct push allowed)"
|
|
49
|
-
effort: light
|
|
50
|
-
tags: [git, pr, publishing]
|
|
51
|
-
skill:
|
|
52
|
-
kind: workflow
|
|
53
|
-
execution:
|
|
54
|
-
workflow: wfi://github/pr/create
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Arguments
|
|
58
|
-
|
|
59
|
-
Use the skill `argument-hint` values as the explicit inputs for this skill.
|
|
60
|
-
|
|
61
|
-
## Variables
|
|
62
|
-
|
|
63
|
-
None
|
|
64
|
-
|
|
65
|
-
## Execution
|
|
66
|
-
|
|
67
|
-
- You are working in the current project.
|
|
68
|
-
- Run `ace-bundle wfi://github/pr/create` in the current project to load the workflow instructions.
|
|
69
|
-
- Read the loaded workflow and execute it end-to-end in this project.
|
|
70
|
-
- Follow the workflow as the source of truth.
|
|
71
|
-
- Do the work described by the workflow instead of only summarizing it.
|
|
72
|
-
- When the workflow requires edits, tests, or commits, perform them in this project.
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: as-github-pr-update
|
|
3
|
-
description: Update PR description based on current work
|
|
4
|
-
# bundle: wfi://github/pr/update
|
|
5
|
-
# agent: general-purpose
|
|
6
|
-
user-invocable: true
|
|
7
|
-
allowed-tools:
|
|
8
|
-
- Bash(ace-git:*)
|
|
9
|
-
- Bash(ace-bundle:*)
|
|
10
|
-
- Bash(gh:*)
|
|
11
|
-
- Read
|
|
12
|
-
- Grep
|
|
13
|
-
argument-hint: pr-number
|
|
14
|
-
last_modified: 2026-01-10
|
|
15
|
-
source: ace-git
|
|
16
|
-
integration:
|
|
17
|
-
targets:
|
|
18
|
-
- claude
|
|
19
|
-
- codex
|
|
20
|
-
- gemini
|
|
21
|
-
- opencode
|
|
22
|
-
- pi
|
|
23
|
-
assign:
|
|
24
|
-
source: wfi://github/pr/update
|
|
25
|
-
steps:
|
|
26
|
-
- name: update-pr-desc
|
|
27
|
-
description: Update PR description with implementation summary and final state
|
|
28
|
-
intent:
|
|
29
|
-
phrases:
|
|
30
|
-
- "update pr description"
|
|
31
|
-
- "update pull request description"
|
|
32
|
-
- "refresh pr description"
|
|
33
|
-
- "sync pr description"
|
|
34
|
-
tags: [git, pr, documentation]
|
|
35
|
-
skill:
|
|
36
|
-
kind: workflow
|
|
37
|
-
execution:
|
|
38
|
-
workflow: wfi://github/pr/update
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
Load and run `ace-bundle wfi://github/pr/update` in the current project, then follow the loaded workflow as the source of truth and execute it end-to-end instead of only summarizing it.
|