ace-git 0.30.1 → 0.31.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c03430c6a212d4bb6c51a3cb4bbf8f884051fe37b55581f9ea3d5d14048c83fa
4
- data.tar.gz: e4da9c0fc2ae249ff7459973be3bfa42679395a68cb184e49446ea049aa5074f
3
+ metadata.gz: 83d5b624eab98d754e4bb770fdd1a80e00237d063bc07ba2b412e31cf0b4fa87
4
+ data.tar.gz: 1e8a556aedb3f9ba844ebe46a7b3c64adea58b979842bf6df7835742057c20c1
5
5
  SHA512:
6
- metadata.gz: 60ade30b905d89e860b44636d5520f7b5365612cbf4182692c3c06ca73a157b0d5811b4a3a8710a6b382180276ec4c8d488c4a156833af455d4baebcfd8a7797
7
- data.tar.gz: e3dbbf14e495ac5bc6389079dd62d4c04a19f59ef4bc7d8d4a04507a63af5e52bc4ed734cadea17d6824d1cde353002bd4717bc9b4270282a670d42b72340983
6
+ metadata.gz: 8c33109eb792dea197dc9e29a9f63839ed10d53c95aa7e1817d22cbbe2cbecc51123f8fe90ad420a2e908b35a6aa1bbaef35612340bcd667ad21b5736dbdad49
7
+ data.tar.gz: a2b337d9084ff29b01cd0834e2e9e9a3ef36299d0c0704bdad61480d844cd1a0165f1d4b9c482c5acf0feb29d0dc6aaa715e992dca1e2fa50d6abeb736e234f7
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.31.0] - 2026-10-23
11
+
12
+ ### Added
13
+
14
+ - Formal reviewer-owned, exact-head provider approval and read-only approval verification through the forge-neutral review flow; comments do not substitute for approval.
15
+
16
+
17
+ ### Fixed
18
+
19
+ - Serialize all local review correlations per normalized PR, validate selected/relevant review actors without rejecting unrelated historical comments, and preserve unsafe pending reviews with explicit refusal.
20
+
10
21
  ## [0.30.1] - 2026-10-17
11
22
 
12
23
  ### Changed
data/docs/usage.md CHANGED
@@ -330,3 +330,66 @@ Configure servers in `.ace/git/config.yml` under `git.servers`, then
330
330
  resolve them by name, by default, or from a git remote URL. Every failure path is
331
331
  classified (missing CLI, unauthenticated, unreachable, malformed output, object
332
332
  not found) with no silent fallbacks.
333
+
334
+
335
+ ### Formal provider approval
336
+
337
+ ```sh
338
+ ace-git pr approve 25 --expected-head FULL_SHA --body-file accepted-review.md --correlation review-123 --server forge --format json
339
+ ace-git pr approval 25 --expected-head FULL_SHA --review-id 99 --server forge --format json
340
+ ```
341
+
342
+ `approve` publishes an already accepted independent review under the configured
343
+ provider identity; it does not invoke a model or decide the review verdict. The
344
+ UTF-8 report must be nonempty, contain no reserved approval marker, and fit in
345
+ 64KiB. The PR must be open, ready and at the full lowercase expected SHA; its
346
+ author cannot approve it. GitHub and Forgejo return a verified effective formal
347
+ approval, including exact repository/PR/head, reviewer ID/login, review ID/URL,
348
+ evidence SHA256, correlation, and `created|existing`. Forgejo additionally checks
349
+ official review status. A comment or `pr ready` is a separate operation.
350
+
351
+ `approval` is read-only and verifies the current effective approval identified by
352
+ the numeric provider review ID. Integrators compare its receipt with the accepted
353
+ reviewer artifact; they do not mint a substitute review. Existing server selection
354
+ and JSON error categories apply. Role instructions confer no provider permission;
355
+ credentials remain with the deployment-configured account.
356
+
357
+ Correlation is scoped to the selected provider/server/repository/PR. Reusing it
358
+ with changed visible evidence conflicts. A same-account local per-PR flock serializes all correlations/worktrees
359
+ for concurrent publication; this is not a distributed uniqueness service. Provider
360
+ visibility may hide another account's pending reviews, and separate hosts do not
361
+ share this lock. No success is inferred for invisible evidence. Save the receipt
362
+ in the existing review/assignment artifacts; there is no new journal.
363
+
364
+ After an unknown direct-create outcome, explicitly resume the same publication
365
+ to reconcile provider state first. Mutations are never automatically retried.
366
+ Changed head, stale/dismissed/nonofficial approval, unavailable evidence or
367
+ unsupported capability withholds success rather than using a comment, another
368
+ identity, or weaker branch protection. Actual provider/account acceptance remains
369
+ a separate deployment proof from deterministic package tests.
370
+
371
+
372
+ Formal approval requires a **dedicated automation reviewer identity and one
373
+ writer per actor/repository/PR**, supplied by deployment. All local correlations
374
+ share a private per-PR lock in the system temporary directory, independently of
375
+ worktree/TMPDIR. Another host or manual client sharing that identity violates
376
+ the supported execution contract: neither provider offers a pending-review CAS
377
+ through this path. This precondition must be verified before live use; role
378
+ instructions and topology discovery do not establish it.
379
+
380
+ Both adapters directly create approval with the accepted full commit ID.
381
+ Forgejo 11+ preserves that explicit ID; its pending-submit route instead rebinds
382
+ to current HEAD and is never used. Direct create can reuse pending, so visible
383
+ unrelated pending refuses before mutation. A matching, comment-free own pending
384
+ can resume through Forgejo's verified direct-create path. Pending with comments
385
+ or an unverified safe recovery path (including current GitHub pending recovery)
386
+ is retained with an explicit refusal. Lost direct-create replies reconcile the
387
+ submitted review, without a second automatic POST. No claim of server-atomic
388
+ pending isolation or cross-host mutual exclusion is made.
389
+
390
+ GitHub actor proof uses authenticated GraphQL viewer login/databaseId under the
391
+ same configured PAT, App user or App installation token. REST `/user` is not
392
+ used for installation credentials. Unavailable/unverifiable viewer or integration
393
+ access refusal is a truthful capability/authentication failure before approval,
394
+ never a guessed bot identity, borrowed token or apparent network outage. Live
395
+ App/account/public-review proof remains separate from deterministic fixtures.
@@ -204,6 +204,38 @@ module Ace
204
204
  end
205
205
  end
206
206
 
207
+ class Approve < BaseCommand
208
+ desc "Publish a formal approval under the configured reviewer identity"
209
+ argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
210
+ option :expected_head, type: :string, required: true, desc: "Exact accepted head SHA"
211
+ option :body_file, type: :string, required: true, desc: "Accepted review report file"
212
+ option :correlation, type: :string, required: true, desc: "Stable approval publication identity"
213
+
214
+ def call(identifier:, expected_head:, body_file:, correlation:, **options)
215
+ result = lifecycle(options).approve(identifier, expected_head: expected_head,
216
+ body_file: body_file, correlation: correlation)
217
+ render_evidence(options, header: "Formal approval confirmed", **result.to_h)
218
+ rescue Ace::Git::Error, ArgumentError => e
219
+ render_failure(options, e)
220
+ raise Ace::Support::Cli::Error.new(e.message)
221
+ end
222
+ end
223
+
224
+ class Approval < BaseCommand
225
+ desc "Read and verify an effective exact-head approval without mutation"
226
+ argument :identifier, required: true, desc: "PR number, owner/repo#number, or URL"
227
+ option :expected_head, type: :string, required: true, desc: "Exact accepted head SHA"
228
+ option :review_id, type: :integer, required: true, desc: "Review ID from the approval receipt"
229
+
230
+ def call(identifier:, expected_head:, review_id:, **options)
231
+ result = lifecycle(options).approval(identifier, expected_head: expected_head, review_id: review_id)
232
+ render_evidence(options, header: "Formal approval verified", **result.to_h)
233
+ rescue Ace::Git::Error, ArgumentError => e
234
+ render_failure(options, e)
235
+ raise Ace::Support::Cli::Error.new(e.message)
236
+ end
237
+ end
238
+
207
239
  # `ace-git pr merge ID --expected-head SHA --method squash|merge|rebase`
208
240
  class Merge < BaseCommand
209
241
  desc "Merge with provider-side expected-head enforcement; no default method"
data/lib/ace/git/cli.rb CHANGED
@@ -53,7 +53,7 @@ module Ace
53
53
  ["diff", "Show filtered git diff output"],
54
54
  ["status", "Show repository status and PR context"],
55
55
  ["branch", "Show current branch information"],
56
- ["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, merge"],
56
+ ["pr", "Forge-neutral pull request lifecycle: show, create, update, ready, approve, approval, merge"],
57
57
  ["service merge", "Fixed receiver-owned exact-head merge envelope"],
58
58
  ["service create", "Fixed receiver-owned draft PR creation"],
59
59
  ["service update", "Fixed receiver-owned draft PR update"],
@@ -74,6 +74,8 @@ module Ace
74
74
  register "pr create", Commands::Pr::Create.new
75
75
  register "pr update", Commands::Pr::Update.new
76
76
  register "pr ready", Commands::Pr::Ready.new
77
+ register "pr approve", Commands::Pr::Approve.new
78
+ register "pr approval", Commands::Pr::Approval.new
77
79
  register "pr merge", Commands::Pr::Merge.new
78
80
  register "service merge", Commands::ServiceMerge.new
79
81
  %w[create update ready].each do |operation|
@@ -142,6 +142,21 @@ module Ace
142
142
  )
143
143
  end
144
144
 
145
+ def approve(identifier, expected_head:, body_file:, correlation:)
146
+ body = load_body(body_file, limit: 64 * 1024 + 1)
147
+ reference = parse_identifier(identifier)
148
+ provider = provider_for(resolve_server_for(reference))
149
+ provider.approve_pull_request(number: reference.number, expected_head: expected_head,
150
+ body: body, correlation: correlation)
151
+ end
152
+
153
+ def approval(identifier, expected_head:, review_id:)
154
+ reference = parse_identifier(identifier)
155
+ provider = provider_for(resolve_server_for(reference))
156
+ provider.pull_request_approval(number: reference.number, expected_head: expected_head,
157
+ review_id: review_id)
158
+ end
159
+
145
160
  def post_review_comment(identifier, expected_head:, body:, correlation:)
146
161
  reference = parse_identifier(identifier)
147
162
  provider = provider_for(resolve_server_for(reference))
@@ -344,11 +359,11 @@ module Ace
344
359
  Ace::Git::Providers.for(server, timeout: @timeout, runner: @runner)
345
360
  end
346
361
 
347
- def load_body(path)
362
+ def load_body(path, limit: nil)
348
363
  raise ArgumentError, "--body-file is required" unless path
349
364
  raise ArgumentError, "Body file not found: #{path}" unless File.file?(path)
350
365
 
351
- File.read(path)
366
+ File.read(path, limit)
352
367
  rescue SystemCallError => e
353
368
  raise ArgumentError, "Cannot read body file #{path}: #{e.message}"
354
369
  end
@@ -168,6 +168,16 @@ module Ace
168
168
  raise NotImplementedError, "Providers must implement #{self.class}#repository_file"
169
169
  end
170
170
 
171
+ # Formal approval is distinct from comments. Unsupported adapters refuse;
172
+ # they must never project an approval as a comment or switch identity.
173
+ def approve_pull_request(number:, expected_head:, body:, correlation:)
174
+ raise ProviderUnsupportedCapabilityError, "Formal approval is unsupported by #{self.class}"
175
+ end
176
+
177
+ def pull_request_approval(number:, expected_head:, review_id:)
178
+ raise ProviderUnsupportedCapabilityError, "Formal approval evidence is unsupported by #{self.class}"
179
+ end
180
+
171
181
  # Create a top-level PR comment. `correlation` is a stable session key
172
182
  # embedded in the body so a retry can find exactly one prior send.
173
183
  # A possible post-send transport failure raises ProviderUnknownOutcomeError.
@@ -48,6 +48,13 @@ module Ace
48
48
  :idempotency
49
49
  )
50
50
 
51
+ # Effective formal review, verified against the current provider state.
52
+ ProviderApprovalReceipt = Data.define(
53
+ :provider, :server_name, :repository_url, :pr_number, :pr_url, :head_sha,
54
+ :reviewer_id, :reviewer, :review_id, :review_url, :state, :effective, :official,
55
+ :correlation, :evidence_sha256, :idempotency
56
+ )
57
+
51
58
  # Normalized repository evidence.
52
59
  ProviderRepository = Data.define(:server_name, :full_name, :default_branch, :url)
53
60
 
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "tmpdir"
5
+ require "etc"
6
+ require_relative "evidence"
7
+ require_relative "../atoms/server_url"
8
+
9
+ module Ace
10
+ module Git
11
+ module Providers
12
+ # Shared policy for formal review evidence. Provider packages supply the
13
+ # authenticated I/O ports; no credentials or delivery state live here.
14
+ class FormalApproval
15
+ SHA = /\A[0-9a-f]{40}\z/
16
+ CORRELATION = /\A[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}\z/
17
+ MARKER = /\n\n<!-- ace-approval:([a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}) sha256:([0-9a-f]{64}) -->\z/
18
+
19
+ def initialize(provider:, request:, list:, official: false, pending_reuse: false)
20
+ @provider, @request, @list, @official = provider, request, list, official
21
+ @pending_reuse = pending_reuse
22
+ end
23
+
24
+ def approve(number:, expected_head:, body:, correlation:)
25
+ validate_input!(number, expected_head)
26
+ unless body.is_a?(String) && body.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !body.strip.empty? &&
27
+ body.bytesize <= 64 * 1024 && !body.include?("\0") && !body.include?("<!-- ace-approval:") &&
28
+ correlation.is_a?(String) && correlation.match?(CORRELATION)
29
+ raise ArgumentError, "Approval requires a nonempty UTF-8 report (maximum 64KiB) and valid correlation"
30
+ end
31
+ body = body.dup.force_encoding(Encoding::UTF_8)
32
+ digest = Digest::SHA256.hexdigest(body)
33
+ sent_body = "#{body}\n\n<!-- ace-approval:#{correlation} sha256:#{digest} -->"
34
+ # Same existing per-session flock family as comment publication,
35
+ # with a digest filename so caller text never forms a filesystem path.
36
+ # All correlations for this PR share the lock: a provider may reuse
37
+ # an actor's pending review instead of creating a distinct object.
38
+ scope = [@provider.server.provider, Atoms::ServerUrl.normalize(@provider.server.url), number]
39
+ key = Digest::SHA256.hexdigest(scope.join("\0"))
40
+ File.open(File.join(Etc.systmpdir, "ace-review-session-approval-#{Process.uid}-#{key}.lock"),
41
+ File::CREAT | File::RDWR | File::NOFOLLOW, 0600) do |lock|
42
+ unless lock.stat.file? && lock.stat.uid == Process.uid && (lock.stat.mode & 0777) == 0600
43
+ raise ConfigError, "Unsafe approval session lock"
44
+ end
45
+ lock.flock(File::LOCK_EX)
46
+ pr = current!(number, expected_head)
47
+ actor = identity!(@request.call(:get, :actor, nil))
48
+ deny_author!(pr, actor)
49
+ rows = reviews!(number)
50
+ matches = rows.select { |row| row.fetch("body").include?("<!-- ace-approval:#{correlation} ") }
51
+ raise ProviderConflictingMatchesError, "Ambiguous approval correlation" if matches.length > 1
52
+ existing = matches.first
53
+ row!(existing) if existing
54
+ if existing && (existing["body"] != sent_body || existing["commit_id"] != expected_head ||
55
+ identity!(existing["user"]) != actor)
56
+ raise ProviderConflictingMatchesError, "Approval correlation is already bound to different evidence"
57
+ end
58
+ # A provider may reuse its current pending object on direct create.
59
+ # Only an exact known, comment-free pending review is resumable, and
60
+ # only when the adapter proves explicit commit preservation on reuse.
61
+ owned_pending = rows.select do |row|
62
+ row["state"] == "PENDING" && row["user"].is_a?(Hash) && row["user"]["id"] == actor.first
63
+ end
64
+ unrelated = owned_pending.any? { |row| !existing || row["id"] != existing["id"] }
65
+ if unrelated
66
+ raise ProviderConflictingMatchesError,
67
+ "Authenticated reviewer has an unrelated pending review; no publication performed"
68
+ end
69
+ recovering = existing && existing["state"] == "PENDING"
70
+ if recovering && (!@pending_reuse || existing["comments_count"] != 0 ||
71
+ existing["dismissed"] || existing["stale"])
72
+ raise ProviderUnsupportedCapabilityError,
73
+ "This pending review cannot be safely completed with its accepted commit/content; pending is preserved"
74
+ end
75
+ mutated = false
76
+ disposition = existing ? :existing : :created
77
+ begin
78
+ current!(number, expected_head)
79
+ if !existing || recovering
80
+ pending_id = existing && existing["id"]
81
+ mutated = true
82
+ # Direct approval keeps the explicit reviewed commit. Never
83
+ # use a later pending-submit endpoint with implicit current HEAD.
84
+ existing = @request.call(:post, "pulls/#{number}/reviews",
85
+ {"body" => sent_body, "commit_id" => expected_head, "event" => "APPROVE"})
86
+ row!(existing)
87
+ if pending_id && existing["id"] != pending_id
88
+ raise ProviderUnknownOutcomeError, "Pending review identity changed during direct approval"
89
+ end
90
+ end
91
+ verifying = true
92
+ receipt(number: number, expected_head: expected_head, review_id: existing.fetch("id"),
93
+ disposition: disposition, expected_body: sent_body, actor: actor)
94
+ rescue Error => error
95
+ definitive_refusal = error.is_a?(ProviderAuthenticationError) ||
96
+ error.is_a?(ProviderUnsupportedCapabilityError)
97
+ raise unless mutated && (verifying || !definitive_refusal)
98
+
99
+ raise ProviderUnknownOutcomeError,
100
+ "Approval outcome unknown for #{@provider.server.name}/#{number}, head #{expected_head}, " \
101
+ "correlation #{correlation}: #{error.class.name.split('::').last}; reconcile before repeating"
102
+ end
103
+ end
104
+ rescue SystemCallError
105
+ raise ConfigError, "Cannot acquire the private approval session lock"
106
+ end
107
+
108
+ def read(number:, expected_head:, review_id:)
109
+ validate_input!(number, expected_head)
110
+ unless review_id.is_a?(Integer) && review_id.positive?
111
+ raise ArgumentError, "Approval review ID must be a positive integer"
112
+ end
113
+ receipt(number: number, expected_head: expected_head, review_id: review_id, disposition: :existing)
114
+ end
115
+
116
+ private
117
+
118
+ def validate_input!(number, head)
119
+ unless number.is_a?(Integer) && number.positive? && head.is_a?(String) && head.match?(SHA)
120
+ raise ArgumentError, "Approval requires a positive PR number and full lowercase head SHA"
121
+ end
122
+ end
123
+
124
+ def current!(number, head)
125
+ pr = @provider.pull_request(number: number)
126
+ unless pr.number == number && pr.server_name == @provider.server.name &&
127
+ pr.head_sha == head && pr.state == :open && pr.draft == false &&
128
+ Atoms::ServerUrl.match?(pr.base_repository_url, @provider.server.url)
129
+ raise ProviderExpectedHeadConflictError,
130
+ "Approval requires the selected open, ready PR at the expected head"
131
+ end
132
+ pr
133
+ end
134
+
135
+ def identity!(user)
136
+ unless user.is_a?(Hash) && user["id"].is_a?(Integer) && user["id"].positive? &&
137
+ user["login"].is_a?(String) && !user["login"].empty?
138
+ raise ProviderMalformedOutputError, "Malformed authenticated review identity"
139
+ end
140
+ [user["id"], user["login"]]
141
+ end
142
+
143
+ def deny_author!(pr, actor)
144
+ unless pr.author.is_a?(String) && !pr.author.empty?
145
+ raise ProviderMalformedOutputError, "PR author is unverified"
146
+ end
147
+ if pr.author.casecmp?(actor.last)
148
+ raise ProviderAuthenticationError, "PR author cannot approve own pull request"
149
+ end
150
+ end
151
+
152
+ def row!(row)
153
+ unless row.is_a?(Hash) && row["id"].is_a?(Integer) && row["id"].positive? &&
154
+ row["body"].is_a?(String) && row["state"].is_a?(String) &&
155
+ row["commit_id"].is_a?(String) && row["commit_id"].match?(SHA)
156
+ raise ProviderMalformedOutputError, "Malformed formal review evidence"
157
+ end
158
+ identity!(row["user"])
159
+ row
160
+ end
161
+
162
+ def reviews!(number)
163
+ rows = @list.call("pulls/#{number}/reviews")
164
+ raise ProviderMalformedOutputError, "Formal reviews are not a list" unless rows.is_a?(Array)
165
+ rows.each do |row|
166
+ unless row.is_a?(Hash) && row["id"].is_a?(Integer) && row["id"].positive? &&
167
+ row["body"].is_a?(String) && row["state"].is_a?(String)
168
+ raise ProviderMalformedOutputError, "Malformed formal review list entry"
169
+ end
170
+ end
171
+ if rows.map { |row| row["id"] }.uniq.length != rows.length
172
+ raise ProviderMalformedOutputError, "Duplicate formal review IDs"
173
+ end
174
+ rows
175
+ end
176
+
177
+ def receipt(number:, expected_head:, review_id:, disposition:, expected_body: nil, actor: nil)
178
+ pr = current!(number, expected_head)
179
+ rows = reviews!(number)
180
+ row = rows.find { |entry| entry["id"] == review_id }
181
+ raise ProviderIdentityMismatchError, "Formal approval was not found" unless row
182
+ row!(row)
183
+ actual_actor = identity!(row["user"])
184
+ deny_author!(pr, actual_actor)
185
+ marker = row["body"].match(MARKER)
186
+ content = marker && row["body"][0...marker.begin(0)]
187
+ latest = rows.select do |entry|
188
+ next false unless entry["user"].is_a?(Hash) && entry["user"]["id"] == actual_actor.first
189
+ next false unless %w[APPROVED CHANGES_REQUESTED REQUEST_CHANGES DISMISSED].include?(entry["state"])
190
+
191
+ row!(entry)
192
+ unless identity!(entry["user"]) == actual_actor
193
+ raise ProviderIdentityMismatchError, "Inconsistent identity in relevant formal review history"
194
+ end
195
+ true
196
+ end.max_by { |entry| entry["id"] }
197
+ unless marker && !content.strip.empty? && content.bytesize <= 64 * 1024 &&
198
+ Digest::SHA256.hexdigest(content) == marker[2] &&
199
+ row["state"] == "APPROVED" && row["commit_id"] == expected_head &&
200
+ !row["dismissed"] && !row["stale"] && (!@official || row["official"] == true) &&
201
+ (!latest || latest["id"] == row["id"]) && (!expected_body || row["body"] == expected_body) &&
202
+ (!actor || actor == actual_actor) && row["html_url"].is_a?(String) &&
203
+ row["html_url"].start_with?("#{pr.url}#")
204
+ raise ProviderIdentityMismatchError, "Review is not the effective exact-head formal approval"
205
+ end
206
+ current!(number, expected_head)
207
+ ProviderApprovalReceipt.new(provider: @provider.server.provider, server_name: @provider.server.name,
208
+ repository_url: @provider.server.url, pr_number: number, pr_url: pr.url,
209
+ head_sha: expected_head, reviewer_id: actual_actor.first, reviewer: actual_actor.last,
210
+ review_id: row["id"], review_url: row["html_url"], state: :approved, effective: true,
211
+ official: @official ? row["official"] : nil,
212
+ correlation: marker[1], evidence_sha256: marker[2], idempotency: disposition)
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.30.1"
5
+ VERSION = "0.31.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.30.1
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-20 00:00:00.000000000 Z
10
+ date: 2026-10-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config
@@ -186,6 +186,7 @@ files:
186
186
  - lib/ace/git/providers.rb
187
187
  - lib/ace/git/providers/base.rb
188
188
  - lib/ace/git/providers/evidence.rb
189
+ - lib/ace/git/providers/formal_approval.rb
189
190
  - lib/ace/git/resolved_server.rb
190
191
  - lib/ace/git/server_registry.rb
191
192
  - lib/ace/git/version.rb