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 +4 -4
- data/CHANGELOG.md +35 -0
- data/docs/handbook.md +5 -0
- data/docs/usage.md +26 -2
- data/handbook/guides/version-control-system-git.g.md +10 -9
- data/handbook/skills/as-git-pr-create/SKILL.md +2 -2
- data/handbook/skills/as-git-pr-update/SKILL.md +2 -2
- data/handbook/workflow-instructions/git/pr/create.wf.md +16 -10
- data/handbook/workflow-instructions/git/pr/update.wf.md +29 -11
- data/lib/ace/git/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/git/atoms/service_merge_evidence.rb +71 -0
- data/lib/ace/git/atoms/service_pr_evidence.rb +76 -0
- data/lib/ace/git/atoms/service_pr_input.rb +56 -0
- data/lib/ace/git/cli/commands/service_merge.rb +29 -0
- data/lib/ace/git/cli.rb +10 -1
- data/lib/ace/git/organisms/pull_request_lifecycle.rb +26 -5
- data/lib/ace/git/organisms/service_merge.rb +214 -0
- data/lib/ace/git/providers/base.rb +6 -0
- data/lib/ace/git/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c03430c6a212d4bb6c51a3cb4bbf8f884051fe37b55581f9ea3d5d14048c83fa
|
|
4
|
+
data.tar.gz: e4da9c0fc2ae249ff7459973be3bfa42679395a68cb184e49446ea049aa5074f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
193
|
-
fork/canonical repository and ref provenance. The
|
|
194
|
-
|
|
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
|
|
197
|
-
|
|
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;
|
|
229
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7
|
+
# Create a draft pull request
|
|
8
8
|
|
|
9
9
|
## Goal
|
|
10
10
|
|
|
11
|
-
Create one draft PR on the selected forge and
|
|
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
|
|
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.
|
|
29
|
-
3.
|
|
30
|
-
4.
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
4
|
-
purpose: Update the exact forge-neutral PR and
|
|
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
|
|
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
|
-
|
|
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
|
|
20
|
-
2.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/ace/git/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-git
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.30.1
|
|
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-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
|