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.
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 = ServerRegistry.resolve_for(**@selection)
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
- result = Atoms::CommandExecutor.execute("git", "remote", "get-url", remote_name.to_s)
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.28.1"
5
+ VERSION = "0.30.0"
6
6
  end
7
7
  end
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.28.1
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-04 00:00:00.000000000 Z
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.