ace-git 0.30.1 → 0.32.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 +21 -1
- data/docs/usage.md +63 -0
- data/handbook/workflow-instructions/git/mirror-sync.wf.md +84 -0
- data/lib/ace/git/cli/commands/pr.rb +32 -0
- data/lib/ace/git/cli.rb +3 -1
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +17 -2
- data/lib/ace/git/providers/base.rb +10 -0
- data/lib/ace/git/providers/evidence.rb +7 -0
- data/lib/ace/git/providers/formal_approval.rb +217 -0
- data/lib/ace/git/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93ab21fbc075387d07fecb5c99fa35e9b3fca1134828578fd9d23fff9a8fdc65
|
|
4
|
+
data.tar.gz: da9b32ec8ac46c10e9c1f7e648c26ba21003f04603b80a41b15cfa3705e52da4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f89ac9e97703d6f9fd2b7d65b58a14ee4e444cb7e67981a860927e11de387ce7600fa4bfe4e724978b9a29252f1b2ec67848eb76977e95e18ba3f1e84caa26a
|
|
7
|
+
data.tar.gz: aefa646c548c7fd820e539367cbbbc79569d71b44b484e7482c0ca3ef89249172bc1fdb89338f6c3e155026236d78d785e64521d478fe8bd354a44f5f780dae6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,27 @@ All notable changes to ace-git will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.32.0] - 2026-10-30
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `wfi://git/mirror-sync` (task 8xs.t.pu0.d): the repeatable host
|
|
13
|
+
mirror-sync operation — explicit source/target/ref, both full SHAs read
|
|
14
|
+
before acting, identity no-op, fast-forward only (`--force-with-lease`
|
|
15
|
+
with the read SHA), divergence escalated for reviewed reconciliation
|
|
16
|
+
(never reset/force/ref deletion), reversed-direction detection in the
|
|
17
|
+
plan, and a mutation-free dry-run.
|
|
18
|
+
|
|
19
|
+
## [0.31.0] - 2026-10-23
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Formal reviewer-owned, exact-head provider approval and read-only approval verification through the forge-neutral review flow; comments do not substitute for approval.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
9
29
|
|
|
10
30
|
## [0.30.1] - 2026-10-17
|
|
11
31
|
|
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.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: git-mirror-sync
|
|
3
|
+
description: Repeatable host mirror-sync operation — explicit source/target/ref, both full SHAs read before acting, identity no-op, fast-forward only, divergence escalated for reviewed reconciliation
|
|
4
|
+
allowed-tools: Bash, Read
|
|
5
|
+
doc-type: workflow
|
|
6
|
+
title: Host Mirror Sync Workflow
|
|
7
|
+
purpose: Bind the host operator's forge-mirror sync as a repeatable, evidenced operation: never destructive, always checkable, with the actual source and target SHAs in the result.
|
|
8
|
+
ace-docs:
|
|
9
|
+
last-updated: '2026-10-30'
|
|
10
|
+
last-checked: '2026-10-30'
|
|
11
|
+
---
|
|
12
|
+
# Host Mirror Sync Workflow
|
|
13
|
+
|
|
14
|
+
## Goal
|
|
15
|
+
|
|
16
|
+
Synchronize an explicitly named target ref from an explicitly named source
|
|
17
|
+
ref (task 8xs.t.pu0.d — for this family: source `fg` Forgejo `cs3b/ace`,
|
|
18
|
+
target `origin` GitHub `cs3b/ace`) as a REPEATABLE host operation with a
|
|
19
|
+
checkable result. This is an ordinary Git workflow — no new script, no
|
|
20
|
+
hardcoded lab topology, no tool change; if a capability is missing it
|
|
21
|
+
belongs in ace-git's adapters.
|
|
22
|
+
|
|
23
|
+
## Instructions
|
|
24
|
+
|
|
25
|
+
Run the loaded workflow as the source of truth and execute it end-to-end
|
|
26
|
+
instead of only summarizing it.
|
|
27
|
+
|
|
28
|
+
### 1) Name the operation and read BOTH full SHAs first
|
|
29
|
+
|
|
30
|
+
Before ANY action, read and record the full SHAs of the exact source and
|
|
31
|
+
target refs. An operation without both SHAs is not runnable:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git ls-remote fg refs/heads/main # source SHA (record it)
|
|
35
|
+
git ls-remote origin refs/heads/main # target SHA (record it)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2) Classify
|
|
39
|
+
|
|
40
|
+
- **Identity** (source SHA == target SHA): the sync is a NO-OP. Report both
|
|
41
|
+
SHAs and the result `identity`; do not touch anything.
|
|
42
|
+
- **Fast-forward** (target SHA is an ancestor of source): proceed.
|
|
43
|
+
- **Divergence** (neither of the above): STOP. Divergence needs its own
|
|
44
|
+
reviewed reconciliation decision — NEVER resolve it here with a reset,
|
|
45
|
+
force push, or ref deletion. Report both SHAs and the divergence; a
|
|
46
|
+
historical SHA (e.g. a recorded closeout base like `af68f8fc1…`) is INPUT
|
|
47
|
+
EVIDENCE for the review, never a SHA to impose unconditionally on the
|
|
48
|
+
live target.
|
|
49
|
+
- **Reversed direction** (the plan shows the target ahead of the source):
|
|
50
|
+
report it explicitly — a dry-run that names source and target swapped is
|
|
51
|
+
a caught error, not a sync.
|
|
52
|
+
|
|
53
|
+
### 3) Execute (fast-forward only)
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git fetch fg main
|
|
57
|
+
git push origin <source-SHA>:refs/heads/main --force-with-lease=refs/heads/main:<target-SHA>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`--force-with-lease` with the READ target SHA is the guard: if anything
|
|
61
|
+
moved the target between the read and the push, the push is REFUSED — the
|
|
62
|
+
operation restarts from step 1. Plain `--force` is forbidden.
|
|
63
|
+
|
|
64
|
+
### 4) Verify and report
|
|
65
|
+
|
|
66
|
+
Re-read the target SHA; the result names the ref, the before/after SHAs of
|
|
67
|
+
BOTH sides, and the verification (`git ls-remote origin` equals the source
|
|
68
|
+
SHA). Repetition after success reports `identity` — the repeatable
|
|
69
|
+
operation is idempotent at the evidence level.
|
|
70
|
+
|
|
71
|
+
### 5) Dry-run
|
|
72
|
+
|
|
73
|
+
The same classification with no mutation: report source/target/ref, both
|
|
74
|
+
SHAs, the planned action (no-op / fast-forward / divergence-blocked /
|
|
75
|
+
reversed), and NOTHING else changed.
|
|
76
|
+
|
|
77
|
+
## Boundaries
|
|
78
|
+
|
|
79
|
+
- Never delete refs, never reset, never `--force` without a lease.
|
|
80
|
+
- Salvage branches, open HITL cases, and active resources on either side
|
|
81
|
+
are untouched by a mirror sync; an active resource conflicting with the
|
|
82
|
+
plan is reported as preserved with its reason.
|
|
83
|
+
- Closing a single Herdr pane remains a separate follow-up — never close a
|
|
84
|
+
whole tab as a substitute.
|
|
@@ -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
|
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.32.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-30 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-config
|
|
@@ -133,6 +133,7 @@ files:
|
|
|
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/mirror-sync.wf.md
|
|
136
137
|
- handbook/workflow-instructions/git/pr/create.wf.md
|
|
137
138
|
- handbook/workflow-instructions/git/pr/update.wf.md
|
|
138
139
|
- handbook/workflow-instructions/git/rebase.wf.md
|
|
@@ -186,6 +187,7 @@ files:
|
|
|
186
187
|
- lib/ace/git/providers.rb
|
|
187
188
|
- lib/ace/git/providers/base.rb
|
|
188
189
|
- lib/ace/git/providers/evidence.rb
|
|
190
|
+
- lib/ace/git/providers/formal_approval.rb
|
|
189
191
|
- lib/ace/git/resolved_server.rb
|
|
190
192
|
- lib/ace/git/server_registry.rb
|
|
191
193
|
- lib/ace/git/version.rb
|