@deftai/directive-core 0.99.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authz/actions.d.ts +6 -0
- package/dist/authz/actions.js +3 -0
- package/dist/authz/classify.js +157 -23
- package/dist/authz/decompose-apply.d.ts +67 -0
- package/dist/authz/decompose-apply.js +302 -0
- package/dist/authz/index.d.ts +3 -1
- package/dist/authz/index.js +3 -1
- package/dist/authz/store.d.ts +53 -0
- package/dist/authz/store.js +219 -1
- package/dist/authz/types.d.ts +19 -2
- package/dist/authz/types.js +5 -1
- package/dist/doctor/checks.d.ts +44 -0
- package/dist/doctor/checks.js +428 -2
- package/dist/doctor/main.d.ts +6 -7
- package/dist/doctor/main.js +72 -79
- package/dist/doctor/which.d.ts +20 -1
- package/dist/doctor/which.js +67 -1
- package/dist/finish-loop/pr-finish-loop.d.ts +13 -2
- package/dist/finish-loop/pr-finish-loop.js +116 -5
- package/dist/finish-loop/types.d.ts +1 -1
- package/dist/hooks/dispatcher.d.ts +9 -5
- package/dist/hooks/dispatcher.js +23 -15
- package/dist/hooks/readonly.d.ts +6 -3
- package/dist/hooks/readonly.js +44 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init-deposit/agent-hooks.d.ts +20 -0
- package/dist/init-deposit/agent-hooks.js +51 -31
- package/dist/intake/issue-ingest.js +29 -0
- package/dist/lifecycle/completed-consistency.d.ts +60 -0
- package/dist/lifecycle/completed-consistency.js +443 -0
- package/dist/lifecycle/completed-tracked-on-delivery.d.ts +47 -0
- package/dist/lifecycle/completed-tracked-on-delivery.js +415 -0
- package/dist/lifecycle/index.d.ts +2 -0
- package/dist/lifecycle/index.js +2 -0
- package/dist/literal-acceptance/capture.d.ts +52 -0
- package/dist/literal-acceptance/capture.js +658 -0
- package/dist/literal-acceptance/evaluate.d.ts +47 -0
- package/dist/literal-acceptance/evaluate.js +193 -0
- package/dist/literal-acceptance/index.d.ts +12 -0
- package/dist/literal-acceptance/index.js +12 -0
- package/dist/literal-acceptance/run.d.ts +38 -0
- package/dist/literal-acceptance/run.js +216 -0
- package/dist/literal-acceptance/safety.d.ts +24 -0
- package/dist/literal-acceptance/safety.js +251 -0
- package/dist/literal-acceptance/types.d.ts +82 -0
- package/dist/literal-acceptance/types.js +26 -0
- package/dist/policy/ceremony-dial.d.ts +67 -11
- package/dist/policy/ceremony-dial.js +174 -23
- package/dist/policy/index.d.ts +1 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/merge-approval-head.d.ts +129 -0
- package/dist/policy/merge-approval-head.js +461 -0
- package/dist/policy/require-human-merge.d.ts +5 -0
- package/dist/policy/require-human-merge.js +5 -0
- package/dist/pr-merge-readiness/ci-gate.d.ts +20 -3
- package/dist/pr-merge-readiness/ci-gate.js +65 -5
- package/dist/pr-merge-readiness/compute.d.ts +13 -0
- package/dist/pr-merge-readiness/compute.js +99 -14
- package/dist/pr-merge-readiness/gh.d.ts +49 -0
- package/dist/pr-merge-readiness/gh.js +268 -0
- package/dist/pr-merge-readiness/index.d.ts +2 -2
- package/dist/pr-merge-readiness/index.js +2 -2
- package/dist/pr-merge-readiness/test-gh-fixtures.helpers.js +4 -0
- package/dist/pr-wait-mergeable/cascade.d.ts +11 -0
- package/dist/pr-wait-mergeable/cascade.js +115 -1
- package/dist/pr-wait-mergeable/main.d.ts +4 -0
- package/dist/pr-wait-mergeable/main.js +4 -0
- package/dist/pr-wait-mergeable/types.d.ts +3 -1
- package/dist/pr-wait-mergeable/wrappers.d.ts +6 -1
- package/dist/pr-wait-mergeable/wrappers.js +7 -1
- package/dist/preflight/evaluate.d.ts +15 -1
- package/dist/preflight/evaluate.js +33 -2
- package/dist/render/framework-commands.js +4 -0
- package/dist/scope/acceptance-evidence.d.ts +76 -0
- package/dist/scope/acceptance-evidence.js +348 -0
- package/dist/scope/coverage-map.d.ts +98 -0
- package/dist/scope/coverage-map.js +558 -0
- package/dist/scope/decompose.js +125 -11
- package/dist/scope/index.d.ts +3 -0
- package/dist/scope/index.js +3 -0
- package/dist/scope/parent-lineage.d.ts +125 -0
- package/dist/scope/parent-lineage.js +626 -0
- package/dist/scope/transition.d.ts +8 -0
- package/dist/scope/transition.js +61 -2
- package/dist/session/active-cli.d.ts +79 -0
- package/dist/session/active-cli.js +382 -0
- package/dist/session/effort-budget.d.ts +130 -0
- package/dist/session/effort-budget.js +334 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +2 -0
- package/dist/session/session-start.d.ts +6 -0
- package/dist/session/session-start.js +53 -2
- package/dist/session/verify-session-ritual.d.ts +14 -0
- package/dist/session/verify-session-ritual.js +33 -0
- package/dist/story-ready/evaluate.d.ts +11 -0
- package/dist/story-ready/evaluate.js +41 -3
- package/dist/triage/actions/candidates-log.d.ts +1 -2
- package/dist/triage/actions/candidates-log.js +37 -6
- package/dist/xbrief-migrate/migrate-project.d.ts +4 -0
- package/dist/xbrief-migrate/migrate-project.js +92 -3
- package/package.json +7 -3
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head-bound human merge approval (#3235).
|
|
3
|
+
*
|
|
4
|
+
* Phase 5 → 6 `plan:approved` events authorize one immutable PR head.
|
|
5
|
+
* When PR HEAD changes after approval (and GitHub auto-merge may still be
|
|
6
|
+
* enabled), Directive fails closed: treats the approval as stale, attempts to
|
|
7
|
+
* disable auto-merge, and emits recovery text requiring a fresh approval bound
|
|
8
|
+
* to the current head.
|
|
9
|
+
*
|
|
10
|
+
* Prefer this path from finish-loop / pr-wait-mergeable / require-human-merge
|
|
11
|
+
* consumers. Does not change consumer repository rulesets.
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { DEFAULT_EVENT_LOG, readEvents } from "../lifecycle/events.js";
|
|
15
|
+
import { captureExec } from "../pr-wait-mergeable/wrappers.js";
|
|
16
|
+
import { resolveBinary } from "../scm/binary.js";
|
|
17
|
+
import { resolveHumanMergePolicy } from "./require-human-merge.js";
|
|
18
|
+
/** Event name recorded at Phase 5 → 6 approval. */
|
|
19
|
+
export const PLAN_APPROVED_EVENT = "plan:approved";
|
|
20
|
+
/** Full Git object id length — approvals must bind to exact heads (#3235 / Greptile P1). */
|
|
21
|
+
export const FULL_HEAD_SHA_LEN = 40;
|
|
22
|
+
function isHexSha(value) {
|
|
23
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
24
|
+
const c = value.charCodeAt(i);
|
|
25
|
+
const isDigit = c >= 48 && c <= 57;
|
|
26
|
+
const isAf = c >= 97 && c <= 102;
|
|
27
|
+
if (!isDigit && !isAf)
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return value.length > 0;
|
|
31
|
+
}
|
|
32
|
+
function normalizeSha(value) {
|
|
33
|
+
if (typeof value !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
const trimmed = value.trim().toLowerCase();
|
|
36
|
+
// Exact full-object-id only — reject short and overlong tokens (#3235 Greptile).
|
|
37
|
+
if (trimmed.length !== FULL_HEAD_SHA_LEN)
|
|
38
|
+
return null;
|
|
39
|
+
if (!isHexSha(trimmed))
|
|
40
|
+
return null;
|
|
41
|
+
return trimmed;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Exact full-SHA equality only (#3235).
|
|
45
|
+
* Prefix matching is forbidden: abbreviated approval SHAs must not authorize a later head.
|
|
46
|
+
*/
|
|
47
|
+
export function headShaMatches(a, b) {
|
|
48
|
+
if (a === null || b === null)
|
|
49
|
+
return false;
|
|
50
|
+
const left = a.trim().toLowerCase();
|
|
51
|
+
const right = b.trim().toLowerCase();
|
|
52
|
+
if (left.length !== FULL_HEAD_SHA_LEN || right.length !== FULL_HEAD_SHA_LEN)
|
|
53
|
+
return false;
|
|
54
|
+
if (!isHexSha(left) || !isHexSha(right))
|
|
55
|
+
return false;
|
|
56
|
+
return left === right;
|
|
57
|
+
}
|
|
58
|
+
/** Extract trailing decimal digits starting at index (no regex — CodeQL safe). */
|
|
59
|
+
function parseLeadingDigits(text, start) {
|
|
60
|
+
let digits = "";
|
|
61
|
+
for (let i = start; i < text.length; i += 1) {
|
|
62
|
+
const c = text.charCodeAt(i);
|
|
63
|
+
if (c < 48 || c > 57)
|
|
64
|
+
break;
|
|
65
|
+
digits += text[i];
|
|
66
|
+
}
|
|
67
|
+
if (digits.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const n = Number.parseInt(digits, 10);
|
|
70
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
71
|
+
}
|
|
72
|
+
function prNumberFromPayload(payload) {
|
|
73
|
+
const raw = payload.pr_number;
|
|
74
|
+
if (typeof raw === "number" && Number.isInteger(raw) && raw > 0)
|
|
75
|
+
return raw;
|
|
76
|
+
if (typeof raw === "string") {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
let allDigits = trimmed.length > 0;
|
|
79
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
80
|
+
const c = trimmed.charCodeAt(i);
|
|
81
|
+
if (c < 48 || c > 57) {
|
|
82
|
+
allDigits = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (allDigits)
|
|
87
|
+
return Number.parseInt(trimmed, 10);
|
|
88
|
+
}
|
|
89
|
+
const planRef = payload.plan_ref;
|
|
90
|
+
if (typeof planRef === "string") {
|
|
91
|
+
const lower = planRef.toLowerCase();
|
|
92
|
+
const marker = "/pull/";
|
|
93
|
+
const idx = lower.indexOf(marker);
|
|
94
|
+
if (idx >= 0) {
|
|
95
|
+
return parseLeadingDigits(planRef, idx + marker.length);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function repositoryFromPayload(payload) {
|
|
101
|
+
if (typeof payload.repository === "string" && payload.repository.includes("/")) {
|
|
102
|
+
return payload.repository.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
if (typeof payload.plan_ref === "string") {
|
|
105
|
+
const lower = payload.plan_ref.toLowerCase();
|
|
106
|
+
const marker = "github.com/";
|
|
107
|
+
const idx = lower.indexOf(marker);
|
|
108
|
+
if (idx >= 0) {
|
|
109
|
+
const rest = payload.plan_ref.slice(idx + marker.length);
|
|
110
|
+
const parts = rest.split("/");
|
|
111
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
112
|
+
return `${parts[0]}/${parts[1]}`.toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
function toPlanApproved(record) {
|
|
119
|
+
const payload = record.payload;
|
|
120
|
+
return {
|
|
121
|
+
head_sha: normalizeSha(payload.head_sha),
|
|
122
|
+
pr_number: prNumberFromPayload(payload),
|
|
123
|
+
approver: typeof payload.approver === "string" ? payload.approver : null,
|
|
124
|
+
plan_ref: typeof payload.plan_ref === "string" ? payload.plan_ref : null,
|
|
125
|
+
detected_at: typeof record.detected_at === "string" ? record.detected_at : null,
|
|
126
|
+
payload,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Latest plan:approved for a PR (by log order; last write wins).
|
|
131
|
+
* When `repo` is set, prefer records whose repository/plan_ref matches (P2 #3235).
|
|
132
|
+
*/
|
|
133
|
+
export function findLatestPlanApprovalForPr(prNumber, options = {}) {
|
|
134
|
+
const stream = options.records !== undefined
|
|
135
|
+
? options.records
|
|
136
|
+
: (options.readEventsFn ?? readEvents)(options.logPath ?? null);
|
|
137
|
+
const wantRepo = typeof options.repo === "string" && options.repo.includes("/")
|
|
138
|
+
? options.repo.toLowerCase()
|
|
139
|
+
: null;
|
|
140
|
+
let latest = null;
|
|
141
|
+
for (const record of stream) {
|
|
142
|
+
if (record.event !== PLAN_APPROVED_EVENT)
|
|
143
|
+
continue;
|
|
144
|
+
const approved = toPlanApproved(record);
|
|
145
|
+
if (approved.pr_number !== prNumber)
|
|
146
|
+
continue;
|
|
147
|
+
if (wantRepo !== null) {
|
|
148
|
+
// When repo is known, never fall back to an unscoped same-number event
|
|
149
|
+
// (cross-repo shadow / Greptile conf floor on #3235).
|
|
150
|
+
const recRepo = repositoryFromPayload(record.payload);
|
|
151
|
+
if (recRepo !== wantRepo)
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
latest = approved;
|
|
155
|
+
}
|
|
156
|
+
return latest;
|
|
157
|
+
}
|
|
158
|
+
/** Recovery instructions when approval is stale or unbound. */
|
|
159
|
+
export function buildMergeApprovalRecovery(input) {
|
|
160
|
+
const approved = input.approvedHeadSha ?? "<none>";
|
|
161
|
+
const current = input.currentHeadSha ?? "<unknown>";
|
|
162
|
+
const lines = [
|
|
163
|
+
"Recovery (#3235 head-bound merge approval):",
|
|
164
|
+
` • approved_head_sha=${approved}`,
|
|
165
|
+
` • current_head_sha=${current}`,
|
|
166
|
+
];
|
|
167
|
+
if (input.autoMergeDisabled === true) {
|
|
168
|
+
lines.push(" • GitHub auto-merge was disabled for this PR (stale authorization revoked).");
|
|
169
|
+
}
|
|
170
|
+
else if (input.autoMergeDisabled === false) {
|
|
171
|
+
lines.push(" • Could not confirm auto-merge disable — run:", ` gh pr merge ${input.prNumber} --disable-auto`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
lines.push(" • If auto-merge is still enabled, disable it:", ` gh pr merge ${input.prNumber} --disable-auto`);
|
|
175
|
+
}
|
|
176
|
+
lines.push(" • Re-run Phase 5 → 6 approval for the CURRENT head:", " task lifecycle:event -- emit plan:approved \\", " --plan-ref <pr-url> --approver <login> --approval-phrase yes \\", ` --pr-number ${input.prNumber} --head-sha ${current}`, " • Only after a fresh head-bound approval may auto-merge be re-enabled", " or the human merge proceed for this head.");
|
|
177
|
+
if (input.status === "missing_binding") {
|
|
178
|
+
lines.push(" • Prior approval lacked --head-sha; re-emit with --head-sha bound to current HEAD.");
|
|
179
|
+
}
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Pure evaluation: compare recorded approval head to current PR HEAD.
|
|
184
|
+
*/
|
|
185
|
+
export function evaluateMergeApprovalHead(input) {
|
|
186
|
+
const prNumber = input.prNumber;
|
|
187
|
+
const currentHeadSha = normalizeSha(input.currentHeadSha);
|
|
188
|
+
const requireHumanMerge = input.requireHumanMerge !== undefined
|
|
189
|
+
? input.requireHumanMerge
|
|
190
|
+
: input.projectRoot !== undefined
|
|
191
|
+
? resolveHumanMergePolicy(input.projectRoot).requireHumanMerge
|
|
192
|
+
: false;
|
|
193
|
+
const enforceWhenApprovalPresent = input.enforceWhenApprovalPresent !== false;
|
|
194
|
+
const enforceStrictBinding = input.enforceStrictBinding !== false;
|
|
195
|
+
if (prNumber <= 0) {
|
|
196
|
+
return {
|
|
197
|
+
status: "skipped",
|
|
198
|
+
allowed: true,
|
|
199
|
+
approved_head_sha: null,
|
|
200
|
+
current_head_sha: currentHeadSha,
|
|
201
|
+
pr_number: prNumber,
|
|
202
|
+
require_human_merge: requireHumanMerge,
|
|
203
|
+
auto_merge_disabled: null,
|
|
204
|
+
message: "merge-approval-head: skipped (invalid pr number)",
|
|
205
|
+
recovery: null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const resolvedLogPath = input.logPath !== undefined
|
|
209
|
+
? input.logPath
|
|
210
|
+
: input.projectRoot !== undefined
|
|
211
|
+
? join(input.projectRoot, DEFAULT_EVENT_LOG)
|
|
212
|
+
: null;
|
|
213
|
+
const wantRepo = typeof input.repo === "string" && input.repo.includes("/") ? input.repo : null;
|
|
214
|
+
// Fail closed when approvals exist for this PR number but callers omitted repo
|
|
215
|
+
// (cross-repo same-number collision / Greptile #3235).
|
|
216
|
+
if (input.approval === undefined && wantRepo === null) {
|
|
217
|
+
const unscoped = findLatestPlanApprovalForPr(prNumber, {
|
|
218
|
+
logPath: resolvedLogPath,
|
|
219
|
+
readEventsFn: input.readEventsFn,
|
|
220
|
+
records: input.records,
|
|
221
|
+
repo: null,
|
|
222
|
+
});
|
|
223
|
+
if (unscoped !== null && (requireHumanMerge || enforceWhenApprovalPresent)) {
|
|
224
|
+
const recovery = buildMergeApprovalRecovery({
|
|
225
|
+
prNumber,
|
|
226
|
+
approvedHeadSha: unscoped.head_sha,
|
|
227
|
+
currentHeadSha,
|
|
228
|
+
status: "stale",
|
|
229
|
+
autoMergeDisabled: null,
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
status: "stale",
|
|
233
|
+
allowed: false,
|
|
234
|
+
approved_head_sha: unscoped.head_sha,
|
|
235
|
+
current_head_sha: currentHeadSha,
|
|
236
|
+
pr_number: prNumber,
|
|
237
|
+
require_human_merge: requireHumanMerge,
|
|
238
|
+
auto_merge_disabled: null,
|
|
239
|
+
message: `❌ merge-approval-head (#3235): plan:approved exists for PR #${prNumber} but ` +
|
|
240
|
+
"repository is unknown — pass --repo owner/repo (or GH_REPO) so approval " +
|
|
241
|
+
"lookup cannot apply a foreign same-number event.",
|
|
242
|
+
recovery,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const approval = input.approval !== undefined
|
|
247
|
+
? input.approval
|
|
248
|
+
: findLatestPlanApprovalForPr(prNumber, {
|
|
249
|
+
logPath: resolvedLogPath,
|
|
250
|
+
readEventsFn: input.readEventsFn,
|
|
251
|
+
records: input.records,
|
|
252
|
+
repo: wantRepo,
|
|
253
|
+
});
|
|
254
|
+
if (approval === null) {
|
|
255
|
+
// No Directive-owned approval — gate is a no-op (human-merge policy is separate).
|
|
256
|
+
return {
|
|
257
|
+
status: "no_approval",
|
|
258
|
+
allowed: true,
|
|
259
|
+
approved_head_sha: null,
|
|
260
|
+
current_head_sha: currentHeadSha,
|
|
261
|
+
pr_number: prNumber,
|
|
262
|
+
require_human_merge: requireHumanMerge,
|
|
263
|
+
auto_merge_disabled: null,
|
|
264
|
+
message: `merge-approval-head: no plan:approved for PR #${prNumber}`,
|
|
265
|
+
recovery: null,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (!enforceWhenApprovalPresent && !requireHumanMerge) {
|
|
269
|
+
return {
|
|
270
|
+
status: "skipped",
|
|
271
|
+
allowed: true,
|
|
272
|
+
approved_head_sha: approval.head_sha,
|
|
273
|
+
current_head_sha: currentHeadSha,
|
|
274
|
+
pr_number: prNumber,
|
|
275
|
+
require_human_merge: requireHumanMerge,
|
|
276
|
+
auto_merge_disabled: null,
|
|
277
|
+
message: "merge-approval-head: skipped (no requireHumanMerge / not enforcing)",
|
|
278
|
+
recovery: null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (approval.head_sha === null) {
|
|
282
|
+
const recovery = buildMergeApprovalRecovery({
|
|
283
|
+
prNumber,
|
|
284
|
+
approvedHeadSha: null,
|
|
285
|
+
currentHeadSha,
|
|
286
|
+
status: "missing_binding",
|
|
287
|
+
autoMergeDisabled: null,
|
|
288
|
+
});
|
|
289
|
+
const fail = enforceStrictBinding && (requireHumanMerge || enforceWhenApprovalPresent);
|
|
290
|
+
return {
|
|
291
|
+
status: "missing_binding",
|
|
292
|
+
allowed: !fail,
|
|
293
|
+
approved_head_sha: null,
|
|
294
|
+
current_head_sha: currentHeadSha,
|
|
295
|
+
pr_number: prNumber,
|
|
296
|
+
require_human_merge: requireHumanMerge,
|
|
297
|
+
auto_merge_disabled: null,
|
|
298
|
+
message: `❌ merge-approval-head (#3235): plan:approved for PR #${prNumber} has no head_sha. ` +
|
|
299
|
+
"Approval must bind to an exact PR head under requireHumanMerge / auto-merge.",
|
|
300
|
+
recovery: fail ? recovery : null,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (currentHeadSha === null) {
|
|
304
|
+
const recovery = buildMergeApprovalRecovery({
|
|
305
|
+
prNumber,
|
|
306
|
+
approvedHeadSha: approval.head_sha,
|
|
307
|
+
currentHeadSha: null,
|
|
308
|
+
status: "stale",
|
|
309
|
+
autoMergeDisabled: null,
|
|
310
|
+
});
|
|
311
|
+
return {
|
|
312
|
+
status: "stale",
|
|
313
|
+
allowed: false,
|
|
314
|
+
approved_head_sha: approval.head_sha,
|
|
315
|
+
current_head_sha: null,
|
|
316
|
+
pr_number: prNumber,
|
|
317
|
+
require_human_merge: requireHumanMerge,
|
|
318
|
+
auto_merge_disabled: null,
|
|
319
|
+
message: `❌ merge-approval-head (#3235): cannot verify approval for PR #${prNumber} ` +
|
|
320
|
+
`(approved_head_sha=${approval.head_sha}) — current HEAD unknown; fail closed.`,
|
|
321
|
+
recovery,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (headShaMatches(approval.head_sha, currentHeadSha)) {
|
|
325
|
+
return {
|
|
326
|
+
status: "ok",
|
|
327
|
+
allowed: true,
|
|
328
|
+
approved_head_sha: approval.head_sha,
|
|
329
|
+
current_head_sha: currentHeadSha,
|
|
330
|
+
pr_number: prNumber,
|
|
331
|
+
require_human_merge: requireHumanMerge,
|
|
332
|
+
auto_merge_disabled: null,
|
|
333
|
+
message: `✓ merge-approval-head: plan:approved for PR #${prNumber} bound to ` +
|
|
334
|
+
`current head ${currentHeadSha.slice(0, 12)}`,
|
|
335
|
+
recovery: null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const recovery = buildMergeApprovalRecovery({
|
|
339
|
+
prNumber,
|
|
340
|
+
approvedHeadSha: approval.head_sha,
|
|
341
|
+
currentHeadSha,
|
|
342
|
+
status: "stale",
|
|
343
|
+
autoMergeDisabled: null,
|
|
344
|
+
});
|
|
345
|
+
return {
|
|
346
|
+
status: "stale",
|
|
347
|
+
allowed: false,
|
|
348
|
+
approved_head_sha: approval.head_sha,
|
|
349
|
+
current_head_sha: currentHeadSha,
|
|
350
|
+
pr_number: prNumber,
|
|
351
|
+
require_human_merge: requireHumanMerge,
|
|
352
|
+
auto_merge_disabled: null,
|
|
353
|
+
message: `❌ merge-approval-head (#3235): stale approval for PR #${prNumber}. ` +
|
|
354
|
+
`approved_head_sha=${approval.head_sha} != current_head_sha=${currentHeadSha}. ` +
|
|
355
|
+
"Human merge approval authorizes one immutable head; HEAD changed after approval.",
|
|
356
|
+
recovery,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** Live `gh pr merge <N> --disable-auto` (best-effort). */
|
|
360
|
+
export function disablePullRequestAutoMerge(prNumber, repo, options = {}) {
|
|
361
|
+
let binary;
|
|
362
|
+
try {
|
|
363
|
+
binary = resolveBinary();
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return { ok: false, stderr: "gh CLI not found" };
|
|
367
|
+
}
|
|
368
|
+
const args = ["pr", "merge", String(prNumber), "--disable-auto"];
|
|
369
|
+
if (repo !== null && repo.length > 0) {
|
|
370
|
+
args.push("--repo", repo);
|
|
371
|
+
}
|
|
372
|
+
const result = captureExec(binary, args, (options.timeoutSec ?? 60) * 1000);
|
|
373
|
+
// gh returns 0 when disabled, and may return non-zero if auto-merge was not enabled
|
|
374
|
+
// ("auto-merge is not enabled") — treat that as ok (already off).
|
|
375
|
+
const stderr = result.stderr.trim();
|
|
376
|
+
if (result.returncode === 0) {
|
|
377
|
+
return { ok: true, stderr };
|
|
378
|
+
}
|
|
379
|
+
const low = stderr.toLowerCase();
|
|
380
|
+
if (low.includes("not enabled") ||
|
|
381
|
+
low.includes("auto-merge is disabled") ||
|
|
382
|
+
low.includes("is not set to auto-merge")) {
|
|
383
|
+
return { ok: true, stderr };
|
|
384
|
+
}
|
|
385
|
+
return { ok: false, stderr };
|
|
386
|
+
}
|
|
387
|
+
/** REST HEAD read via `gh api repos/.../pulls/N --jq .head.sha`. */
|
|
388
|
+
export function fetchPrHeadShaRest(prNumber, repo, options = {}) {
|
|
389
|
+
if (repo === null || repo.length === 0)
|
|
390
|
+
return null;
|
|
391
|
+
let binary;
|
|
392
|
+
try {
|
|
393
|
+
binary = resolveBinary();
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
const endpoint = `repos/${repo}/pulls/${prNumber}`;
|
|
399
|
+
const result = captureExec(binary, ["api", endpoint, "--jq", ".head.sha"], (options.timeoutSec ?? 60) * 1000);
|
|
400
|
+
if (result.returncode !== 0)
|
|
401
|
+
return null;
|
|
402
|
+
return normalizeSha(result.stdout.trim());
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Evaluate + on deny attempt to disable GitHub auto-merge and attach recovery.
|
|
406
|
+
*/
|
|
407
|
+
export function enforceMergeApprovalHead(input) {
|
|
408
|
+
let currentHeadSha = input.currentHeadSha;
|
|
409
|
+
if ((currentHeadSha === null || currentHeadSha === undefined || currentHeadSha.trim() === "") &&
|
|
410
|
+
input.fetchHeadShaFn !== undefined) {
|
|
411
|
+
currentHeadSha = input.fetchHeadShaFn(input.prNumber, input.repo ?? null);
|
|
412
|
+
}
|
|
413
|
+
const evaluated = evaluateMergeApprovalHead({
|
|
414
|
+
...input,
|
|
415
|
+
currentHeadSha: currentHeadSha ?? null,
|
|
416
|
+
});
|
|
417
|
+
if (evaluated.allowed) {
|
|
418
|
+
return evaluated;
|
|
419
|
+
}
|
|
420
|
+
const shouldDisable = input.disableAutoMergeOnDeny !== false;
|
|
421
|
+
let autoMergeDisabled = null;
|
|
422
|
+
if (shouldDisable) {
|
|
423
|
+
const disableFn = input.disableAutoMergeFn ?? disablePullRequestAutoMerge;
|
|
424
|
+
const disabled = disableFn(input.prNumber, input.repo ?? null);
|
|
425
|
+
autoMergeDisabled = disabled.ok;
|
|
426
|
+
}
|
|
427
|
+
const recovery = buildMergeApprovalRecovery({
|
|
428
|
+
prNumber: input.prNumber,
|
|
429
|
+
approvedHeadSha: evaluated.approved_head_sha,
|
|
430
|
+
currentHeadSha: evaluated.current_head_sha,
|
|
431
|
+
status: evaluated.status,
|
|
432
|
+
autoMergeDisabled,
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
...evaluated,
|
|
436
|
+
auto_merge_disabled: autoMergeDisabled,
|
|
437
|
+
message: evaluated.message,
|
|
438
|
+
recovery,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/** Strip trailing path separators without regex (CodeQL-safe). */
|
|
442
|
+
function trimTrailingSeparators(path) {
|
|
443
|
+
let end = path.length;
|
|
444
|
+
while (end > 0) {
|
|
445
|
+
const ch = path.charCodeAt(end - 1);
|
|
446
|
+
if (ch === 47 || ch === 92) {
|
|
447
|
+
// / or \
|
|
448
|
+
end -= 1;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
return path.slice(0, end);
|
|
454
|
+
}
|
|
455
|
+
/** Default event log path relative to project root. */
|
|
456
|
+
export function defaultMergeApprovalEventLog(projectRoot) {
|
|
457
|
+
const root = trimTrailingSeparators(projectRoot);
|
|
458
|
+
const log = DEFAULT_EVENT_LOG.split("\\").join("/");
|
|
459
|
+
return `${root}/${log}`;
|
|
460
|
+
}
|
|
461
|
+
//# sourceMappingURL=merge-approval-head.js.map
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* (3) branch-protection / setup docs require ≥1 human reviewer
|
|
9
9
|
*
|
|
10
10
|
* Override: `task policy:allow-bot-merge -- --confirm` + `DEFT_ALLOW_BOT_MERGE=1`.
|
|
11
|
+
*
|
|
12
|
+
* Head-bound Phase 5→6 approval (#3235): when a `plan:approved` event exists,
|
|
13
|
+
* merge paths also enforce `approved_head_sha == current_head_sha` via
|
|
14
|
+
* `merge-approval-head.ts` (stale head → fail closed + disable auto-merge).
|
|
15
|
+
* That gate is complementary to this policy surface and does not replace it.
|
|
11
16
|
*/
|
|
12
17
|
export declare const FIELD_REQUIRE_HUMAN_MERGE = "plan.policy.requireHumanMerge";
|
|
13
18
|
export declare const FIELD_REQUIRE_HUMAN_MERGE_CLI_ALIAS = "requireHumanMerge";
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* (3) branch-protection / setup docs require ≥1 human reviewer
|
|
9
9
|
*
|
|
10
10
|
* Override: `task policy:allow-bot-merge -- --confirm` + `DEFT_ALLOW_BOT_MERGE=1`.
|
|
11
|
+
*
|
|
12
|
+
* Head-bound Phase 5→6 approval (#3235): when a `plan:approved` event exists,
|
|
13
|
+
* merge paths also enforce `approved_head_sha == current_head_sha` via
|
|
14
|
+
* `merge-approval-head.ts` (stale head → fail closed + disable auto-merge).
|
|
15
|
+
* That gate is complementary to this policy surface and does not replace it.
|
|
11
16
|
*/
|
|
12
17
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
18
|
import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CheckRunRecord, type RequiredStatusContext } from "./gh.js";
|
|
2
2
|
/**
|
|
3
3
|
* Bot-reviewer check-runs are not CI workflow jobs. Presence of only these
|
|
4
4
|
* (or empty check-runs) is the `ci_never_scheduled` weather signal (#3167).
|
|
@@ -15,7 +15,9 @@ export declare function suiteFamilyOf(name: string): "typescript" | "go" | null;
|
|
|
15
15
|
* not authoritative: a green primary must not clear a cancelled aggregator.
|
|
16
16
|
*/
|
|
17
17
|
export declare function isAuthoritativeSuiteAggregator(name: string): boolean;
|
|
18
|
-
export type CiReadyState = "ready" | "blocked" | "ci_failures" | "not_ready_yet" | "runner_capacity_stall" | "ci_never_scheduled" | "ci_cancelled_no_failover"
|
|
18
|
+
export type CiReadyState = "ready" | "blocked" | "ci_failures" | "not_ready_yet" | "runner_capacity_stall" | "ci_never_scheduled" | "ci_cancelled_no_failover"
|
|
19
|
+
/** Ruleset/BP required context with no check-run on HEAD (#3234). */
|
|
20
|
+
| "ci_absent_required" | "skipped";
|
|
19
21
|
export interface CiGateOptions {
|
|
20
22
|
readonly skipCi?: boolean;
|
|
21
23
|
readonly ignoreCheckNames?: readonly string[];
|
|
@@ -23,6 +25,13 @@ export interface CiGateOptions {
|
|
|
23
25
|
readonly capacityStallBudgetMs?: number;
|
|
24
26
|
/** Injectable clock for capacity-stall tests. */
|
|
25
27
|
readonly nowMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Required status-check contexts from rulesets / branch protection (#3234).
|
|
30
|
+
* Strings or `{ name, appId? }` specs. When set, a context with no matching
|
|
31
|
+
* exact-HEAD check-run (name + optional app id) is fail-closed (path-filtered
|
|
32
|
+
* workflows never schedule). Empty/omitted preserves inventory-only.
|
|
33
|
+
*/
|
|
34
|
+
readonly requiredContexts?: readonly (string | RequiredStatusContext)[];
|
|
26
35
|
}
|
|
27
36
|
export interface CiCheckConclusion {
|
|
28
37
|
readonly name: string;
|
|
@@ -41,6 +50,13 @@ export interface CiGateSummary {
|
|
|
41
50
|
readonly capacity_stalled_required: readonly string[];
|
|
42
51
|
/** Required checks cancelled without a green failover sibling (#3167). */
|
|
43
52
|
readonly cancelled_required: readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* Ruleset/BP required contexts with no matching check-run on HEAD (#3234).
|
|
55
|
+
* Distinct from pending (never scheduled vs still running).
|
|
56
|
+
*/
|
|
57
|
+
readonly absent_required: readonly string[];
|
|
58
|
+
/** Full required-context list used for the comparison (may be empty). */
|
|
59
|
+
readonly required_contexts: readonly string[];
|
|
44
60
|
readonly conclusions: readonly CiCheckConclusion[];
|
|
45
61
|
}
|
|
46
62
|
export interface CiGateResult {
|
|
@@ -49,13 +65,14 @@ export interface CiGateResult {
|
|
|
49
65
|
}
|
|
50
66
|
export declare function buildCiSummaryLine(summary: CiGateSummary): string;
|
|
51
67
|
/**
|
|
52
|
-
* Classify required CI weather for a HEAD check-run list (#2169 / #2672 / #3167).
|
|
68
|
+
* Classify required CI weather for a HEAD check-run list (#2169 / #2672 / #3167 / #3234).
|
|
53
69
|
*
|
|
54
70
|
* Distinct ready_state codes:
|
|
55
71
|
* - `ci_never_scheduled` — no CI workflow check-runs (bots alone / empty)
|
|
56
72
|
* - `runner_capacity_stall` — queued past budget, no runner claimed
|
|
57
73
|
* - `ci_failures` — completed failure / timed_out with product evidence
|
|
58
74
|
* - `ci_cancelled_no_failover` — only cancelled failures; no green failover
|
|
75
|
+
* - `ci_absent_required` — ruleset/BP required context missing on HEAD (#3234)
|
|
59
76
|
* - `not_ready_yet` / `ready` / `blocked` / `skipped` — legacy + residual
|
|
60
77
|
*/
|
|
61
78
|
export declare function evaluateCiGate(checkRuns: readonly CheckRunRecord[], options?: CiGateOptions): CiGateResult;
|