@oscharko-dev/keiko-tools 0.2.8 → 0.2.9
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/git-commit-intent-node.d.ts +7 -0
- package/dist/git-commit-intent-node.d.ts.map +1 -0
- package/dist/git-commit-intent-node.js +42 -0
- package/dist/git-merge-gateway.d.ts +95 -0
- package/dist/git-merge-gateway.d.ts.map +1 -0
- package/dist/git-merge-gateway.js +536 -0
- package/dist/git-merge-node.d.ts +17 -0
- package/dist/git-merge-node.d.ts.map +1 -0
- package/dist/git-merge-node.js +243 -0
- package/dist/git-mutation-adapter.d.ts +51 -0
- package/dist/git-mutation-adapter.d.ts.map +1 -0
- package/dist/git-mutation-adapter.js +207 -0
- package/dist/git-mutation-evidence.d.ts +23 -0
- package/dist/git-mutation-evidence.d.ts.map +1 -0
- package/dist/git-mutation-evidence.js +283 -0
- package/dist/git-mutation-node.d.ts +22 -0
- package/dist/git-mutation-node.d.ts.map +1 -0
- package/dist/git-mutation-node.js +145 -0
- package/dist/git-mutation-orchestrator.d.ts +92 -0
- package/dist/git-mutation-orchestrator.d.ts.map +1 -0
- package/dist/git-mutation-orchestrator.js +321 -0
- package/dist/git-mutation-preflight.d.ts +35 -0
- package/dist/git-mutation-preflight.d.ts.map +1 -0
- package/dist/git-mutation-preflight.js +226 -0
- package/dist/git-mutation-taxonomy.d.ts +15 -0
- package/dist/git-mutation-taxonomy.d.ts.map +1 -0
- package/dist/git-mutation-taxonomy.js +102 -0
- package/dist/git-pr-gateway.d.ts +101 -0
- package/dist/git-pr-gateway.d.ts.map +1 -0
- package/dist/git-pr-gateway.js +487 -0
- package/dist/git-pr-node.d.ts +17 -0
- package/dist/git-pr-node.d.ts.map +1 -0
- package/dist/git-pr-node.js +173 -0
- package/dist/git-publish-gateway.d.ts +72 -0
- package/dist/git-publish-gateway.d.ts.map +1 -0
- package/dist/git-publish-gateway.js +423 -0
- package/dist/git-publish-node.d.ts +17 -0
- package/dist/git-publish-node.d.ts.map +1 -0
- package/dist/git-publish-node.js +107 -0
- package/dist/git-worktree-adapter.d.ts +78 -0
- package/dist/git-worktree-adapter.d.ts.map +1 -0
- package/dist/git-worktree-adapter.js +300 -0
- package/dist/git-worktree-snapshot-node.d.ts +31 -0
- package/dist/git-worktree-snapshot-node.d.ts.map +1 -0
- package/dist/git-worktree-snapshot-node.js +189 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -0
- package/package.json +9 -5
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
// The governed merge gateway (Issue #478, Epic #470, ADR-0065) — AC1–AC5.
|
|
2
|
+
//
|
|
3
|
+
// This is the merge-orchestration authority the #472 kernel and the #477 PR gateway both deferred: a
|
|
4
|
+
// THIRD parallel execution authority, never an extension of the publish gateway or the PR gateway. A
|
|
5
|
+
// merge shells a distinct `gh api` REST call (`PUT /repos/{owner}/{repo}/pulls/{number}/merge`) with a
|
|
6
|
+
// distinct failure taxonomy (405 not-mergeable, 409 head-modified, 422 required-status-checks), so this
|
|
7
|
+
// gateway has its OWN narrow two-method adapter port (read readiness + execute merge), its OWN dedicated
|
|
8
|
+
// `gh api` allowlist, and its OWN GitHub merge-error classifier. It NEVER touches the local mutation
|
|
9
|
+
// adapter, the publish gateway, or the PR gateway.
|
|
10
|
+
//
|
|
11
|
+
// Like the kernel and the other gateways, it is deterministic given its injected dependencies. It
|
|
12
|
+
// performs no IO of its own: the actual `gh api` calls live behind the injected GitMergeAdapter
|
|
13
|
+
// (implemented by git-merge-node.ts on the Node subpath). It reuses the kernel's pure machinery
|
|
14
|
+
// unchanged: evaluateGitPreflight (merge maps to preflightNoLocalPrecondition), the
|
|
15
|
+
// GitMutationLifecycleResult shape (so the #474 evidence builder consumes the merge lifecycle with no
|
|
16
|
+
// change), and the failure taxonomy.
|
|
17
|
+
//
|
|
18
|
+
// The merge governs three gates in order: (1) preflight, (2) policy + final approval (the default pack
|
|
19
|
+
// makes merge approval-gated), and (3) the READINESS gate — it reads the provider's content-free
|
|
20
|
+
// merge-readiness facts and refuses to call the merge endpoint when a blocking blocker is present, in
|
|
21
|
+
// addition to the provider's own server-side enforcement (AC1).
|
|
22
|
+
import { deriveEligibleMergeStrategies, evaluateGitPolicy, GIT_DELIVERY_MERGE_STRATEGY_HINTS, GIT_DELIVERY_SCHEMA_VERSION, gitDeliveryBranchNameMatchesAny, gitDeliveryRiskClassWithinCeiling, gitMergeReadinessFor, gitMergeRejectionFor, gitMergeRejectionToErrorCode, } from "@oscharko-dev/keiko-contracts";
|
|
23
|
+
import { evaluateGitPreflight } from "./git-mutation-preflight.js";
|
|
24
|
+
import { gitMutationCategoryForExecutionResult } from "./git-mutation-taxonomy.js";
|
|
25
|
+
// ─── Dedicated gh-api allowlist ──────────────────────────────────────────────────────────────────────
|
|
26
|
+
// A closed allowlist permitting ONLY the `api` subcommand of `gh`. Structurally separate from the git
|
|
27
|
+
// mutation rules, the publish rules, and the PR rules. The specific REST endpoints/methods (the merge
|
|
28
|
+
// PUT, the readiness GETs, and the guarded branch DELETE) are enforced by the pure argv builders below;
|
|
29
|
+
// the allowlist denies any flag that could read an arbitrary file (`--input`) or paginate.
|
|
30
|
+
export const GIT_MERGE_ALLOWED_SUBCOMMANDS = Object.freeze(["api"]);
|
|
31
|
+
export const GIT_MERGE_COMMAND_RULES = Object.freeze([
|
|
32
|
+
{
|
|
33
|
+
executable: "gh",
|
|
34
|
+
allowedSubcommands: GIT_MERGE_ALLOWED_SUBCOMMANDS,
|
|
35
|
+
valueFlags: Object.freeze([
|
|
36
|
+
"--method",
|
|
37
|
+
"-X",
|
|
38
|
+
"--hostname",
|
|
39
|
+
"--jq",
|
|
40
|
+
"-q",
|
|
41
|
+
"-f",
|
|
42
|
+
"--raw-field",
|
|
43
|
+
"-F",
|
|
44
|
+
"--field",
|
|
45
|
+
"-H",
|
|
46
|
+
"--header",
|
|
47
|
+
]),
|
|
48
|
+
denyFlags: Object.freeze(["--input", "--paginate"]),
|
|
49
|
+
},
|
|
50
|
+
]);
|
|
51
|
+
// ─── Pure argv builders (merge PUT, readiness GETs, guarded branch DELETE) ────────────────────────────
|
|
52
|
+
export class GitMergeArgvError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "GitMergeArgvError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// NUL + the C0 control range + DEL, enumerated via a string-built RegExp (no literal control chars in
|
|
59
|
+
// source). A ref / repo slug never legitimately contains one.
|
|
60
|
+
// eslint-disable-next-line no-control-regex -- intentionally matches control chars to REJECT them
|
|
61
|
+
const REF_CONTROL_CHAR = new RegExp("[\u0000-\u001f\u007f]");
|
|
62
|
+
const OWNER_REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
63
|
+
const PR_NUMBER_RE = /^[1-9][0-9]{0,9}$/;
|
|
64
|
+
const SHA_RE = /^[0-9a-fA-F]{7,64}$/;
|
|
65
|
+
function assertRef(value, label) {
|
|
66
|
+
if (value.length === 0) {
|
|
67
|
+
throw new GitMergeArgvError(`${label} must not be empty`);
|
|
68
|
+
}
|
|
69
|
+
if (REF_CONTROL_CHAR.test(value)) {
|
|
70
|
+
throw new GitMergeArgvError(`${label} must not contain control characters`);
|
|
71
|
+
}
|
|
72
|
+
if (/\s/.test(value)) {
|
|
73
|
+
throw new GitMergeArgvError(`${label} must not contain whitespace`);
|
|
74
|
+
}
|
|
75
|
+
if (value.startsWith("-")) {
|
|
76
|
+
throw new GitMergeArgvError(`${label} must not start with "-" (flag-injection guard)`);
|
|
77
|
+
}
|
|
78
|
+
if (value.includes(":")) {
|
|
79
|
+
throw new GitMergeArgvError(`${label} must not contain ":"`);
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function assertOwnerAndRepo(value) {
|
|
84
|
+
if (!OWNER_REPO_RE.test(value)) {
|
|
85
|
+
throw new GitMergeArgvError('ownerAndRepo must match "owner/repo"');
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
function assertPrNumber(value) {
|
|
90
|
+
if (!PR_NUMBER_RE.test(value)) {
|
|
91
|
+
throw new GitMergeArgvError("prExternalId must be a positive PR number");
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function assertSha(value) {
|
|
96
|
+
if (!SHA_RE.test(value)) {
|
|
97
|
+
throw new GitMergeArgvError("expectedHeadRefHash must be a hex commit SHA");
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
// Content-free GitHub `merge_method` for each strategy hint. provider-default omits the field so GitHub
|
|
102
|
+
// uses the repository's default merge method. Total Record: a new strategy is a compile error here.
|
|
103
|
+
const MERGE_METHOD_BY_STRATEGY = {
|
|
104
|
+
squash: "squash",
|
|
105
|
+
rebase: "rebase",
|
|
106
|
+
"merge-commit": "merge",
|
|
107
|
+
"provider-default": undefined,
|
|
108
|
+
};
|
|
109
|
+
// `gh api --method PUT /repos/{owner}/{repo}/pulls/{number}/merge [-f merge_method=…] [-f sha=…] --jq .merged`.
|
|
110
|
+
// The merge endpoint is metadata-only from this builder's perspective: it never reads a file or paginates.
|
|
111
|
+
export function buildMergeArgv(req) {
|
|
112
|
+
const repo = assertOwnerAndRepo(req.ownerAndRepo);
|
|
113
|
+
const number = assertPrNumber(req.prExternalId);
|
|
114
|
+
const method = MERGE_METHOD_BY_STRATEGY[req.mergeStrategy];
|
|
115
|
+
const argv = ["api", "--method", "PUT", `/repos/${repo}/pulls/${number}/merge`];
|
|
116
|
+
if (method !== undefined) {
|
|
117
|
+
argv.push("-f", `merge_method=${method}`);
|
|
118
|
+
}
|
|
119
|
+
if (req.expectedHeadRefHash !== undefined) {
|
|
120
|
+
argv.push("-f", `sha=${assertSha(req.expectedHeadRefHash)}`);
|
|
121
|
+
}
|
|
122
|
+
argv.push("--jq", ".merged");
|
|
123
|
+
return argv;
|
|
124
|
+
}
|
|
125
|
+
// `gh api /repos/{owner}/{repo}/pulls/{number} --jq <projection>`. Reads only the content-free fields the
|
|
126
|
+
// readiness mapper needs (state, merged, draft, mergeable, mergeable_state, base ref, head sha).
|
|
127
|
+
const PR_READINESS_JQ = "{state:.state,merged:.merged,draft:.draft,mergeable:.mergeable,mergeable_state:.mergeable_state,base:.base.ref,head:.head.sha,headRef:.head.ref}";
|
|
128
|
+
export function buildMergeReadinessArgv(req) {
|
|
129
|
+
const repo = assertOwnerAndRepo(req.ownerAndRepo);
|
|
130
|
+
const number = assertPrNumber(req.prExternalId);
|
|
131
|
+
return ["api", `/repos/${repo}/pulls/${number}`, "--jq", PR_READINESS_JQ];
|
|
132
|
+
}
|
|
133
|
+
// `gh api /repos/{owner}/{repo} --jq <projection>`. Reads the repository's allowed merge strategies.
|
|
134
|
+
const REPO_MERGE_CONFIG_JQ = "{squash:.allow_squash_merge,merge:.allow_merge_commit,rebase:.allow_rebase_merge}";
|
|
135
|
+
export function buildRepoMergeConfigArgv(req) {
|
|
136
|
+
const repo = assertOwnerAndRepo(req.ownerAndRepo);
|
|
137
|
+
return ["api", `/repos/${repo}`, "--jq", REPO_MERGE_CONFIG_JQ];
|
|
138
|
+
}
|
|
139
|
+
// `gh api /repos/{owner}/{repo}/commits/{sha}/status --jq .state`. Reads the head commit's combined
|
|
140
|
+
// check status (success / pending / failure) to refine a blocked/unstable merge state.
|
|
141
|
+
export function buildHeadStatusArgv(repoSlug, headSha) {
|
|
142
|
+
const repo = assertOwnerAndRepo(repoSlug);
|
|
143
|
+
const sha = assertSha(headSha);
|
|
144
|
+
return ["api", `/repos/${repo}/commits/${sha}/status`, "--jq", ".state"];
|
|
145
|
+
}
|
|
146
|
+
// `gh api --method DELETE /repos/{owner}/{repo}/git/refs/heads/{branch}`. The guarded branch deletion
|
|
147
|
+
// performed only after a successful merge when deleteBranchAfterMerge is set.
|
|
148
|
+
export function buildDeleteMergedBranchArgv(repoSlug, headBranchName) {
|
|
149
|
+
const repo = assertOwnerAndRepo(repoSlug);
|
|
150
|
+
const branch = assertRef(headBranchName, "headBranchName");
|
|
151
|
+
return ["api", "--method", "DELETE", `/repos/${repo}/git/refs/heads/${branch}`];
|
|
152
|
+
}
|
|
153
|
+
// True iff the argv begins with the single allowed `api` subcommand. Lets tests prove the
|
|
154
|
+
// no-generic-fallback property structurally for the merge authority.
|
|
155
|
+
export function gitMergeArgvIsGoverned(argv) {
|
|
156
|
+
return argv.length > 0 && GIT_MERGE_ALLOWED_SUBCOMMANDS.includes(argv[0] ?? "");
|
|
157
|
+
}
|
|
158
|
+
// ─── Provider-rejection classifier (GitHub-specific; neutral taxonomy lives in keiko-contracts) ───────
|
|
159
|
+
// Ordered phrase table over the (lower-cased, secret-redacted) gh output. The FIRST matching row wins.
|
|
160
|
+
//
|
|
161
|
+
// ⚠️ ORDERING SEMANTIC (load-bearing): rate-limited MUST precede permission-denied (a GitHub rate limit
|
|
162
|
+
// is surfaced as HTTP 403); already-merged and head-modified MUST precede not-mergeable (all three can
|
|
163
|
+
// surface as HTTP 405/409). Reordering rows silently flips the classification. The ambiguous-token test
|
|
164
|
+
// pins this invariant — do not reorder or insert a row without re-checking it.
|
|
165
|
+
const REJECTION_PHRASES = [
|
|
166
|
+
["rate-limited", ["rate limit exceeded", "secondary rate limit", "exceeded a secondary rate"]],
|
|
167
|
+
["already-merged", ["pull request is already merged", "already merged"]],
|
|
168
|
+
["head-modified", ["head branch was modified", "base branch was modified", "was modified"]],
|
|
169
|
+
["conflict", ["merge conflict", "has conflicts", "is in conflict"]],
|
|
170
|
+
[
|
|
171
|
+
"approvals-missing",
|
|
172
|
+
["approving review", "review is required", "changes requested", "review required"],
|
|
173
|
+
],
|
|
174
|
+
[
|
|
175
|
+
"checks-failing",
|
|
176
|
+
["required status check", "status checks are required", "expected — waiting", "checks are not"],
|
|
177
|
+
],
|
|
178
|
+
[
|
|
179
|
+
"branch-protection",
|
|
180
|
+
[
|
|
181
|
+
"protected branch",
|
|
182
|
+
"branch protection",
|
|
183
|
+
"merge queue",
|
|
184
|
+
"not allowed to merge",
|
|
185
|
+
"required to be",
|
|
186
|
+
],
|
|
187
|
+
],
|
|
188
|
+
[
|
|
189
|
+
"permission-denied",
|
|
190
|
+
["http 401", "http 403", "bad credentials", "must have admin", "forbidden"],
|
|
191
|
+
],
|
|
192
|
+
["not-found", ["http 404", "not found"]],
|
|
193
|
+
[
|
|
194
|
+
"not-mergeable",
|
|
195
|
+
["http 405", "not mergeable", "method not allowed", "pull request is not mergeable"],
|
|
196
|
+
],
|
|
197
|
+
[
|
|
198
|
+
"provider-unavailable",
|
|
199
|
+
[
|
|
200
|
+
"http 502",
|
|
201
|
+
"http 503",
|
|
202
|
+
"http 504",
|
|
203
|
+
"bad gateway",
|
|
204
|
+
"service unavailable",
|
|
205
|
+
"could not resolve host",
|
|
206
|
+
"connection refused",
|
|
207
|
+
"timed out",
|
|
208
|
+
"timeout",
|
|
209
|
+
],
|
|
210
|
+
],
|
|
211
|
+
];
|
|
212
|
+
export function classifyGitMergeRejection(output) {
|
|
213
|
+
const haystack = output.toLowerCase();
|
|
214
|
+
for (const [reason, phrases] of REJECTION_PHRASES) {
|
|
215
|
+
if (phrases.some((phrase) => haystack.includes(phrase))) {
|
|
216
|
+
return reason;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return "unknown";
|
|
220
|
+
}
|
|
221
|
+
function mergeReadinessFromState(mergeableState) {
|
|
222
|
+
switch (mergeableState) {
|
|
223
|
+
case "clean":
|
|
224
|
+
case "has_hooks":
|
|
225
|
+
case "unstable":
|
|
226
|
+
return { ready: true, requiredApprovalCount: 0, receivedApprovalCount: 0 };
|
|
227
|
+
case "dirty":
|
|
228
|
+
return {
|
|
229
|
+
ready: false,
|
|
230
|
+
blockingReason: "conflicts",
|
|
231
|
+
requiredApprovalCount: 0,
|
|
232
|
+
receivedApprovalCount: 0,
|
|
233
|
+
};
|
|
234
|
+
case "blocked":
|
|
235
|
+
case "behind":
|
|
236
|
+
return {
|
|
237
|
+
ready: false,
|
|
238
|
+
blockingReason: "branch-protection",
|
|
239
|
+
requiredApprovalCount: 0,
|
|
240
|
+
receivedApprovalCount: 0,
|
|
241
|
+
};
|
|
242
|
+
// "draft" is reflected by isDraft; "unknown" (still computing) yields no specific reason so the
|
|
243
|
+
// contract derivation emits readiness-unknown.
|
|
244
|
+
default:
|
|
245
|
+
return { ready: false, requiredApprovalCount: 0, receivedApprovalCount: 0 };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
export function mapRawMergeReadiness(raw) {
|
|
249
|
+
const status = raw.merged
|
|
250
|
+
? "merged"
|
|
251
|
+
: raw.state === "closed"
|
|
252
|
+
? "closed"
|
|
253
|
+
: "open";
|
|
254
|
+
return {
|
|
255
|
+
schemaVersion: "1",
|
|
256
|
+
externalId: raw.prNumber,
|
|
257
|
+
status,
|
|
258
|
+
isDraft: raw.draft === true || raw.mergeableState === "draft",
|
|
259
|
+
headBranchName: raw.headBranchName,
|
|
260
|
+
...(raw.baseRef !== undefined
|
|
261
|
+
? { baseBranchName: raw.baseRef }
|
|
262
|
+
: { baseBranchName: "unknown" }),
|
|
263
|
+
mergeReadiness: mergeReadinessFromState(raw.mergeableState),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function constraintBlock(constraint, target, capabilities) {
|
|
267
|
+
if (constraint.kind === "branch-pattern") {
|
|
268
|
+
const ok = target !== undefined && gitDeliveryBranchNameMatchesAny(target, constraint.patterns);
|
|
269
|
+
return ok ? undefined : "policy-pack-blocked";
|
|
270
|
+
}
|
|
271
|
+
if (constraint.kind === "provider-capability") {
|
|
272
|
+
return capabilities.includes(constraint.capability) ? undefined : "provider-capability-absent";
|
|
273
|
+
}
|
|
274
|
+
return gitDeliveryRiskClassWithinCeiling("merge", constraint.maxRiskClass)
|
|
275
|
+
? undefined
|
|
276
|
+
: "risk-class-ceiling";
|
|
277
|
+
}
|
|
278
|
+
export function evaluateGitMergeEffectivePolicy(decision, baseTarget, capabilities) {
|
|
279
|
+
if (decision.outcome === "allowed") {
|
|
280
|
+
return { outcome: "allowed" };
|
|
281
|
+
}
|
|
282
|
+
if (decision.outcome === "blocked") {
|
|
283
|
+
return { outcome: "blocked", blockReason: decision.reason };
|
|
284
|
+
}
|
|
285
|
+
if (decision.outcome === "approval-gated") {
|
|
286
|
+
return { outcome: "approval-gated" };
|
|
287
|
+
}
|
|
288
|
+
for (const constraint of decision.constraints) {
|
|
289
|
+
const reason = constraintBlock(constraint, baseTarget, capabilities);
|
|
290
|
+
if (reason !== undefined) {
|
|
291
|
+
return { outcome: "blocked", blockReason: reason };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { outcome: "allowed" };
|
|
295
|
+
}
|
|
296
|
+
function mergeResolvedInputs(command) {
|
|
297
|
+
return {
|
|
298
|
+
kind: "merge",
|
|
299
|
+
prExternalId: command.prExternalId,
|
|
300
|
+
mergeStrategyHint: command.mergeStrategy,
|
|
301
|
+
deleteBranchAfterMerge: command.deleteBranchAfterMerge,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function buildMergePreview(command) {
|
|
305
|
+
return {
|
|
306
|
+
schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
|
|
307
|
+
affectedBranchName: command.baseBranchName,
|
|
308
|
+
wouldCreateRemoteBranch: false,
|
|
309
|
+
// A merge lands a commit on the base branch, which typically triggers the base branch's checks.
|
|
310
|
+
wouldTriggerChecks: true,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function assembleEnvelope(actionId, inputs, policyDecision, approval, preview, executionResult) {
|
|
314
|
+
return {
|
|
315
|
+
schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
|
|
316
|
+
actionId,
|
|
317
|
+
kind: inputs.kind,
|
|
318
|
+
resolvedInputs: inputs,
|
|
319
|
+
policyDecision,
|
|
320
|
+
approvalRequirement: approval,
|
|
321
|
+
preview,
|
|
322
|
+
...(executionResult !== undefined ? { executionResult } : {}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function approvalState(approval, now) {
|
|
326
|
+
if (!approval.required) {
|
|
327
|
+
return "absent";
|
|
328
|
+
}
|
|
329
|
+
if (approval.expiresAtMs !== undefined && approval.expiresAtMs <= now) {
|
|
330
|
+
return "expired";
|
|
331
|
+
}
|
|
332
|
+
return "valid";
|
|
333
|
+
}
|
|
334
|
+
function resolveMergeGate(decision, approval, target, capabilities, now) {
|
|
335
|
+
if (decision.outcome === "allowed") {
|
|
336
|
+
return { proceed: true };
|
|
337
|
+
}
|
|
338
|
+
if (decision.outcome === "blocked") {
|
|
339
|
+
return { proceed: false, status: "policy-block", reason: decision.reason };
|
|
340
|
+
}
|
|
341
|
+
if (decision.outcome === "approval-gated") {
|
|
342
|
+
const state = approvalState(approval, now);
|
|
343
|
+
if (state === "valid")
|
|
344
|
+
return { proceed: true };
|
|
345
|
+
if (state === "expired") {
|
|
346
|
+
return { proceed: false, status: "policy-block", reason: "approval-expired" };
|
|
347
|
+
}
|
|
348
|
+
return { proceed: false, status: "approval-required", approvers: decision.requiredApprovers };
|
|
349
|
+
}
|
|
350
|
+
for (const constraint of decision.constraints) {
|
|
351
|
+
const reason = constraintBlock(constraint, target, capabilities);
|
|
352
|
+
if (reason !== undefined) {
|
|
353
|
+
return { proceed: false, status: "policy-block", reason };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { proceed: true };
|
|
357
|
+
}
|
|
358
|
+
function mergeOutcomeFor(result) {
|
|
359
|
+
if (result.outcome === "succeeded") {
|
|
360
|
+
return { status: "succeeded", executionResult: result };
|
|
361
|
+
}
|
|
362
|
+
const category = gitMutationCategoryForExecutionResult(result) ?? "execution-failure";
|
|
363
|
+
if (category === "recovery-required") {
|
|
364
|
+
return { status: "recovery-required", category, executionResult: result };
|
|
365
|
+
}
|
|
366
|
+
if (category === "provider-failure") {
|
|
367
|
+
return { status: "failed", category, executionResult: result };
|
|
368
|
+
}
|
|
369
|
+
return { status: "failed", category: "execution-failure", executionResult: result };
|
|
370
|
+
}
|
|
371
|
+
function prepareMerge(request, deps) {
|
|
372
|
+
const inputs = mergeResolvedInputs(request.command);
|
|
373
|
+
const capabilities = deps.activeProviderCapabilities ?? [];
|
|
374
|
+
const context = {
|
|
375
|
+
actionKind: "merge",
|
|
376
|
+
targetBranchName: request.command.baseBranchName,
|
|
377
|
+
activeProviderCapabilities: capabilities,
|
|
378
|
+
};
|
|
379
|
+
return {
|
|
380
|
+
inputs,
|
|
381
|
+
preflight: evaluateGitPreflight(inputs, deps.snapshot),
|
|
382
|
+
preview: buildMergePreview(request.command),
|
|
383
|
+
policyDecision: evaluateGitPolicy(deps.orgPolicyPack, deps.repoPolicyPack, context),
|
|
384
|
+
actionId: deps.newActionId(),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function lifecycleFor(prep, approval, outcome, phaseReached, executionResult) {
|
|
388
|
+
return {
|
|
389
|
+
envelope: assembleEnvelope(prep.actionId, prep.inputs, prep.policyDecision, approval, prep.preview, executionResult),
|
|
390
|
+
outcome,
|
|
391
|
+
phaseReached,
|
|
392
|
+
preflight: prep.preflight,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// Reads the provider readiness through the adapter, never throwing: a thrown read becomes a
|
|
396
|
+
// provider-error readiness so the readiness gate blocks fail-closed.
|
|
397
|
+
async function readReadiness(command, deps) {
|
|
398
|
+
let provider;
|
|
399
|
+
try {
|
|
400
|
+
provider = await deps.adapter.readMergeReadiness({
|
|
401
|
+
ownerAndRepo: command.ownerAndRepo,
|
|
402
|
+
prExternalId: command.prExternalId,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
provider = { providerCapableStrategies: [], providerError: true };
|
|
407
|
+
}
|
|
408
|
+
const strategyPolicy = deps.strategyPolicy ?? {
|
|
409
|
+
allowedStrategies: [...GIT_DELIVERY_MERGE_STRATEGY_HINTS],
|
|
410
|
+
};
|
|
411
|
+
const eligibility = deriveEligibleMergeStrategies(command.mergeStrategy, strategyPolicy, provider.providerCapableStrategies);
|
|
412
|
+
const summary = gitMergeReadinessFor({
|
|
413
|
+
...(provider.pullRequest !== undefined ? { pullRequest: provider.pullRequest } : {}),
|
|
414
|
+
...(provider.checks !== undefined ? { checks: provider.checks } : {}),
|
|
415
|
+
...(provider.branchProtection !== undefined
|
|
416
|
+
? { branchProtection: provider.branchProtection }
|
|
417
|
+
: {}),
|
|
418
|
+
strategyEligible: eligibility.requestedEligible,
|
|
419
|
+
...(provider.providerError === true ? { providerError: true } : {}),
|
|
420
|
+
});
|
|
421
|
+
return { provider, summary };
|
|
422
|
+
}
|
|
423
|
+
async function runMergeAdapter(command, adapter) {
|
|
424
|
+
try {
|
|
425
|
+
return await adapter.mergePullRequest({
|
|
426
|
+
ownerAndRepo: command.ownerAndRepo,
|
|
427
|
+
prExternalId: command.prExternalId,
|
|
428
|
+
headBranchName: command.headBranchName,
|
|
429
|
+
mergeStrategy: command.mergeStrategy,
|
|
430
|
+
deleteBranchAfterMerge: command.deleteBranchAfterMerge,
|
|
431
|
+
...(command.expectedHeadRefHash !== undefined
|
|
432
|
+
? { expectedHeadRefHash: command.expectedHeadRefHash }
|
|
433
|
+
: {}),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return {
|
|
438
|
+
schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
|
|
439
|
+
outcome: "failed",
|
|
440
|
+
durationMs: 0,
|
|
441
|
+
errorCode: "internal-error",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function providerPullRequestMatchesCommand(command, pullRequest) {
|
|
446
|
+
return (pullRequest?.externalId === command.prExternalId &&
|
|
447
|
+
pullRequest.baseBranchName === command.baseBranchName &&
|
|
448
|
+
pullRequest.headBranchName === command.headBranchName);
|
|
449
|
+
}
|
|
450
|
+
function providerMismatchReadiness(summary) {
|
|
451
|
+
return {
|
|
452
|
+
...summary,
|
|
453
|
+
mergeable: false,
|
|
454
|
+
blockers: [
|
|
455
|
+
{ code: "provider-error", severity: "blocking", remediation: "internal" },
|
|
456
|
+
...summary.blockers,
|
|
457
|
+
],
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// The readiness gate result: proceed to execute, or block (with the lifecycle to return).
|
|
461
|
+
function readinessBlockLifecycle(prep, approval, summary, providerError) {
|
|
462
|
+
if (providerError) {
|
|
463
|
+
// A provider read failure is an internal/transport failure, not a policy block.
|
|
464
|
+
const executionResult = {
|
|
465
|
+
schemaVersion: GIT_DELIVERY_SCHEMA_VERSION,
|
|
466
|
+
outcome: "failed",
|
|
467
|
+
durationMs: 0,
|
|
468
|
+
errorCode: "internal-error",
|
|
469
|
+
};
|
|
470
|
+
return lifecycleFor(prep, approval, { status: "failed", category: "execution-failure", executionResult }, "policy", executionResult);
|
|
471
|
+
}
|
|
472
|
+
// Otherwise the merge is blocked by unmet provider/branch-protection merge requirements; the precise,
|
|
473
|
+
// content-free blocker list is carried on the GitMergeLifecycleResult.readiness for the UI/recovery.
|
|
474
|
+
void summary;
|
|
475
|
+
return lifecycleFor(prep, approval, { status: "blocked", category: "policy-block", blockReason: "protected-branch" }, "policy", undefined);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Runs ONE governed merge operation end-to-end: derive merge inputs → preflight (no local precondition)
|
|
479
|
+
* → policy + final approval gate → READINESS gate (read provider facts; block when not mergeable) → (only
|
|
480
|
+
* when all gates pass) execute through the narrow merge adapter. Returns a kernel-shaped lifecycle result
|
|
481
|
+
* (so the #474 evidence builder records it unchanged) plus the readiness summary, the live merge-rejection
|
|
482
|
+
* descriptor when the provider rejected the merge, and the merged / branch-deleted flags.
|
|
483
|
+
*/
|
|
484
|
+
// The readiness gate + (when mergeable) the merge execution. Reached only after preflight and the
|
|
485
|
+
// policy/approval gate have passed.
|
|
486
|
+
async function runReadinessAndMerge(prep, request, deps) {
|
|
487
|
+
// Readiness gate: the merge is not attempted when a blocking blocker is present (AC1).
|
|
488
|
+
const { provider, summary } = await readReadiness(request.command, deps);
|
|
489
|
+
if (!summary.mergeable) {
|
|
490
|
+
return {
|
|
491
|
+
lifecycle: readinessBlockLifecycle(prep, request.approval, summary, provider.providerError === true),
|
|
492
|
+
readiness: summary,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (!providerPullRequestMatchesCommand(request.command, provider.pullRequest)) {
|
|
496
|
+
const mismatchReadiness = providerMismatchReadiness(summary);
|
|
497
|
+
return {
|
|
498
|
+
lifecycle: readinessBlockLifecycle(prep, request.approval, mismatchReadiness, true),
|
|
499
|
+
readiness: mismatchReadiness,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const result = await runMergeAdapter(request.command, deps.adapter);
|
|
503
|
+
const lifecycle = lifecycleFor(prep, request.approval, mergeOutcomeFor(result), "result", result);
|
|
504
|
+
const base = {
|
|
505
|
+
lifecycle,
|
|
506
|
+
readiness: summary,
|
|
507
|
+
...(result.merged !== undefined ? { merged: result.merged } : {}),
|
|
508
|
+
...(result.branchDeleted !== undefined ? { branchDeleted: result.branchDeleted } : {}),
|
|
509
|
+
};
|
|
510
|
+
if (result.outcome !== "succeeded" && result.outcome !== "aborted") {
|
|
511
|
+
return { ...base, rejection: gitMergeRejectionFor(result.rejectionReason ?? "unknown") };
|
|
512
|
+
}
|
|
513
|
+
return base;
|
|
514
|
+
}
|
|
515
|
+
export async function runGitMerge(request, deps) {
|
|
516
|
+
const prep = prepareMerge(request, deps);
|
|
517
|
+
if (!prep.preflight.ok) {
|
|
518
|
+
const outcome = {
|
|
519
|
+
status: "blocked",
|
|
520
|
+
category: "preflight-block",
|
|
521
|
+
findings: prep.preflight.blocking,
|
|
522
|
+
};
|
|
523
|
+
return { lifecycle: lifecycleFor(prep, request.approval, outcome, "preflight", undefined) };
|
|
524
|
+
}
|
|
525
|
+
const gate = resolveMergeGate(prep.policyDecision, request.approval, request.command.baseBranchName, deps.activeProviderCapabilities ?? [], deps.now());
|
|
526
|
+
if (!gate.proceed) {
|
|
527
|
+
const outcome = gate.status === "approval-required"
|
|
528
|
+
? { status: "approval-required", requiredApprovers: gate.approvers }
|
|
529
|
+
: { status: "blocked", category: "policy-block", blockReason: gate.reason };
|
|
530
|
+
return { lifecycle: lifecycleFor(prep, request.approval, outcome, "policy", undefined) };
|
|
531
|
+
}
|
|
532
|
+
return runReadinessAndMerge(prep, request, deps);
|
|
533
|
+
}
|
|
534
|
+
// Re-export the contract bridge so the server/UI consume the error-code mapping from this gateway,
|
|
535
|
+
// keeping the publish/PR/merge gateway surfaces symmetric.
|
|
536
|
+
export { gitMergeRejectionToErrorCode };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
|
|
2
|
+
import { type GitMergeAdapter } from "./git-merge-gateway.js";
|
|
3
|
+
import { type ExecutableResolver, type HomeProvider, type SpawnFn } from "./exec.js";
|
|
4
|
+
import { type SandboxPolicy } from "./types.js";
|
|
5
|
+
export interface NodeGitMergeAdapterDeps {
|
|
6
|
+
readonly workspace: WorkspaceInfo;
|
|
7
|
+
readonly processEnv?: NodeJS.ProcessEnv | undefined;
|
|
8
|
+
readonly now?: (() => number) | undefined;
|
|
9
|
+
readonly spawn?: SpawnFn | undefined;
|
|
10
|
+
readonly policy?: SandboxPolicy | undefined;
|
|
11
|
+
readonly resolveExecutable?: ExecutableResolver | undefined;
|
|
12
|
+
readonly home?: HomeProvider | undefined;
|
|
13
|
+
readonly signal?: AbortSignal | undefined;
|
|
14
|
+
readonly timeoutMs?: number | undefined;
|
|
15
|
+
}
|
|
16
|
+
export declare function createNodeGitMergeAdapter(deps: NodeGitMergeAdapterDeps): GitMergeAdapter;
|
|
17
|
+
//# sourceMappingURL=git-merge-node.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-merge-node.d.ts","sourceRoot":"","sources":["../src/git-merge-node.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAOnE,OAAO,EAUL,KAAK,eAAe,EAMrB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAGL,KAAK,kBAAkB,EACvB,KAAK,YAAY,EAEjB,KAAK,OAAO,EACb,MAAM,WAAW,CAAC;AACnB,OAAO,EAA8C,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAE5F,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,UAAU,GAAG,SAAS,CAAC;IACpD,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAGrC,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAC5C,QAAQ,CAAC,iBAAiB,CAAC,EAAE,kBAAkB,GAAG,SAAS,CAAC;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAC1C,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACzC;AA6QD,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,uBAAuB,GAAG,eAAe,CAQxF"}
|