ace-git 0.29.0 → 0.30.1

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: ecb7e3224f26cacff289f62d5199d90740ee07a4fbaa378649fc948133e6d05c
4
- data.tar.gz: 13ac60da2a81dda68242bc0c9f5f21f5e44f764562f24877426e873b59137bbf
3
+ metadata.gz: c03430c6a212d4bb6c51a3cb4bbf8f884051fe37b55581f9ea3d5d14048c83fa
4
+ data.tar.gz: e4da9c0fc2ae249ff7459973be3bfa42679395a68cb184e49446ea049aa5074f
5
5
  SHA512:
6
- metadata.gz: 19e1dfe5f0315b38572c0c8464852bfb7f2c25477f92f646c73df44d4b0c0f06d72c32cd6e961ef8185ed1ced37c57cd111a4980dec8f698d1eba3ad7596e8c7
7
- data.tar.gz: 16ff4c84f2f86b690116792e97cfcbe323c5dd2209d63e5148f6d4aa35fc3725a99dc84b87695cb4d00b9187816ab6652f1194ba7940caf11f6d96dd065b4fdf
6
+ metadata.gz: 60ade30b905d89e860b44636d5520f7b5365612cbf4182692c3c06ca73a157b0d5811b4a3a8710a6b382180276ec4c8d488c4a156833af455d4baebcfd8a7797
7
+ data.tar.gz: e3dbbf14e495ac5bc6389079dd62d4c04a19f59ef4bc7d8d4a04507a63af5e52bc4ed734cadea17d6824d1cde353002bd4717bc9b4270282a670d42b72340983
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.30.1] - 2026-10-17
11
+
12
+ ### Changed
13
+
14
+ - Document the ordinary local PR create/update/ready path; remove the protected worker boundary and `ace-lab service request/status` requirements from the git PR workflows and guide.
15
+ - Document explicit remote read/compare reconciliation for an interrupted PR update instead of claiming the command compares and accepts the remote description; an interrupted create adopts one exact-head draft but does not compare or rewrite remote title/body.
16
+ - Drop the `attempt-bound`/`execution-attempt` wording from the `as-git-pr-create` and `as-git-pr-update` skills; they wrap the same forge-neutral PR workflows.
17
+
18
+ ### Removed
19
+
20
+ - Remove the protected merge boundary feature test and its fixture, which exercised the removed protected authority model.
21
+
22
+ ## [0.30.0] - 2026-10-08
23
+
24
+ ### Added
25
+
26
+ - Execute protected draft create/update, ready and merge through named forge providers, binding exact repository provenance, candidate SHA, input and current campaign acceptance.
27
+
28
+ ### Fixed
29
+
30
+ - Resolve receiver provider identity from the authenticated PR URL and preserve uncertain effects without replay.
31
+
32
+ - Verify protected request, status and delivery refuse actual authority endpoint loss without replacing the original claim or dispatching another effect.
33
+
34
+ - Restore process-wide provider registrations and loader state after registry test fixtures, preserving real adapter tests across randomized single-batch execution.
35
+
36
+ - Resolve receiver-side merge provider identity from the authenticated PR URL when the prepared candidate has no remote, refusing mismatched or changed pinned servers.
37
+
38
+ - Route protected delivery through original worker merge request, canonical status and exact result consumption while preserving ordinary delivery and unresolved protected PR policy.
39
+
40
+
41
+ ### Added
42
+
43
+ - Fixed receiver-side `service merge` validates the claimed candidate/input, uses the selected neutral provider once, and publishes bounded evidence only for a verified exact-head merged outcome.
44
+
10
45
  ## [0.29.0] - 2026-10-05
11
46
 
12
47
  ### Removed
data/docs/handbook.md CHANGED
@@ -9,6 +9,11 @@ ace-docs:
9
9
 
10
10
  # ace-git Handbook Catalog
11
11
 
12
+ > **Planned (L5.5):** role rules, independent review rounds, and the
13
+ > attempt-bound forge delivery workflow are not available yet. The PR
14
+ > create/update skills below are projections of that planned workflow; the
15
+ > ordinary `ace-git pr` CLI commands remain the supported local path.
16
+
12
17
  Reference for package-owned handbook resources in `ace-git/handbook/`.
13
18
 
14
19
  ## Skills
data/docs/usage.md CHANGED
@@ -86,6 +86,7 @@ ace-git diff --raw
86
86
  ```
87
87
 
88
88
  **Options:**
89
+
89
90
  - `--format, -f` - Output format: `diff` (default), `summary`, `grouped-stats`
90
91
  - `--since, -s` - Changes since date/duration (e.g., "7d", "1 week ago")
91
92
  - `--paths, -p` - Include only these glob patterns
@@ -118,12 +119,14 @@ ace-git status --with-diff
118
119
  ```
119
120
 
120
121
  **Options:**
122
+
121
123
  - `--format, -f` - Output format: `markdown` (default), `json`
122
124
  - `--no-pr, -n` - Skip PR metadata and activity lookups (faster)
123
125
  - `--commits N` - Number of recent commits to show (default: 3 from config)
124
126
  - `--with-diff` - Include PR diff in output
125
127
 
126
128
  **Output includes:**
129
+
127
130
  - Current branch name with git status -sb output
128
131
  - Remote tracking status (ahead/behind)
129
132
  - Detected task pattern (from branch name)
@@ -153,6 +156,7 @@ ace-git branch --format json
153
156
  ```
154
157
 
155
158
  **Options:**
159
+
156
160
  - `--format, -f` - Output format: `text` (default), `json`
157
161
 
158
162
  ### `ace-git pr`
@@ -191,16 +195,24 @@ ace-git pr merge 25 --expected-head SHA --method squash
191
195
  ```
192
196
 
193
197
  **Behavior guarantees:**
198
+
194
199
  - Create is idempotent: one exact open base/head match returns the existing
200
+
195
201
  PR (`idempotency: existing`); multiple matches are a conflict.
202
+
196
203
  - `merge` requires the provider to enforce `--expected-head` atomically;
204
+
197
205
  providers without that capability return an unsupported-capability failure
198
206
  instead of racing.
207
+
199
208
  - A create request whose outcome is unknown (transport failure after send)
209
+
200
210
  reports an unknown outcome with the exact identity to reconcile; it never
201
211
  retries automatically.
212
+
202
213
  - With `--format json`, a failed operation prints the classified error as
203
- JSON on stdout (`{"error": {"category": ..., "message": ...}}` — e.g.
214
+
215
+ JSON on stdout (`{"error": {"category": ..., "message": ...}}` -- e.g.
204
216
  `expected_head_conflict`, `unsupported_capability`, `unknown_outcome`)
205
217
  before the nonzero exit; text mode explains the same failure on stderr.
206
218
 
@@ -256,9 +268,10 @@ diff:
256
268
  | `grouped_stats.layers` | Array | `["lib","test","handbook"]` | Preferred layer grouping order |
257
269
  | `grouped_stats.collapse_above` | Integer | `5` | Collapse markdown groups above this file count |
258
270
  | `grouped_stats.show_full_tree` | String | `collapsible` | Tree rendering strategy hint |
259
- | `grouped_stats.dotfile_groups` | Array | `[".ace-task",".ace"]` | Dot-directories to prioritize in grouping |
271
+ | `grouped_stats.dotfile_groups` | Array | `[".ace-tasks",".ace"]` | Dot-directories to prioritize in grouping |
260
272
 
261
273
  **Default exclude_patterns** (from `.ace-defaults/git/config.yml`):
274
+
262
275
  - Lock files: `**/*.lock`, `package-lock.json`, `yarn.lock`, `Gemfile.lock`
263
276
  - Vendored deps: `vendor/**/*`, `node_modules/**/*`
264
277
  - Build artifacts: `coverage/**/*`, `dist/**/*`, `build/**/*`, `.cache/**/*`
@@ -289,13 +302,24 @@ Note: `test/**/*` and `spec/**/*` are NOT excluded by default - test changes are
289
302
 
290
303
  ## Provider Packages
291
304
 
305
+ > **Planned (L5.5):** the forge-neutral worker/integrator role rules and the
306
+ > attempt-bound PR delivery workflow are not available yet. The old protected
307
+ > `service create|update|ready|merge` receiver handlers are not part of the
308
+ > file-based flow and are not a requirement for opening or reviewing a PR.
309
+ >
310
+ > Use the ordinary `ace-git pr` commands documented above for now; they create
311
+ > or reconcile a PR for the pushed branch.
312
+
292
313
  `ace-git` is forge-neutral. It defines the server registry (`Ace::Git::ServerRegistry`),
293
314
  the provider contract (`Ace::Git::Providers::Base`), and normalized evidence types;
294
315
  provider packages own all forge CLI behavior:
295
316
 
296
317
  - **ace-git-github** - GitHub behavior via the `gh` CLI (PR lookups, diffs, issue
318
+
297
319
  tracking operations). Registers under the `github` provider type.
320
+
298
321
  - **ace-git-forgejo** - Forgejo behavior via the `fj` CLI. Registers under the
322
+
299
323
  `forgejo` provider type.
300
324
 
301
325
  Both providers implement the same issue-tracking contract (issue lookup, comment
@@ -21,7 +21,7 @@ This project uses a **mono-repo architecture** with all packages at the reposito
21
21
 
22
22
  - **ace-*/**: Ruby gems for specific functionality (ace-git, ace-task, ace-docs, etc.)
23
23
  - **docs/**: Project documentation and architecture decisions
24
- - **.ace-task/**: Task management and release coordination
24
+ - **.ace-tasks/**: Task management and release coordination
25
25
  - **_legacy/**: Archived content from previous multi-repo structure
26
26
 
27
27
  #### Common Git Commands
@@ -183,18 +183,19 @@ git remote prune origin
183
183
  git push -u origin feature/your-feature
184
184
  ```
185
185
 
186
- 2. **Create an attempt-bound draft on the selected forge**:
186
+ 2. **Create a draft on the selected forge**:
187
187
 
188
188
  ```bash
189
189
  ace-bundle wfi://git/pr/create
190
190
  ```
191
191
 
192
- Use the managed assignment and active delivery attempt with explicit
193
- fork/canonical repository and ref provenance. The canonical operation is
194
- `ace-assign delivery --assignment ID --attempt ATTEMPT --operation create --title TITLE --body-file DESCRIPTION`.
192
+ Use the actual assignment and active attempt when the work belongs to an
193
+ assignment, with explicit fork/canonical repository and ref provenance. The
194
+ forge command is
195
+ `ace-git pr create --server NAME --head HEAD-REF --base BASE-REF --expected-head CANDIDATE-SHA --title TITLE --body-file DESCRIPTION --format json`.
195
196
  Preserve the resolved forge and exact candidate SHA. Continue through
196
- `wfi://git/pr/update` only with accepted current test and independent review
197
- receipts; CI status remains advisory. Inside ACE use source `bin/ace-*`.
197
+ `wfi://git/pr/update` only with current exact-head test and independent review
198
+ evidence; CI status remains advisory. Inside ACE use source `bin/ace-*`.
198
199
 
199
200
  3. **Link to relevant issues/tasks**:
200
201
 
@@ -225,8 +226,8 @@ git push --force-with-lease origin feature/your-feature
225
226
 
226
227
  #### Fork-Based Workflow
227
228
 
228
- Provider-specific GitHub clone example for external contributors; canonical
229
- assignment delivery above uses the explicitly selected forge and provenance:
229
+ Provider-specific GitHub clone example for external contributors; the neutral
230
+ draft/update flow above uses the explicitly selected forge and provenance:
230
231
 
231
232
  ```bash
232
233
  # Fork repository on GitHub, then:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: as-git-pr-create
3
- description: Create an attempt-bound forge-neutral pull request
3
+ description: Create a forge-neutral pull request
4
4
  user-invocable: true
5
5
  source: ace-git
6
6
  allowed-tools:
@@ -13,7 +13,7 @@ assign:
13
13
  - name: create-pr
14
14
  description: Create the exact assignment pull request
15
15
  produces: [pull-request]
16
- consumes: [code-changes, execution-attempt]
16
+ consumes: [code-changes]
17
17
  skill:
18
18
  kind: workflow
19
19
  execution:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: as-git-pr-update
3
- description: Update an attempt-bound forge-neutral pull request
3
+ description: Update a forge-neutral pull request
4
4
  user-invocable: true
5
5
  source: ace-git
6
6
  allowed-tools:
@@ -13,7 +13,7 @@ assign:
13
13
  - name: update-pr-desc
14
14
  description: Update the exact assignment pull request
15
15
  produces: [pull-request]
16
- consumes: [code-changes, execution-attempt]
16
+ consumes: [code-changes]
17
17
  skill:
18
18
  kind: workflow
19
19
  execution:
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  doc-type: workflow
3
- title: Create an attempt-bound draft pull request
3
+ title: Create a draft pull request
4
4
  purpose: Create or reconcile a forge-neutral draft for the exact candidate
5
5
  ---
6
6
 
7
- # Create an attempt-bound draft pull request
7
+ # Create a draft pull request
8
8
 
9
9
  ## Goal
10
10
 
11
- Create one draft PR on the selected forge and retain its exact source/base identity in the assignment attempt journal.
11
+ Create one draft PR on the selected forge and record its exact source/base identity in the assignment report.
12
12
 
13
13
  ## Prerequisites
14
14
 
15
- A reviewed task, managed assignment, active delivery attempt, committed candidate, and pushed source ref. Credentials permit transport only; they grant no integration authority.
15
+ A reviewed task or explicit implementation scope, a committed candidate, and a pushed source ref. Credentials permit transport only; they grant no integration permission.
16
16
 
17
17
  ## Inputs
18
18
 
@@ -25,12 +25,18 @@ Use `forge_server` or `forge_default: true`, mutually exclusive. Neither means r
25
25
  ## Steps
26
26
 
27
27
  1. Read the task and actual committed diff; prepare a concrete title and description explaining behavior and executed validation. Save the description as a local file.
28
- 2. Use the existing managed assignment and active delivery attempt IDs. If needed, start the delivery step with `ace-assign attempt start --assignment ID --step STEP --project PROJECT` (numeric step scope). The execution boundary supplies actor identity; do not impersonate a reviewer or integrator.
29
- 3. Supply the assignment source's `delivery` mapping, or save the explicit input JSON and pass `--parameters FILE`. For task-mode creation, `ace-assign create --task TASK --delivery-parameters FILE` retains these inputs in the generated job.
30
- 4. Execute `ace-assign delivery --assignment ID --attempt ATTEMPT --operation create --title TITLE --body-file DESCRIPTION`. Add `--parameters FILE` only when supplying an explicit file. In this repository invoke the source `bin/ace-assign` entrypoint.
31
- 5. Read the structured result. Verify the retained server/provider/base and head repository/ref, draft PR number/URL, candidate SHA, base_head, evidence_git_ref, and separate journal_commit. The pushed ref must equal the candidate; journal commits never advance it.
32
- 6. On interruption or unknown create outcome, repeat the delivery command to perform exact-selector reconciliation reads. One matching draft at the exact head is adopted without a second create. Zero remains unresolved; multiple matches conflict. Never bypass uncertainty with a direct provider command.
28
+ 2. When this work belongs to an assignment, use its actual ID and record the PR identity in the assignment report. The execution boundary supplies actor identity; do not impersonate a reviewer or integrator.
29
+ 3. Verify the pushed source ref resolves to the candidate SHA. The pushed ref must equal the candidate.
30
+ 4. Create the draft with the configured forge command; drafts are the default:
31
+
32
+ ```sh
33
+ ace-git pr create --server NAME --head HEAD-REF --head-repo HEAD-REPOSITORY-URL --base BASE-REF --expected-head CANDIDATE-SHA --title TITLE --body-file DESCRIPTION --format json
34
+ ```
35
+
36
+ Use the retained default/remote selection instead of `--server NAME` when applicable. In this repository invoke the source `bin/ace-git` entrypoint.
37
+ 5. Read the structured result. Verify the retained server/provider/base and head repository/ref, draft PR number/URL, candidate SHA and base head. Record the PR identity and observed candidate head in the assignment report.
38
+ 6. On interruption or an unknown create outcome, repeat the same command with the same selectors: the implementation re-reads open PRs and adopts one matching draft at the exact head without a second create. Adoption does not compare or rewrite the remote title/body, so verify the remote content explicitly before relying on it, and use `wfi://git/pr/update` if the description differs. Zero matches remain unresolved; multiple matches conflict. Never bypass an uncertain read with a different provider command.
33
39
 
34
40
  ## Success criteria
35
41
 
36
- The journal records exact identity, intent before the write, and proven draft outcome afterward. A report or shell exit alone proves nothing. Continue with actual tests and independent current-head review; keep the PR draft until those receipts are accepted. CI is visible and advisory. Publication is a separate authorized operation and is not required for delivery.
42
+ The assignment report records exact identity and the proven draft outcome. A report or shell exit alone proves nothing. Continue with actual tests and independent current-head review; keep the PR draft until those results are accepted. CI is visible and advisory. Publication is a separate authorized operation and is not required for delivery.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  doc-type: workflow
3
- title: Update an attempt-bound pull request
4
- purpose: Update the exact forge-neutral PR and consume current delivery evidence
3
+ title: Update a pull request
4
+ purpose: Update the exact forge-neutral PR and record current delivery evidence
5
5
  ---
6
6
 
7
- # Update an attempt-bound pull request
7
+ # Update a pull request
8
8
 
9
9
  ## Goal
10
10
 
@@ -12,17 +12,35 @@ Update the recorded PR description for the current candidate and preserve provid
12
12
 
13
13
  ## Prerequisites
14
14
 
15
- A managed assignment with a recorded PR and active delivery attempt. Reuse the original forge selection and complete fork/canonical provenance; changed configuration or identity is a conflict. A new committed candidate, including task-only changes, requires renewed tests and independent review.
15
+ An assignment with a recorded PR, or an explicit local PR scope. Reuse the original forge selection and complete fork/canonical provenance; changed configuration or identity is a conflict. A new committed candidate, including task-only changes, requires renewed tests and independent review.
16
16
 
17
17
  ## Steps
18
18
 
19
- 1. Inspect the committed changes and accepted test/review evidence. Prepare the final description with the concrete behavior, executed test results, independent verdict, and actual limitations. Save it as a local file; do not put credentials or terminal dumps in the attempt journal.
20
- 2. Execute `ace-assign delivery --assignment ID --attempt ATTEMPT --operation update --body-file DESCRIPTION`. Supply `--parameters FILE` only if the assignment source lacks the original `delivery` mapping. In this repository use `bin/ace-assign`.
21
- 3. On interrupted update, repeat the delivery command. It reads the exact remote title/body and compares the requested field digest before accepting; an unmatched or unreadable outcome remains unresolved. Never blindly resend a mutation.
22
- 4. To mark ready, supply two JSON reference files, each containing only `attempt_id` and `receipt_digest`, from coordinator-accepted current-head test and independent review attempts in this assignment/project. Execute `ace-assign delivery --assignment ID --attempt ATTEMPT --operation ready --tests TEST-REF --review REVIEW-REF`. Missing, fabricated, stale, or self-approved evidence cannot authorize readiness. Red CI alone does not veto valid executed tests and review.
23
- 5. Merge is an integrator/service operation under the existing exact qjx authorization policy. The authorized executor calls `ace-git pr merge IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --method METHOD --format json` with the retained selection (or `--default-server`, otherwise remote resolution). Both expected SHA and the authorized squash/merge/rebase method are required; there is no default method. It does not recursively request its own future receipt. The worker consumes the completed, verified service receipt using `ace-assign delivery --assignment ID --attempt ATTEMPT --operation merge --tests TEST-REF --review REVIEW-REF --service-request REQUEST`. The request must bind this assignment, attempt, project, candidate and PR URL. No completed receipt means a routed blocker; credentials or caller assertions grant no authority.
24
- 6. Unknown merge remains pending service reconciliation and is never automatically retried. Inspect the authoritative selected remote and verified executor evidence. Changed head or scope blocks acceptance. Merge does not authorize publication, deployment or synchronization.
19
+ 1. Inspect the committed changes and accepted test/review evidence. Prepare the final description with the concrete behavior, executed test results, independent verdict, and actual limitations. Save it as a local file; do not put credentials or terminal dumps in the assignment report.
20
+ 2. Update the PR with the configured forge command:
21
+
22
+ ```sh
23
+ ace-git pr update IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --body-file DESCRIPTION --format json
24
+ ```
25
+
26
+ Use the retained default/remote selection instead of `--server NAME` when applicable. In this repository invoke the source `bin/ace-git` entrypoint.
27
+ 3. On an interrupted update, do not assume the command reconciles for you: `ace-git pr update` verifies the head and rewrites the title/body, so a retry is a new write. Explicitly read the exact remote title/body (for example `ace-git pr show IDENTIFIER --format json`), compare it with the prepared description; if identical, treat the update as done, otherwise re-run the command. An unreadable or uncertain read stays unresolved. Never blindly resend a mutation.
28
+ 4. Mark ready only after tests and independent review pass on the exact head. Record the executed tests and independent review evidence for that head, then run:
29
+
30
+ ```sh
31
+ ace-git pr ready IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --format json
32
+ ```
33
+
34
+ Missing, stale, or self-approved evidence cannot authorize readiness. Red CI alone does not veto valid executed tests and review.
35
+ 5. Merge is a separate authorized operation for the exact candidate. The authorized executor runs:
36
+
37
+ ```sh
38
+ ace-git pr merge IDENTIFIER --server NAME --expected-head CANDIDATE-SHA --method METHOD --format json
39
+ ```
40
+
41
+ with the retained selection (or `--default-server`, otherwise remote resolution). Both the expected SHA and the authorized squash/merge/rebase method are required; there is no default method. Credentials or caller assertions grant no permission.
42
+ 6. An unknown merge remains an unresolved effect. Inspect the authoritative selected remote and the actual executor evidence; never retry blindly. Changed head or scope blocks acceptance. Merge does not authorize publication, deployment or synchronization.
25
43
 
26
44
  ## Success criteria
27
45
 
28
- The exact remote outcome and existing qjl evidence agree. Real artifacts, current executed tests, independent verdict and required authorization gate completion. Preserve unresolved history and distinct candidate_head/journal_commit. qkb.1 supplies installed charter/proposal/service adoption; this workflow does not fabricate that acceptance.
46
+ The exact remote outcome and the locally recorded evidence agree. Real artifacts, current executed tests and an independent verdict gate completion. Preserve unresolved history and distinguish the candidate head from evidence commits.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ require_relative "server_url"
3
+
4
+ module Ace
5
+ module Git
6
+ module Atoms
7
+ # Explicit non-secret remote delivery inputs; local recipes need not call this validator.
8
+ module DeliveryParameters
9
+ PROVENANCE_FIELDS = %w[mode head_repository_url head_ref base_repository_url base_ref].freeze
10
+ def self.validate(input)
11
+ raise ArgumentError, "Delivery parameters must be a mapping" unless input.is_a?(Hash)
12
+ unless (input.keys - %w[forge_server forge_default pr_provenance]).empty?
13
+ raise ArgumentError, "Unexpected delivery parameter fields"
14
+ end
15
+ default = input.fetch("forge_default", false)
16
+ raise ArgumentError, "forge_default must be boolean" unless [true, false].include?(default)
17
+ server = input["forge_server"]
18
+ if server && (!server.is_a?(String) || !server.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/))
19
+ raise ArgumentError, "Invalid forge_server"
20
+ end
21
+ raise ArgumentError, "forge_server and forge_default are mutually exclusive" if server && default
22
+ provenance = input["pr_provenance"]
23
+ unless provenance.is_a?(Hash) && provenance.keys.sort == PROVENANCE_FIELDS.sort &&
24
+ %w[fork canonical].include?(provenance["mode"])
25
+ raise ArgumentError, "Explicit complete fork or canonical PR provenance is required"
26
+ end
27
+ %w[head_repository_url base_repository_url].each { |key| validate_url!(provenance[key]) }
28
+ %w[head_ref base_ref].each do |key|
29
+ ref = provenance[key]
30
+ unless ref.is_a?(String) && ref.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_\/.\-]{0,255}\z/) &&
31
+ !ref.include?("..") && !ref.include?("//") && !ref.end_with?("/", ".", ".lock")
32
+ raise ArgumentError, "Invalid PR ref"
33
+ end
34
+ end
35
+ if provenance["mode"] == "canonical" && !Ace::Git::Atoms::ServerUrl.match?(
36
+ provenance["head_repository_url"], provenance["base_repository_url"])
37
+ raise ArgumentError, "Canonical provenance requires matching head and base repositories"
38
+ end
39
+ {"forge_server" => server, "forge_default" => default,
40
+ "pr_provenance" => provenance.transform_values(&:dup)}
41
+ end
42
+
43
+ def self.validate_url!(url)
44
+ unless url.is_a?(String) && url.length <= 512 &&
45
+ (url.match?(%r{\Ahttps?://[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}) ||
46
+ url.match?(%r{\Agit@[a-zA-Z0-9.-]+:[a-zA-Z0-9_./-]+\z}) ||
47
+ url.match?(%r{\Assh://git@[a-zA-Z0-9.:-]+/[a-zA-Z0-9_./-]+\z}))
48
+ raise ArgumentError, "PR repository URL must contain a repository and no credentials or query"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "delivery_parameters"
4
+ require_relative "pr_reference"
5
+ require_relative "../providers/evidence"
6
+
7
+ module Ace
8
+ module Git
9
+ module Atoms
10
+ # Parse executor-owned evidence; authority supplies the original binding
11
+ # and separately checks the reconstructed input's canonical digest.
12
+ module ServiceMergeEvidence
13
+ MAX_BYTES = 64 * 1024
14
+ def self.validate(bytes, request_id:, input_digest:, target:, head:)
15
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_BYTES) &&
16
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
17
+ raise ArgumentError, "merge evidence is invalid or oversized"
18
+ end
19
+ marker, payload = bytes.split("\n", 2)
20
+ unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
21
+ raise ArgumentError, "merge evidence original marker differs"
22
+ end
23
+ value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
24
+ allow_duplicate_key: false, allow_comments: false, allow_nan: false)
25
+ closed!(value, %w[server delivery method candidate_head receipt])
26
+ delivery = DeliveryParameters.validate(value.fetch("delivery"))
27
+ unless value["delivery"] == delivery && value["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] &&
28
+ %w[squash merge rebase].include?(value["method"]) && value["candidate_head"] == head
29
+ raise ArgumentError, "merge evidence selection/method/head differs"
30
+ end
31
+ server, receipt = value.values_at("server", "receipt")
32
+ closed!(server, %w[name provider url])
33
+ closed!(receipt, %w[server_name operation pull_request idempotency])
34
+ unless server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
35
+ server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
36
+ receipt["server_name"] == server["name"] && receipt["operation"] == "merge"
37
+ raise ArgumentError, "merge evidence provider receipt differs"
38
+ end
39
+ DeliveryParameters.validate_url!(server["url"])
40
+ pr = receipt.fetch("pull_request")
41
+ closed!(pr, ProviderPullRequest.members.map(&:to_s))
42
+ provenance = delivery.fetch("pr_provenance")
43
+ reference = target.is_a?(Hash) && PrReference.parse(target["resource"])
44
+ unless reference && reference.repository_url && pr["number"].is_a?(Integer) && pr["number"].positive? &&
45
+ pr["number"] == reference.number && pr["url"] == target["resource"] && pr["server_name"] == server["name"] &&
46
+ pr["head_sha"] == head && head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) &&
47
+ pr["state"] == "merged" && [true, false].include?(pr["draft"]) &&
48
+ pr.except("number", "draft").values.all? { |value| value.nil? || value.is_a?(String) } &&
49
+ pr["merge_commit_sha"].is_a?(String) && pr["merge_commit_sha"].match?(/\A[0-9a-f]{40}\z/) &&
50
+ pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
51
+ ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
52
+ ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
53
+ ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
54
+ ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
55
+ (!delivery["forge_server"] || delivery["forge_server"] == server["name"])
56
+ raise ArgumentError, "merge evidence PR identity or outcome differs"
57
+ end
58
+ {input: {"target" => target, "delivery" => delivery, "method" => value.fetch("method")},
59
+ pr: pr, server: server}
60
+ rescue JSON::ParserError, KeyError, TypeError
61
+ raise ArgumentError, "merge evidence is malformed"
62
+ end
63
+
64
+ def self.closed!(value, fields)
65
+ raise ArgumentError, "merge evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
66
+ end
67
+ private_class_method :closed!
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "service_pr_input"
4
+ require_relative "../providers"
5
+ require_relative "../server_registry"
6
+
7
+ module Ace
8
+ module Git
9
+ module Atoms
10
+ module ServicePrEvidence
11
+ def self.validate(bytes, operation:, request_id:, input_digest:, target:, head:)
12
+ unless ServicePrInput::DRAFT_OPERATIONS.include?(operation) || operation == "ready"
13
+ raise ArgumentError, "unsupported PR evidence operation"
14
+ end
15
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 64 * 1024) &&
16
+ bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !bytes.include?("\0")
17
+ raise ArgumentError, "PR evidence is invalid or oversized"
18
+ end
19
+ marker, payload = bytes.split("\n", 2)
20
+ unless marker == "ace-service-attestation request:#{request_id} input:#{input_digest} outcome:succeeded"
21
+ raise ArgumentError, "PR evidence original marker differs"
22
+ end
23
+ value = JSON.parse(payload.to_s, max_nesting: 16, create_additions: false,
24
+ allow_duplicate_key: false, allow_comments: false, allow_nan: false)
25
+ closed!(value, %w[server operation input candidate_head receipt])
26
+ input = ServicePrInput.validate(value.fetch("input"), operation: operation)
27
+ unless value["operation"] == operation && value["candidate_head"] == head && input["target"] == target
28
+ raise ArgumentError, "PR evidence operation/input/head differs"
29
+ end
30
+ server, receipt = value.values_at("server", "receipt")
31
+ closed!(server, %w[name provider url])
32
+ closed!(receipt, %w[server_name operation pull_request idempotency])
33
+ pr = receipt.fetch("pull_request")
34
+ closed!(pr, ProviderPullRequest.members.map(&:to_s))
35
+ delivery = input.fetch("delivery")
36
+ provenance = delivery.fetch("pr_provenance")
37
+ reference = PrReference.parse(pr["url"])
38
+ unless server["name"].is_a?(String) && server["provider"].is_a?(String) && server["url"].is_a?(String)
39
+ raise ArgumentError, "PR evidence provider identity is malformed"
40
+ end
41
+ selected = ResolvedServer.new(name: server["name"], provider: server["provider"], url: server["url"])
42
+ expected_title = Providers.for(selected).class.pull_request_title(title: input["title"], draft: true) if ServicePrInput::DRAFT_OPERATIONS.include?(operation)
43
+ unless reference && reference.repository_url && pr["number"] == reference.number &&
44
+ pr["number"].is_a?(Integer) && pr["number"].positive? &&
45
+ server["name"].is_a?(String) && server["name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
46
+ server["provider"].is_a?(String) && server["provider"].match?(/\A[a-zA-Z0-9_-]{1,128}\z/) &&
47
+ receipt["server_name"] == server["name"] && receipt["operation"] == operation &&
48
+ pr["server_name"] == server["name"] && pr["head_sha"] == head &&
49
+ head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/) && pr["state"] == "open" &&
50
+ pr["draft"] == ServicePrInput::DRAFT_OPERATIONS.include?(operation) &&
51
+ pr.except("number", "draft").values.all? { |item| item.nil? || item.is_a?(String) } &&
52
+ (operation == "create" ? %w[created existing].include?(receipt["idempotency"]) : receipt["idempotency"].nil?) &&
53
+ pr["head_ref"] == provenance["head_ref"] && pr["base_ref"] == provenance["base_ref"] &&
54
+ ServerUrl.match?(reference.repository_url, provenance["base_repository_url"]) &&
55
+ ServerUrl.match?(server["url"], provenance["base_repository_url"]) &&
56
+ ServerUrl.match?(pr["head_repository_url"], provenance["head_repository_url"]) &&
57
+ ServerUrl.match?(pr["base_repository_url"], provenance["base_repository_url"]) &&
58
+ (!delivery["forge_server"] || delivery["forge_server"] == server["name"]) &&
59
+ (operation == "create" || pr["url"] == target["resource"]) &&
60
+ (operation == "ready" || pr["title"] == expected_title && pr["body"] == input["body"])
61
+ raise ArgumentError, "PR evidence selected identity or outcome differs"
62
+ end
63
+ DeliveryParameters.validate_url!(server["url"])
64
+ {input: input, pr: pr, server: server}
65
+ rescue JSON::ParserError, KeyError, TypeError, Ace::Git::Error
66
+ raise ArgumentError, "PR evidence is malformed"
67
+ end
68
+
69
+ def self.closed!(value, fields)
70
+ raise ArgumentError, "PR evidence fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
71
+ end
72
+ private_class_method :closed!
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require_relative "delivery_parameters"
3
+ require_relative "pr_reference"
4
+
5
+ module Ace
6
+ module Git
7
+ module Atoms
8
+ # Fixed receiver input vocabulary; draft is source policy, not a flag.
9
+ module ServicePrInput
10
+ OPERATIONS = %w[create update ready merge].freeze
11
+ DRAFT_OPERATIONS = %w[create update].freeze
12
+
13
+ def self.validate(input, operation:)
14
+ fields = case operation
15
+ when "create", "update" then %w[target delivery title body]
16
+ when "ready" then %w[target delivery]
17
+ when "merge" then %w[target delivery method]
18
+ else raise ArgumentError, "unsupported protected PR operation"
19
+ end
20
+ unless input.is_a?(Hash) && input.keys.sort == fields.sort &&
21
+ input["target"].is_a?(Hash) && input["target"].keys.sort == %w[artifact_digest resource]
22
+ raise ArgumentError, "protected PR input fields differ"
23
+ end
24
+ parameters = DeliveryParameters.validate(input.fetch("delivery"))
25
+ unless input["delivery"].keys.sort == %w[forge_default forge_server pr_provenance] && input["delivery"] == parameters
26
+ raise ArgumentError, "protected PR selection must be normalized"
27
+ end
28
+ resource = input.fetch("target").fetch("resource")
29
+ if operation == "create"
30
+ DeliveryParameters.validate_url!(resource)
31
+ unless ServerUrl.match?(resource, parameters.fetch("pr_provenance").fetch("base_repository_url"))
32
+ raise ArgumentError, "draft create target differs from base repository"
33
+ end
34
+ else
35
+ reference = resource.is_a?(String) && PrReference.parse(resource)
36
+ raise ArgumentError, "protected PR target requires an exact URL" unless reference && reference.repository_url
37
+ end
38
+ digest = input.fetch("target").fetch("artifact_digest")
39
+ unless digest.nil? || digest.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(digest)
40
+ raise ArgumentError, "protected PR target artifact digest differs"
41
+ end
42
+ if DRAFT_OPERATIONS.include?(operation)
43
+ unless input["title"].is_a?(String) && input["title"].bytesize.between?(1, 1024) &&
44
+ input["body"].is_a?(String) && input["body"].bytesize <= 32 * 1024 &&
45
+ [input["title"], input["body"]].all? { |text| text.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !text.include?("\0") }
46
+ raise ArgumentError, "draft PR requires bounded UTF-8 title/body"
47
+ end
48
+ elsif operation == "merge" && !%w[squash merge rebase].include?(input["method"])
49
+ raise ArgumentError, "protected merge requires an exact method"
50
+ end
51
+ input
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../organisms/service_merge"
3
+
4
+ module Ace
5
+ module Git
6
+ module CLI
7
+ module Commands
8
+ class ServiceMerge < Ace::Support::Cli::Command
9
+ desc "Execute a fixed receiver-owned exact-head PR envelope"
10
+
11
+ def initialize(input: nil, producer: nil, root: nil, operation: "merge")
12
+ @input, @producer, @root, @operation = input, producer, root, operation
13
+ end
14
+
15
+ def call(**options)
16
+ raise ArgumentError, "fixed PR entry accepts no options" unless options.empty?
17
+ bytes = (@input || $stdin).read(Organisms::ServiceMerge::MAX_INPUT + 1)
18
+ result = (@producer || Organisms::ServiceMerge.new(operation: @operation)).call(bytes: bytes, root: @root || Dir.pwd)
19
+ puts JSON.generate(result)
20
+ rescue Ace::Git::Error, ArgumentError, SystemCallError, IOError => error
21
+ # No stdout receipt on an unverified/uncertain effect. The existing
22
+ # receiver retains its original dispatch instead of retrying it.
23
+ raise Ace::Support::Cli::Error, error.message
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
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",
@@ -196,6 +196,11 @@ module Ace
196
196
  )
197
197
  end
198
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
+
199
204
  # Update title/body after exact-head verification.
200
205
  #
201
206
  # @return [ProviderMutationReceipt]
@@ -237,8 +242,23 @@ module Ace
237
242
  end
238
243
 
239
244
  # Freeze the selected provider identity for an assignment attempt.
240
- def resolved_identity
241
- resolve_selected_server.to_h.transform_keys(&:to_s).transform_values(&:to_s)
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)
242
262
  end
243
263
 
244
264
  # Read-only reconciliation of a possibly completed create. Absence
@@ -291,10 +311,13 @@ module Ace
291
311
  ServerRegistry.servers_for_owner_repo(reference.owner_repo)
292
312
  end
293
313
 
314
+ resolve_matching_server(candidates, reference.repository_url || reference.owner_repo)
315
+ end
316
+
317
+ def resolve_matching_server(candidates, identity)
294
318
  if @selection[:server_name] || @selection[:use_default]
295
319
  selected = resolve_selected_server
296
320
  unless candidates.any? { |candidate| candidate.name == selected.name }
297
- identity = reference.repository_url || reference.owner_repo
298
321
  raise ProviderIdentityMismatchError,
299
322
  "PR identifier (#{identity}) does not match the selected server '#{selected.name}'"
300
323
  end
@@ -303,12 +326,10 @@ module Ace
303
326
  elsif candidates.size == 1
304
327
  candidates.first
305
328
  elsif candidates.empty?
306
- identity = reference.repository_url || reference.owner_repo
307
329
  raise AmbiguousRemoteError,
308
330
  "PR repository '#{identity}' matches no configured server (configured: " \
309
331
  "#{ServerRegistry.servers.map(&:name).join(", ")})"
310
332
  else
311
- identity = reference.repository_url || reference.owner_repo
312
333
  raise AmbiguousRemoteError,
313
334
  "PR repository '#{identity}' matches multiple configured servers " \
314
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Git
5
- VERSION = "0.29.0"
5
+ VERSION = "0.30.1"
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.29.0
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-05 00:00:00.000000000 Z
10
+ date: 2026-10-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-config
@@ -141,6 +141,7 @@ files:
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