@patronage/software-factory 0.25.0 → 0.30.0-alpha.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.
- package/CONTEXT.md +1 -1
- package/README.md +5 -5
- package/dist/index.d.ts +284 -392
- package/dist/index.js +1149 -1099
- package/dist/schemas.d.ts +60 -168
- package/dist/schemas.js +39 -70
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import picomatch from "picomatch";
|
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
import { parse } from "yaml";
|
|
20
20
|
//#region package.json
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.30.0-alpha.1";
|
|
22
22
|
//#endregion
|
|
23
23
|
//#region src/review-rungs.ts
|
|
24
24
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -45,6 +45,7 @@ const FACTORY_BOUNDARY_FENCE_LANG = "factory-boundary";
|
|
|
45
45
|
const BOUNDARY_CLOSEOUT_DEFAULT_RUNG = "oracle";
|
|
46
46
|
const rungMeetsMinimum = (rung, minimum) => RUNG_ORDER[rung] >= RUNG_ORDER[minimum];
|
|
47
47
|
const rungSchema = z.enum(EVIDENCE_REVIEW_RUNGS$1);
|
|
48
|
+
const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}(?:\[[bB][oO][tT]\])?$/u;
|
|
48
49
|
const waveSchema = z.object({
|
|
49
50
|
autoMerge: z.boolean().optional(),
|
|
50
51
|
issues: z.array(z.number().int().positive()).min(1),
|
|
@@ -54,7 +55,7 @@ const waveSchema = z.object({
|
|
|
54
55
|
const boundaryManifestSchema = z.object({
|
|
55
56
|
boundary: z.string().min(1),
|
|
56
57
|
closeout: z.object({ review: rungSchema }).passthrough().optional(),
|
|
57
|
-
declaredBy: z.string().
|
|
58
|
+
declaredBy: z.string().regex(GITHUB_LOGIN_PATTERN, { message: "must be a GitHub login (letters, digits, single hyphens, max 39 chars), optionally suffixed with \"[bot]\" for a GitHub App identity — not an email address or display name" }),
|
|
58
59
|
prs: z.record(z.string().regex(/^[0-9]+$/u), z.string().min(1)).optional(),
|
|
59
60
|
schemaVersion: z.literal(1),
|
|
60
61
|
topology: z.enum([
|
|
@@ -132,16 +133,17 @@ const closeoutRungFor = (manifest) => manifest.closeout?.review ?? "oracle";
|
|
|
132
133
|
//#region src/boundary-review-proof.ts
|
|
133
134
|
var boundary_review_proof_exports = /* @__PURE__ */ __exportAll({
|
|
134
135
|
BOUNDARY_REVIEW_PROOF_KIND: () => BOUNDARY_REVIEW_PROOF_KIND$1,
|
|
136
|
+
boundaryReviewProofAuthorsSeen: () => boundaryReviewProofAuthorsSeen,
|
|
135
137
|
boundaryReviewProofSchema: () => boundaryReviewProofSchema$1,
|
|
136
138
|
selectBoundaryReviewProof: () => selectBoundaryReviewProof,
|
|
137
139
|
undispositionedBlockingFindings: () => undispositionedBlockingFindings
|
|
138
140
|
});
|
|
139
141
|
const BOUNDARY_REVIEW_PROOF_KIND$1 = "boundary-review-proof";
|
|
140
|
-
const shaSchema$
|
|
142
|
+
const shaSchema$5 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
|
|
141
143
|
const coveredSetEntrySchema$1 = z.object({
|
|
142
|
-
headSha: shaSchema$
|
|
144
|
+
headSha: shaSchema$5.optional(),
|
|
143
145
|
issue: z.number().int().positive().optional(),
|
|
144
|
-
mergedSha: shaSchema$
|
|
146
|
+
mergedSha: shaSchema$5.optional(),
|
|
145
147
|
pr: z.number().int().positive(),
|
|
146
148
|
state: z.enum(["merged", "open"]),
|
|
147
149
|
wave: z.string().min(1).optional()
|
|
@@ -186,12 +188,11 @@ const boundaryReviewProofSchema$1 = z.object({
|
|
|
186
188
|
}).passthrough().optional()
|
|
187
189
|
}).passthrough();
|
|
188
190
|
const FENCED_JSON_PATTERN = /```json[ \t]*\r?\n(?<inner>[\s\S]*?)```/gu;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
const declaredByLower = declaredBy.toLowerCase();
|
|
191
|
+
const collectValidBoundaryReviewProofs = (comments, boundary) => {
|
|
192
|
+
const entries = [];
|
|
192
193
|
for (const comment of comments) {
|
|
193
194
|
const commentAuthor = comment.author.login;
|
|
194
|
-
if (!commentAuthor
|
|
195
|
+
if (!commentAuthor) continue;
|
|
195
196
|
for (const match of comment.body.matchAll(FENCED_JSON_PATTERN)) {
|
|
196
197
|
const inner = match.groups?.inner ?? "";
|
|
197
198
|
let raw;
|
|
@@ -203,15 +204,34 @@ const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
|
|
|
203
204
|
if (typeof raw !== "object" || raw === null || raw.kind !== "boundary-review-proof") continue;
|
|
204
205
|
const parsed = boundaryReviewProofSchema$1.safeParse(raw);
|
|
205
206
|
if (!parsed.success || parsed.data.boundary !== boundary) continue;
|
|
206
|
-
|
|
207
|
+
entries.push({
|
|
208
|
+
commentAuthor,
|
|
207
209
|
...comment.id === void 0 ? {} : { commentId: comment.id },
|
|
208
210
|
...comment.url === void 0 ? {} : { commentUrl: comment.url },
|
|
209
211
|
proof: parsed.data
|
|
210
|
-
};
|
|
212
|
+
});
|
|
211
213
|
}
|
|
212
214
|
}
|
|
215
|
+
return entries;
|
|
216
|
+
};
|
|
217
|
+
const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
|
|
218
|
+
let selected;
|
|
219
|
+
const declaredByLower = declaredBy.toLowerCase();
|
|
220
|
+
for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) {
|
|
221
|
+
if (entry.commentAuthor.toLowerCase() !== declaredByLower) continue;
|
|
222
|
+
selected = {
|
|
223
|
+
...entry.commentId === void 0 ? {} : { commentId: entry.commentId },
|
|
224
|
+
...entry.commentUrl === void 0 ? {} : { commentUrl: entry.commentUrl },
|
|
225
|
+
proof: entry.proof
|
|
226
|
+
};
|
|
227
|
+
}
|
|
213
228
|
return selected;
|
|
214
229
|
};
|
|
230
|
+
const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
|
|
231
|
+
const authors = /* @__PURE__ */ new Set();
|
|
232
|
+
for (const entry of collectValidBoundaryReviewProofs(comments, boundary)) authors.add(entry.commentAuthor);
|
|
233
|
+
return [...authors];
|
|
234
|
+
};
|
|
215
235
|
const undispositionedBlockingFindings = (proof) => proof.findings.filter((finding) => (finding.category !== "maintainability" || finding.blockingAfterCap === true) && finding.disposition === void 0).map((finding) => `boundary finding "${finding.title}" (category ${finding.category ?? "unknown"}) has no disposition; fix it or record waived / follow-up-filed`);
|
|
216
236
|
//#endregion
|
|
217
237
|
//#region src/comment-provenance.ts
|
|
@@ -692,7 +712,7 @@ function runInheritedTee(command, args, cwd, extraEnv = {}) {
|
|
|
692
712
|
function currentHeadSha(cwd) {
|
|
693
713
|
return runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim();
|
|
694
714
|
}
|
|
695
|
-
function showFileAtRef
|
|
715
|
+
function showFileAtRef(cwd, ref, absolutePath) {
|
|
696
716
|
const rel = path.relative(cwd, absolutePath);
|
|
697
717
|
const spec = rel.startsWith(".") ? rel : `./${rel}`;
|
|
698
718
|
try {
|
|
@@ -979,38 +999,23 @@ const DIFF_CLASSIFICATIONS$1 = [
|
|
|
979
999
|
//#endregion
|
|
980
1000
|
//#region src/required-check-policy.ts
|
|
981
1001
|
const EVIDENCE_CHECK_TYPES$1 = ["review", "verify"];
|
|
982
|
-
const requiredCheckScopeSchema$1 = z.object({
|
|
983
|
-
classifications: z.array(z.enum(DIFF_CLASSIFICATIONS$1)).min(1).optional(),
|
|
984
|
-
labels: z.array(z.string().min(1)).min(1).optional()
|
|
985
|
-
}).strict().superRefine((scope, context) => {
|
|
986
|
-
if (scope.labels === void 0 && scope.classifications === void 0) context.addIssue({
|
|
987
|
-
code: "custom",
|
|
988
|
-
message: "requiredChecks scope must declare at least one condition (labels and/or classifications)."
|
|
989
|
-
});
|
|
990
|
-
}).meta({ anyOf: [{ required: ["classifications"] }, { required: ["labels"] }] });
|
|
1002
|
+
const requiredCheckScopeSchema$1 = z.object({ classifications: z.array(z.enum(DIFF_CLASSIFICATIONS$1)).min(1) }).strict();
|
|
991
1003
|
const requiredCheckInScope = ({ context, scope }) => {
|
|
992
1004
|
if (!scope) return {
|
|
993
1005
|
inScope: true,
|
|
994
1006
|
reason: "unconditional: no scope declared"
|
|
995
1007
|
};
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
else {
|
|
1000
|
-
const present = scope.labels.filter((label) => context.labels?.includes(label));
|
|
1001
|
-
if (present.length > 0) held.push(`scoping label(s) present: ${present.join(", ")}`);
|
|
1002
|
-
else failed.push(`none of the scoping labels are on the PR: ${scope.labels.join(", ")}`);
|
|
1003
|
-
}
|
|
1004
|
-
if (scope.classifications !== void 0) if (context.classification === void 0) held.push(`classification unavailable; fail closed (scoping classifications [${scope.classifications.join(", ")}] treated as in force)`);
|
|
1005
|
-
else if (scope.classifications.includes(context.classification)) held.push(`classification "${context.classification}" is in scope`);
|
|
1006
|
-
else failed.push(`classification "${context.classification}" is not one of: ${scope.classifications.join(", ")}`);
|
|
1007
|
-
if (failed.length > 0) return {
|
|
1008
|
-
inScope: false,
|
|
1009
|
-
reason: `out of scope — ${failed.join("; ")}`
|
|
1008
|
+
if (context.classification === void 0) return {
|
|
1009
|
+
inScope: true,
|
|
1010
|
+
reason: `classification unavailable; fail closed (scoping classifications [${scope.classifications.join(", ")}] treated as in force)`
|
|
1010
1011
|
};
|
|
1011
|
-
return {
|
|
1012
|
+
if (scope.classifications.includes(context.classification)) return {
|
|
1012
1013
|
inScope: true,
|
|
1013
|
-
reason: `in scope — ${
|
|
1014
|
+
reason: `in scope — classification "${context.classification}" is in scope`
|
|
1015
|
+
};
|
|
1016
|
+
return {
|
|
1017
|
+
inScope: false,
|
|
1018
|
+
reason: `out of scope — classification "${context.classification}" is not one of: ${scope.classifications.join(", ")}`
|
|
1014
1019
|
};
|
|
1015
1020
|
};
|
|
1016
1021
|
//#endregion
|
|
@@ -3465,7 +3470,7 @@ const runCloseoutFsWorker = async (data, budgetMs) => {
|
|
|
3465
3470
|
};
|
|
3466
3471
|
const isIsolatedReadResult = (value) => value !== null && typeof value === "object" && value.status === "read";
|
|
3467
3472
|
const deferProjectHqIngestIsolated = async (input) => {
|
|
3468
|
-
const setupDeadline = performance.now() +
|
|
3473
|
+
const setupDeadline = performance.now() + timeoutFor({ env: process.env });
|
|
3469
3474
|
let profilePath;
|
|
3470
3475
|
try {
|
|
3471
3476
|
profilePath = input.profilePath ? path.resolve(input.cwd, input.profilePath) : defaultProfilePath(input.cwd);
|
|
@@ -3557,7 +3562,7 @@ const deferProjectHqIngestIsolated = async (input) => {
|
|
|
3557
3562
|
root: layout.root,
|
|
3558
3563
|
segments: layout.segments,
|
|
3559
3564
|
type: "persist"
|
|
3560
|
-
},
|
|
3565
|
+
}, journalFlushBudgetFor({ env: process.env }));
|
|
3561
3566
|
return persisted !== null && typeof persisted === "object" && persisted.status === "persisted" ? "deferred" : "held";
|
|
3562
3567
|
};
|
|
3563
3568
|
async function deferProjectHqIngest(input, dependencies = {}) {
|
|
@@ -3615,6 +3620,13 @@ function assertCleanWorktreeForProof({ changedFiles, cwd, dirtyMessage, statusPo
|
|
|
3615
3620
|
//#region src/demand-keys.ts
|
|
3616
3621
|
/** The demands that exist at most once per candidate. */
|
|
3617
3622
|
const DEMAND_KEYS = {
|
|
3623
|
+
/**
|
|
3624
|
+
* The boundary wave that authorizes this candidate's merge schedule (#477).
|
|
3625
|
+
* In force exactly when GitHub already has auto-merge enabled: a live
|
|
3626
|
+
* schedule with no wave demand resolved is an unauthorized merge waiting to
|
|
3627
|
+
* happen.
|
|
3628
|
+
*/
|
|
3629
|
+
boundaryWave: "boundary-wave",
|
|
3618
3630
|
/** The PR is still a draft. */
|
|
3619
3631
|
draft: "draft",
|
|
3620
3632
|
/** The candidate is not the intended final human review point. */
|
|
@@ -3629,6 +3641,12 @@ const DEMAND_KEYS = {
|
|
|
3629
3641
|
mergeFreeze: "merge-freeze",
|
|
3630
3642
|
/** GitHub's mergeability / merge-state rollup. */
|
|
3631
3643
|
mergeState: "merge-state",
|
|
3644
|
+
/**
|
|
3645
|
+
* A native GitHub approval, demanded when the boundary wave in force does
|
|
3646
|
+
* not authorize machine merge (#477). "Green = authorized to merge" is then
|
|
3647
|
+
* a property of the branded required check itself.
|
|
3648
|
+
*/
|
|
3649
|
+
nativeApproval: "native-approval",
|
|
3632
3650
|
/** The PR body's required rendered sections. */
|
|
3633
3651
|
prBodySections: "pr-body-sections",
|
|
3634
3652
|
/** A current, head-bound, passing typed `pr:verify` proof. */
|
|
@@ -3659,7 +3677,7 @@ const percentEncode = (character) => {
|
|
|
3659
3677
|
* demand nobody waived, and make HQ count two causes as one.
|
|
3660
3678
|
*
|
|
3661
3679
|
* Deterministic in both directions of use: the key a `pr:ready` proof records
|
|
3662
|
-
* is the key a `pr:
|
|
3680
|
+
* is the key a `pr:ready` waiver matches.
|
|
3663
3681
|
*/
|
|
3664
3682
|
const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
|
|
3665
3683
|
/** The demand one profile-declared external required check makes. */
|
|
@@ -3743,7 +3761,7 @@ const demandKeySchema = z.string().refine((value) => {
|
|
|
3743
3761
|
* reason codes must come from. A waiver store is durable operator evidence
|
|
3744
3762
|
* written before today's vocabulary existed, so tightening its validation
|
|
3745
3763
|
* would make an existing record parse as no waiver and let the next write drop
|
|
3746
|
-
* it. A waiver naming a key nobody resolves is already inert — `pr:
|
|
3764
|
+
* it. A waiver naming a key nobody resolves is already inert — `pr:ready`
|
|
3747
3765
|
* reports it as an unapplied waiver — so nothing is admitted by accepting it.
|
|
3748
3766
|
*/
|
|
3749
3767
|
const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
|
|
@@ -3818,10 +3836,10 @@ const resolveAuthoringSessionIds = ({ override, recorded }) => {
|
|
|
3818
3836
|
//#region src/demand-waiver.ts
|
|
3819
3837
|
const DEMAND_WAIVER_SCHEMA_VERSION$1 = 1;
|
|
3820
3838
|
const DEFAULT_DEMAND_WAIVER_PATH = ".factory-memory/demand-waivers.json";
|
|
3821
|
-
const shaSchema$
|
|
3839
|
+
const shaSchema$4 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
3822
3840
|
const demandWaiverSchema = z.object({
|
|
3823
3841
|
candidate: z.object({
|
|
3824
|
-
headSha: shaSchema$
|
|
3842
|
+
headSha: shaSchema$4,
|
|
3825
3843
|
pr: z.number().int().positive()
|
|
3826
3844
|
}),
|
|
3827
3845
|
demand: waiverDemandKeySchema,
|
|
@@ -4673,66 +4691,7 @@ const boundaryCheckProofSchema = z.object({
|
|
|
4673
4691
|
}).optional(),
|
|
4674
4692
|
status: z.enum(["ready", "blocked"])
|
|
4675
4693
|
});
|
|
4676
|
-
const
|
|
4677
|
-
z.object({
|
|
4678
|
-
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
4679
|
-
kind: z.literal("match")
|
|
4680
|
-
}),
|
|
4681
|
-
z.object({
|
|
4682
|
-
kind: z.literal("diverged"),
|
|
4683
|
-
liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
4684
|
-
postProofCommits: z.array(z.object({
|
|
4685
|
-
sha: z.string().min(1),
|
|
4686
|
-
subject: z.string()
|
|
4687
|
-
})).optional(),
|
|
4688
|
-
proofHeadSha: z.string().regex(/^[0-9a-f]{40}$/u)
|
|
4689
|
-
}),
|
|
4690
|
-
z.object({
|
|
4691
|
-
kind: z.literal("live-head-invalid"),
|
|
4692
|
-
pr: z.number().int().positive(),
|
|
4693
|
-
received: z.string()
|
|
4694
|
-
}),
|
|
4695
|
-
z.object({
|
|
4696
|
-
errorDetail: z.string().optional(),
|
|
4697
|
-
kind: z.literal("ready-proof-missing"),
|
|
4698
|
-
readyProofPath: z.string().min(1)
|
|
4699
|
-
}),
|
|
4700
|
-
z.object({
|
|
4701
|
-
kind: z.literal("ready-proof-pr-mismatch"),
|
|
4702
|
-
proofPr: z.number().int().positive(),
|
|
4703
|
-
requestedPr: z.number().int().positive()
|
|
4704
|
-
}),
|
|
4705
|
-
z.object({
|
|
4706
|
-
blockingReasons: z.array(z.string()),
|
|
4707
|
-
kind: z.literal("ready-proof-not-ready"),
|
|
4708
|
-
status: z.string().min(1)
|
|
4709
|
-
})
|
|
4710
|
-
]);
|
|
4711
|
-
z.object({
|
|
4712
|
-
blockingReasons: z.array(z.string()),
|
|
4713
|
-
command: z.literal("patronage-factory pr:merge-check"),
|
|
4714
|
-
followUp: followUpActionSchema.optional(),
|
|
4715
|
-
identity: mergeGuardIdentitySchema$1,
|
|
4716
|
-
liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
4717
|
-
notices: z.array(z.string()).optional(),
|
|
4718
|
-
pr: z.number().int().positive(),
|
|
4719
|
-
schemaVersion: z.literal(1),
|
|
4720
|
-
status: z.enum(["pass", "fail"]),
|
|
4721
|
-
waivedDemands: z.array(waivedDemandSchema).optional(),
|
|
4722
|
-
worktreeHeldBranch: z.object({
|
|
4723
|
-
branch: z.string().min(1),
|
|
4724
|
-
worktreePath: z.string().min(1)
|
|
4725
|
-
}).optional()
|
|
4726
|
-
});
|
|
4727
|
-
const requiredCheckScopeSchema = z.object({
|
|
4728
|
-
classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1).optional(),
|
|
4729
|
-
labels: z.array(z.string().min(1)).min(1).optional()
|
|
4730
|
-
}).strict().superRefine((scope, context) => {
|
|
4731
|
-
if (scope.labels === void 0 && scope.classifications === void 0) context.addIssue({
|
|
4732
|
-
code: "custom",
|
|
4733
|
-
message: "requiredChecks scope must declare at least one condition (labels and/or classifications)."
|
|
4734
|
-
});
|
|
4735
|
-
});
|
|
4694
|
+
const requiredCheckScopeSchema = z.object({ classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1) }).strict();
|
|
4736
4695
|
const readinessRepairSchema$1 = z.object({
|
|
4737
4696
|
action: z.string().min(1),
|
|
4738
4697
|
code: z.enum([
|
|
@@ -4908,19 +4867,34 @@ const managedReadinessLedgerSchema$1 = z.object({
|
|
|
4908
4867
|
})
|
|
4909
4868
|
});
|
|
4910
4869
|
/**
|
|
4911
|
-
* The pr:ready proof versions a reader still accepts. `pr:ready` emits
|
|
4912
|
-
* (#
|
|
4913
|
-
* and HQ must ingest them without degrading, so the wire schema parses
|
|
4870
|
+
* The pr:ready proof versions a reader still accepts. `pr:ready` emits v3 only
|
|
4871
|
+
* (#477) — one current contract — but v1 and v2 events were spooled before the
|
|
4872
|
+
* bumps and HQ must ingest them without degrading, so the wire schema parses
|
|
4873
|
+
* all three.
|
|
4914
4874
|
*/
|
|
4915
|
-
const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [
|
|
4875
|
+
const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [
|
|
4876
|
+
1,
|
|
4877
|
+
2,
|
|
4878
|
+
3
|
|
4879
|
+
];
|
|
4916
4880
|
const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
|
|
4917
4881
|
z.object({
|
|
4882
|
+
arming: z.object({
|
|
4883
|
+
detail: z.string().min(1).optional(),
|
|
4884
|
+
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
4885
|
+
outcome: z.enum([
|
|
4886
|
+
"armed",
|
|
4887
|
+
"merged",
|
|
4888
|
+
"not-armed"
|
|
4889
|
+
])
|
|
4890
|
+
}).optional(),
|
|
4918
4891
|
blockedReasons: blockedReasonsSchema.optional(),
|
|
4919
4892
|
blockingReasons: z.array(z.string()),
|
|
4920
4893
|
command: z.literal("patronage-factory pr:ready"),
|
|
4921
4894
|
followUp: followUpActionSchema.optional(),
|
|
4922
4895
|
humanBlockingReasons: z.array(z.string()).default([]),
|
|
4923
4896
|
ledger: managedReadinessLedgerSchema$1,
|
|
4897
|
+
notices: z.array(z.string().min(1)).optional(),
|
|
4924
4898
|
profileBlobSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
4925
4899
|
profilePath: z.string().min(1).optional(),
|
|
4926
4900
|
repairs: z.array(readinessRepairSchema$1).default([]),
|
|
@@ -4930,7 +4904,8 @@ z.object({
|
|
|
4930
4904
|
"ready",
|
|
4931
4905
|
"blocked",
|
|
4932
4906
|
"slice-ready/not-final"
|
|
4933
|
-
])
|
|
4907
|
+
]),
|
|
4908
|
+
waivedDemands: z.array(waivedDemandSchema).optional()
|
|
4934
4909
|
}).superRefine((proof, context) => {
|
|
4935
4910
|
if (proof.schemaVersion < 2) {
|
|
4936
4911
|
if (proof.blockedReasons !== void 0) context.addIssue({
|
|
@@ -5093,8 +5068,11 @@ const defaultResolveRepo = (cwd) => runCapture("gh", [
|
|
|
5093
5068
|
"--jq",
|
|
5094
5069
|
".nameWithOwner"
|
|
5095
5070
|
], cwd).stdout.trim();
|
|
5096
|
-
const evaluateProofAgainstDemands = ({ computed, demandedRung, manifestHash, selected }) => {
|
|
5097
|
-
if (!selected)
|
|
5071
|
+
const evaluateProofAgainstDemands = ({ authorsSeen, computed, declaredBy, demandedRung, manifestHash, selected }) => {
|
|
5072
|
+
if (!selected) {
|
|
5073
|
+
if (authorsSeen.length === 0) return { blockingReasons: [`no boundary-review proof found on the epic issue; run the boundary review at the ${demandedRung} rung and post its fenced JSON proof comment — absence fails closed`] };
|
|
5074
|
+
return { blockingReasons: [`boundary-review proof block(s) found on the epic issue, but none authored by declaredBy "${declaredBy}" (authors seen: ${authorsSeen.join(", ")}); run the boundary review at the ${demandedRung} rung from that account, or correct declaredBy if it names the wrong one`] };
|
|
5075
|
+
}
|
|
5098
5076
|
const { proof } = selected;
|
|
5099
5077
|
const blockingReasons = [];
|
|
5100
5078
|
if (proof.manifestHash.value !== manifestHash) blockingReasons.push(`boundary-review proof was minted against a different manifest (proof hash ${proof.manifestHash.value.slice(0, 12)}…, current ${manifestHash.slice(0, 12)}…); the manifest changed since review — re-run the boundary review`);
|
|
@@ -5116,9 +5094,12 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
5116
5094
|
if (parsed.ok) {
|
|
5117
5095
|
const demandedRung = closeoutRungFor(parsed.manifest);
|
|
5118
5096
|
const computed = computeCoveredSet(parsed.manifest, repo, github);
|
|
5119
|
-
const
|
|
5097
|
+
const comments = github.fetchIssueComments(repo, args.epic);
|
|
5098
|
+
const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
|
|
5120
5099
|
const { blockingReasons } = evaluateProofAgainstDemands({
|
|
5100
|
+
authorsSeen: selected ? [] : boundaryReviewProofAuthorsSeen(comments, parsed.manifest.boundary),
|
|
5121
5101
|
computed,
|
|
5102
|
+
declaredBy: parsed.manifest.declaredBy,
|
|
5122
5103
|
demandedRung,
|
|
5123
5104
|
manifestHash: parsed.manifestHash,
|
|
5124
5105
|
selected
|
|
@@ -7122,6 +7103,21 @@ const FACTORY_CHECK_NAMES = {
|
|
|
7122
7103
|
"pr-verify": "patronage-factory/pr-verify"
|
|
7123
7104
|
};
|
|
7124
7105
|
const hqLaneRefUrl = (base, repo, number) => `${base}/${encodeURIComponent(repo)}/${number}`;
|
|
7106
|
+
/**
|
|
7107
|
+
* Fire-and-forget publication for a check run that is a *mirror* of a local
|
|
7108
|
+
* verdict — swallowing the failure cannot change what the gate decided.
|
|
7109
|
+
*
|
|
7110
|
+
* Not every branded check is a mirror any more (#477). `patronage-factory/
|
|
7111
|
+
* pr-ready` is a source-pinned required check in the branch ruleset: a
|
|
7112
|
+
* swallowed failure there leaves a candidate armed, unmergeable, and — as epic
|
|
7113
|
+
* #473 wave 2 measured — with no rollup row saying why. So no `pr-ready`
|
|
7114
|
+
* publication comes through here at all: `pr:ready` publishes once, completed,
|
|
7115
|
+
* through {@link ensureFactoryCheckRunPublished}, whose result the caller
|
|
7116
|
+
* reads. It used to publish an in-progress run here while hosted checks
|
|
7117
|
+
* settled, on the reasoning that a missing in-progress check cannot green
|
|
7118
|
+
* anything — true, and beside the point, because a *present* one cannot be
|
|
7119
|
+
* un-blocked and nothing ever completed it (#526).
|
|
7120
|
+
*/
|
|
7125
7121
|
function publishFactoryCheckSafely(publisher, input) {
|
|
7126
7122
|
try {
|
|
7127
7123
|
publisher?.(input);
|
|
@@ -7349,7 +7345,9 @@ const checkRunRequestBody = (input, detailsUrl) => {
|
|
|
7349
7345
|
* means the credentials are wrong, and re-sending them cannot help.
|
|
7350
7346
|
*
|
|
7351
7347
|
* Throws the last error when every attempt fails; callers decide whether to
|
|
7352
|
-
* fall back or to swallow.
|
|
7348
|
+
* fall back or to swallow. Returns the created check run, whose `id` is the
|
|
7349
|
+
* only handle on *which* run this call produced — what the read-back below
|
|
7350
|
+
* requires the served run to be.
|
|
7353
7351
|
*/
|
|
7354
7352
|
async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUrl, input, remainingAttempts }) {
|
|
7355
7353
|
const { attempts, budgetMs, delayMs } = dependencies.retry ?? DEFAULT_CHECK_RUN_RETRY;
|
|
@@ -7360,7 +7358,7 @@ async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUr
|
|
|
7360
7358
|
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7361
7359
|
try {
|
|
7362
7360
|
const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), timeoutMs);
|
|
7363
|
-
await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
|
|
7361
|
+
return await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/check-runs`, {
|
|
7364
7362
|
body: JSON.stringify(checkRunRequestBody(input, detailsUrl)),
|
|
7365
7363
|
headers: {
|
|
7366
7364
|
Authorization: `Bearer ${token}`,
|
|
@@ -7371,7 +7369,7 @@ async function postFactoryCheckRun({ config, deadlineMs, dependencies, detailsUr
|
|
|
7371
7369
|
} catch (error) {
|
|
7372
7370
|
if (remaining <= 1 || !isRetryableCheckRunFailure(error) || deadline !== void 0 && clock() + delayMs >= deadline) throw error;
|
|
7373
7371
|
await (dependencies.sleep ?? defaultSleep)(delayMs);
|
|
7374
|
-
await postFactoryCheckRun({
|
|
7372
|
+
return await postFactoryCheckRun({
|
|
7375
7373
|
config,
|
|
7376
7374
|
deadlineMs: deadline,
|
|
7377
7375
|
dependencies,
|
|
@@ -7408,6 +7406,177 @@ async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
|
|
|
7408
7406
|
}
|
|
7409
7407
|
}
|
|
7410
7408
|
/**
|
|
7409
|
+
* Read back every run GitHub serves for this check name on this head SHA.
|
|
7410
|
+
*
|
|
7411
|
+
* `filter=all`, not `filter=latest` (#524). `latest` was chosen here on the
|
|
7412
|
+
* rationale that it is the view the required-check rollup reads; that rationale
|
|
7413
|
+
* is false, measured on PR #523's head `77cf425`. Two `patronage-factory/
|
|
7414
|
+
* pr-ready` runs existed there — `91323051988` (`in_progress`, stranded by
|
|
7415
|
+
* older code) and `91323113996` (`completed`/`success`). `latest` served the
|
|
7416
|
+
* completed one, while the pull request's rollup reported *both*
|
|
7417
|
+
* (`PENDING` and `SUCCESS`) and `mergeStateStatus` stayed `BLOCKED`. So `latest`
|
|
7418
|
+
* can report a publication confirmed while the gate still blocks on a different
|
|
7419
|
+
* run of the same name — false confidence in exactly the direction this
|
|
7420
|
+
* read-back exists to eliminate. The set the gate evaluates is every run of the
|
|
7421
|
+
* name, which is what this asks for.
|
|
7422
|
+
*
|
|
7423
|
+
* Three pins keep the answer usable as proof:
|
|
7424
|
+
*
|
|
7425
|
+
* - `app_id` — the required check is source-pinned to the factory App, so a
|
|
7426
|
+
* same-named run from another installed app (a GitHub Actions job named
|
|
7427
|
+
* `patronage-factory/pr-ready` materializes under integration 15368) is not
|
|
7428
|
+
* the pinned requirement. Left unpinned it could confirm a publication the
|
|
7429
|
+
* pinned check never got, or refuse every genuine one. This is the same
|
|
7430
|
+
* fail-closed rule `checkRunProducedByFactoryApp` applies to durable records
|
|
7431
|
+
* below. A run served without any `app` id is refused rather than filtered
|
|
7432
|
+
* out: dropping it fails closed for the newest-run clause but open for
|
|
7433
|
+
* terminality, and an unfinished run of unknown provenance still blocks the
|
|
7434
|
+
* gate.
|
|
7435
|
+
* - `per_page=100` with a required `total_count` that equals the page — a
|
|
7436
|
+
* truncated page holds neither the whole set nor a defensible newest run, and
|
|
7437
|
+
* `filter=all` returns strictly more runs than `latest` did, so this guard now
|
|
7438
|
+
* carries more weight. Truncation is unconfirmed, not paginated: a busy head
|
|
7439
|
+
* refuses rather than judging a publication against a page that silently
|
|
7440
|
+
* omits the run blocking it. An absent or malformed count cannot establish
|
|
7441
|
+
* completeness at all, so it refuses too.
|
|
7442
|
+
* - an unparsable or absent `started_at` — unorderable state is refused before
|
|
7443
|
+
* selection rather than sorting to an extreme and being chosen.
|
|
7444
|
+
*
|
|
7445
|
+
* Ordering among what survives goes through the sequencing owner rather than a
|
|
7446
|
+
* second rule here.
|
|
7447
|
+
*/
|
|
7448
|
+
async function servedFactoryCheckRun(input, config, dependencies) {
|
|
7449
|
+
const request = dependencies.fetch ?? fetch;
|
|
7450
|
+
const timeoutMs = dependencies.timeoutMs ?? 5e3;
|
|
7451
|
+
const token = await installationToken(input, config, request, (dependencies.now ?? Date.now)(), timeoutMs);
|
|
7452
|
+
const query = new URLSearchParams({
|
|
7453
|
+
app_id: String(config.appId),
|
|
7454
|
+
check_name: FACTORY_CHECK_NAMES[input.gate],
|
|
7455
|
+
filter: "all",
|
|
7456
|
+
per_page: "100"
|
|
7457
|
+
});
|
|
7458
|
+
const served = await githubJson(request, `https://api.github.com/repos/${input.owner}/${input.repo}/commits/${input.sha}/check-runs?${query.toString()}`, {
|
|
7459
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
7460
|
+
method: "GET"
|
|
7461
|
+
}, timeoutMs);
|
|
7462
|
+
const page = Array.isArray(served.check_runs) ? served.check_runs : [];
|
|
7463
|
+
const totalCount = served.total_count;
|
|
7464
|
+
if (!(typeof totalCount === "number" && Number.isSafeInteger(totalCount))) return {
|
|
7465
|
+
kind: "unavailable",
|
|
7466
|
+
reason: "the served page reports no usable total_count"
|
|
7467
|
+
};
|
|
7468
|
+
if (totalCount !== page.length) return {
|
|
7469
|
+
kind: "unavailable",
|
|
7470
|
+
reason: `${page.length} runs on the page against a total_count of ${totalCount}`
|
|
7471
|
+
};
|
|
7472
|
+
if (page.some((run) => (run.app?.id ?? null) === null)) return {
|
|
7473
|
+
kind: "unavailable",
|
|
7474
|
+
reason: "a served run has no app identity"
|
|
7475
|
+
};
|
|
7476
|
+
const runs = page.filter((run) => String(run.app?.id ?? "") === String(config.appId));
|
|
7477
|
+
const orderable = runs.flatMap((run) => {
|
|
7478
|
+
const orderMs = generationOrderMs(run.started_at);
|
|
7479
|
+
return Number.isFinite(orderMs) ? [{
|
|
7480
|
+
orderMs,
|
|
7481
|
+
run
|
|
7482
|
+
}] : [];
|
|
7483
|
+
});
|
|
7484
|
+
if (orderable.length !== runs.length) return {
|
|
7485
|
+
kind: "unavailable",
|
|
7486
|
+
reason: "a served run has no usable started_at"
|
|
7487
|
+
};
|
|
7488
|
+
const newest = selectNewestGeneration(orderable);
|
|
7489
|
+
if (newest.kind === "ambiguous") return {
|
|
7490
|
+
kind: "unavailable",
|
|
7491
|
+
reason: `${newest.tied.length} served runs share the newest started_at`
|
|
7492
|
+
};
|
|
7493
|
+
if (newest.kind === "none") return {
|
|
7494
|
+
kind: "unavailable",
|
|
7495
|
+
reason: "no run is served for this name"
|
|
7496
|
+
};
|
|
7497
|
+
return {
|
|
7498
|
+
kind: "newest",
|
|
7499
|
+
run: newest.generation.run,
|
|
7500
|
+
runs
|
|
7501
|
+
};
|
|
7502
|
+
}
|
|
7503
|
+
/** Runs GitHub has not finished. A non-terminal run blocks its required check. */
|
|
7504
|
+
const unfinishedRuns = (runs) => runs.filter((run) => run.status !== "completed");
|
|
7505
|
+
/**
|
|
7506
|
+
* Whether the served run *is* the publication that was just posted: the same
|
|
7507
|
+
* run, in the status that was asked for, and — for a completed one — with the
|
|
7508
|
+
* conclusion that was asked for.
|
|
7509
|
+
*
|
|
7510
|
+
* Requiring the id is what separates "GitHub serves a run that looks like
|
|
7511
|
+
* mine" from "GitHub serves mine". An older identical success from a previous
|
|
7512
|
+
* invocation satisfies every field comparison while this POST is still
|
|
7513
|
+
* invisible, and confirming on it is exactly the assumed-vs-live trust this
|
|
7514
|
+
* read-back exists to end.
|
|
7515
|
+
*/
|
|
7516
|
+
const publishedRunIsServed = (input, createdId, served) => {
|
|
7517
|
+
if (createdId === void 0 || served.id !== createdId) return false;
|
|
7518
|
+
const status = input.status ?? "completed";
|
|
7519
|
+
if (served.status !== status) return false;
|
|
7520
|
+
return status !== "completed" || served.conclusion === input.conclusion;
|
|
7521
|
+
};
|
|
7522
|
+
/**
|
|
7523
|
+
* Whether the required check this publication targets is *satisfied* on the
|
|
7524
|
+
* head: the newest run of the name is this call's own, and no run of the name
|
|
7525
|
+
* survives unfinished.
|
|
7526
|
+
*
|
|
7527
|
+
* The second clause is what makes the answer the *gate's* answer rather than
|
|
7528
|
+
* one collapsed view of it (#524). A stranded `in_progress` run of a
|
|
7529
|
+
* required-check name blocks its pull request permanently and is not cleared by
|
|
7530
|
+
* publishing a newer completed run, so a confirmation that ignores it is a lie
|
|
7531
|
+
* in the one direction that matters. A publication that is itself non-terminal
|
|
7532
|
+
* therefore never confirms — correctly, since it cannot satisfy the check
|
|
7533
|
+
* either. No factory gate makes one for this name any more: `pr:ready`'s
|
|
7534
|
+
* in-progress writer, the last of them, is deleted (#526).
|
|
7535
|
+
*/
|
|
7536
|
+
const servedRunConfirmsPublication = (input, createdId, selection) => publishedRunIsServed(input, createdId, selection.run) && unfinishedRuns(selection.runs).length === 0;
|
|
7537
|
+
/**
|
|
7538
|
+
* Which of the two refusals happened, reported in the order an operator can act
|
|
7539
|
+
* on: this call's own publication first, then any *other* run holding the check
|
|
7540
|
+
* open. Once the first clause holds, this call's run is terminal, so everything
|
|
7541
|
+
* the second clause names belongs to something else.
|
|
7542
|
+
*/
|
|
7543
|
+
const unconfirmedDetail = (input, createdId, selection) => {
|
|
7544
|
+
const served = selection.run;
|
|
7545
|
+
if (!publishedRunIsServed(input, createdId, served)) return served.id === createdId ? `status ${served.status ?? "unknown"}${served.conclusion ? `, conclusion ${served.conclusion}` : ""}` : `run ${served.id ?? "unknown"} rather than the run ${createdId ?? "unknown"} this call created`;
|
|
7546
|
+
return `${unfinishedRuns(selection.runs).map((run) => `run ${run.id ?? "unknown"} (${run.status ?? "unknown"})`).join(", ")} unfinished for this name alongside it — a non-terminal run blocks the required check whatever this call published, and publishing a newer one does not clear it`;
|
|
7547
|
+
};
|
|
7548
|
+
/**
|
|
7549
|
+
* Confirm the publication against what GitHub serves, and say plainly which of
|
|
7550
|
+
* the two events failed when it cannot.
|
|
7551
|
+
*
|
|
7552
|
+
* Only the read is wrapped. A token, timeout, or 5xx failure here is not a
|
|
7553
|
+
* failure to publish — the POST already succeeded and the check is probably
|
|
7554
|
+
* present — so it must not be reported through the "unable to republish"
|
|
7555
|
+
* register, which drives `pr:ready`'s "this run's verdict never reached the
|
|
7556
|
+
* required check" notice.
|
|
7557
|
+
*/
|
|
7558
|
+
async function confirmPublishedCheckRun({ config, createdId, dependencies, input, onDiagnostic }) {
|
|
7559
|
+
const checkName = FACTORY_CHECK_NAMES[input.gate];
|
|
7560
|
+
let selection;
|
|
7561
|
+
try {
|
|
7562
|
+
selection = await servedFactoryCheckRun(input, config, dependencies);
|
|
7563
|
+
} catch (error) {
|
|
7564
|
+
selection = {
|
|
7565
|
+
kind: "unavailable",
|
|
7566
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
7567
|
+
};
|
|
7568
|
+
}
|
|
7569
|
+
if (selection.kind === "unavailable") {
|
|
7570
|
+
onDiagnostic?.(`${checkName}: the check run was accepted but could not be read back for ${input.sha.slice(0, 7)} (${selection.reason}), so the publication is not confirmed. The check itself may well be present.\n`);
|
|
7571
|
+
return false;
|
|
7572
|
+
}
|
|
7573
|
+
if (!servedRunConfirmsPublication(input, createdId, selection)) {
|
|
7574
|
+
onDiagnostic?.(`${checkName}: the check run was accepted but for ${input.sha.slice(0, 7)} GitHub serves ${unconfirmedDetail(input, createdId, selection)}, so the publication is not confirmed.\n`);
|
|
7575
|
+
return false;
|
|
7576
|
+
}
|
|
7577
|
+
return true;
|
|
7578
|
+
}
|
|
7579
|
+
/**
|
|
7411
7580
|
* Publish a factory check run and *wait* for it, so a caller that has just made
|
|
7412
7581
|
* the head SHA visible on GitHub (pushed the branch, created the PR) can make
|
|
7413
7582
|
* the proof reliably present for that SHA before it returns (#247).
|
|
@@ -7416,6 +7585,28 @@ async function resolveDetailsUrlSafely(input, dependencies, onDiagnostic) {
|
|
|
7416
7585
|
* human-readable mirror, not a proof surface, so a caller that needs an
|
|
7417
7586
|
* App-verified check run must be told plainly whether it got one. Returns
|
|
7418
7587
|
* `true` only when the App-owned check run landed.
|
|
7588
|
+
*
|
|
7589
|
+
* "Landed" means GitHub serves it, not that the POST was accepted (#520). On
|
|
7590
|
+
* PR #519 the POST was accepted, `pr:ready` reported ready and armed, and the
|
|
7591
|
+
* source-pinned required check read `in_progress` across three runs — so GitHub
|
|
7592
|
+
* never scheduled the merge and emitted no rollup row saying why. Arming
|
|
7593
|
+
* already refuses to infer its outcome from the invocation and reads the pull
|
|
7594
|
+
* request back (`arm-auto-merge.ts`); publication now does the same.
|
|
7595
|
+
*
|
|
7596
|
+
* "Landed" is judged against every run of the name from the pinned App, not
|
|
7597
|
+
* against the one GitHub collapses to (#524): the newest must be *this* run, in
|
|
7598
|
+
* the status and conclusion that were published, and no run of the name may
|
|
7599
|
+
* still be unfinished. A surviving `in_progress` run blocks the required check
|
|
7600
|
+
* on its own, so confirming past it would report success on a pull request
|
|
7601
|
+
* GitHub will never merge. One read, no polling: an unconfirmed publication
|
|
7602
|
+
* returns `false`, which `pr:ready` already turns into a notice and an
|
|
7603
|
+
* idempotent re-dispatch.
|
|
7604
|
+
*
|
|
7605
|
+
* Confirmation is a point-in-time read, deliberately: a later publication for
|
|
7606
|
+
* the same name changes the answer — a completed one by becoming the newest, an
|
|
7607
|
+
* unfinished one by holding the check open beside this verdict rather than
|
|
7608
|
+
* replacing it — and every remaining writer publishes once, as the last thing
|
|
7609
|
+
* its invocation does. No retry, no poll, no re-confirm.
|
|
7419
7610
|
*/
|
|
7420
7611
|
async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
|
|
7421
7612
|
const { onDiagnostic } = dependencies;
|
|
@@ -7426,7 +7617,7 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
|
|
|
7426
7617
|
return false;
|
|
7427
7618
|
}
|
|
7428
7619
|
const detailsUrl = await resolveDetailsUrlSafely(input, dependencies, onDiagnostic);
|
|
7429
|
-
await postFactoryCheckRun({
|
|
7620
|
+
const created = await postFactoryCheckRun({
|
|
7430
7621
|
config,
|
|
7431
7622
|
dependencies: {
|
|
7432
7623
|
retry: DURABLE_CHECK_RUN_RETRY,
|
|
@@ -7435,7 +7626,13 @@ async function ensureFactoryCheckRunPublished(input, dependencies = {}) {
|
|
|
7435
7626
|
detailsUrl,
|
|
7436
7627
|
input
|
|
7437
7628
|
});
|
|
7438
|
-
return
|
|
7629
|
+
return await confirmPublishedCheckRun({
|
|
7630
|
+
config,
|
|
7631
|
+
createdId: typeof created.id === "number" ? created.id : void 0,
|
|
7632
|
+
dependencies,
|
|
7633
|
+
input,
|
|
7634
|
+
onDiagnostic
|
|
7635
|
+
});
|
|
7439
7636
|
} catch (error) {
|
|
7440
7637
|
const message = error instanceof Error ? error.message : String(error);
|
|
7441
7638
|
onDiagnostic?.(`${FACTORY_CHECK_NAMES[input.gate]}: unable to republish check run: ${message}\n`);
|
|
@@ -10660,15 +10857,6 @@ function normalizeRepositoryIdentity(repository) {
|
|
|
10660
10857
|
owner: repository.owner.toLowerCase()
|
|
10661
10858
|
};
|
|
10662
10859
|
}
|
|
10663
|
-
function normalizeRepositorySlug(slug) {
|
|
10664
|
-
const segments = slug.split("/");
|
|
10665
|
-
const [owner, name] = segments;
|
|
10666
|
-
if (!(owner && name) || segments.length !== 2) return;
|
|
10667
|
-
return repositorySlug(normalizeRepositoryIdentity({
|
|
10668
|
-
name,
|
|
10669
|
-
owner
|
|
10670
|
-
}));
|
|
10671
|
-
}
|
|
10672
10860
|
const GITHUB_HOSTNAME = "github.com";
|
|
10673
10861
|
const SCP_GITHUB_REMOTE_PATTERN = /^(?:[^@\s]+@)?github\.com:(?<path>[^\s]+)$/iu;
|
|
10674
10862
|
function githubRepositoryPath(remoteUrl) {
|
|
@@ -10739,6 +10927,37 @@ function resolveCandidateIdentity({ base, cwd, git }) {
|
|
|
10739
10927
|
};
|
|
10740
10928
|
}
|
|
10741
10929
|
/**
|
|
10930
|
+
* Resolve only the applicability patch id for one diff base — no head or
|
|
10931
|
+
* merge-base read, and no validation beyond what the supplied `stablePatchId`
|
|
10932
|
+
* itself returns. Some callers (verify-once applicability, review-identity
|
|
10933
|
+
* comparison) only ever consumed the patch id half of the triple; before the
|
|
10934
|
+
* owner-routing fix for #379/#466 they invoked `stablePatchId` directly and
|
|
10935
|
+
* got its return value unchanged. Routing them through
|
|
10936
|
+
* `resolveCandidateIdentity` instead would tax every such call with two reads
|
|
10937
|
+
* it never used and two new ways to throw (`currentHeadSha`, optional
|
|
10938
|
+
* `mergeBaseSha`), changing the failure surface without changing the value —
|
|
10939
|
+
* and `resolveCandidateIdentity`'s own 40-character object-id check would
|
|
10940
|
+
* reject a legitimate 64-character patch id from a SHA-256 repository, which
|
|
10941
|
+
* the original direct call never rejected. This is a narrower view over the
|
|
10942
|
+
* same owned machine — not a second primitive and not a new `CONTEXT.md`
|
|
10943
|
+
* entry.
|
|
10944
|
+
*
|
|
10945
|
+
* Takes the primitive itself, not a `git` object to read it off: the original
|
|
10946
|
+
* expression this replaces (`dependencies.git?.stablePatchId ?? stablePatchId`)
|
|
10947
|
+
* nullish-coalesces on the *property* and calls the winner *unbound* — no
|
|
10948
|
+
* receiver, ever. An object-shaped parameter would reintroduce both a
|
|
10949
|
+
* newly-thrown `undefined(...)` call when a seam supplies the property as
|
|
10950
|
+
* `undefined` (rather than omitting the whole `git`) and a receiver
|
|
10951
|
+
* (`git.stablePatchId(...)`) the original expression never had. A
|
|
10952
|
+
* function-shaped parameter can only be called unbound, and the caller
|
|
10953
|
+
* chooses the fallback the same way the original expression did — see
|
|
10954
|
+
* `defaultStablePatchId` above for where that fallback comes from when the
|
|
10955
|
+
* caller may not import `git.ts` directly.
|
|
10956
|
+
*/
|
|
10957
|
+
function resolveCandidatePatchId({ base, cwd, stablePatchId }) {
|
|
10958
|
+
return stablePatchId(cwd, base);
|
|
10959
|
+
}
|
|
10960
|
+
/**
|
|
10742
10961
|
* A per-invocation resolver: one identity per diff base, computed once.
|
|
10743
10962
|
*/
|
|
10744
10963
|
function createCandidateIdentityResolver({ cwd, git }) {
|
|
@@ -10772,16 +10991,16 @@ var external_evidence_exports = /* @__PURE__ */ __exportAll({
|
|
|
10772
10991
|
requiredCheckScopeSchema: () => requiredCheckScopeSchema$1,
|
|
10773
10992
|
resolveAuthoringSessionIds: () => resolveAuthoringSessionIds
|
|
10774
10993
|
});
|
|
10775
|
-
const shaSchema$
|
|
10994
|
+
const shaSchema$3 = z.string().regex(/^[0-9a-f]{7,40}$/u, { message: "must be a 7-40 char lowercase hex git SHA" });
|
|
10776
10995
|
const evidenceEnvelopeSchema = z.object({
|
|
10777
10996
|
check: z.string().min(1),
|
|
10778
10997
|
checkType: z.enum(EVIDENCE_CHECK_TYPES$1),
|
|
10779
10998
|
findingsPointer: z.string().min(1).optional(),
|
|
10780
|
-
headSha: shaSchema$
|
|
10781
|
-
mergeBaseSha: shaSchema$
|
|
10999
|
+
headSha: shaSchema$3,
|
|
11000
|
+
mergeBaseSha: shaSchema$3,
|
|
10782
11001
|
model: z.string().min(1).optional(),
|
|
10783
11002
|
outcome: z.enum(["pass", "fail"]),
|
|
10784
|
-
patchId: shaSchema$
|
|
11003
|
+
patchId: shaSchema$3,
|
|
10785
11004
|
policyVersion: z.string().min(1).optional(),
|
|
10786
11005
|
producer: z.string().min(1),
|
|
10787
11006
|
requestId: z.string().min(1).optional(),
|
|
@@ -11965,7 +12184,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
11965
12184
|
//#endregion
|
|
11966
12185
|
//#region src/demand-waive.ts
|
|
11967
12186
|
const DEMAND_WAIVER_SCHEMA_VERSION = 1;
|
|
11968
|
-
const shaSchema$
|
|
12187
|
+
const shaSchema$2 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
11969
12188
|
const demandWaiverStorePath = (cwd, filePath) => path.resolve(cwd, filePath ?? ".factory-memory/demand-waivers.json");
|
|
11970
12189
|
/** Every waiver recorded in this checkout; an absent or unreadable store is
|
|
11971
12190
|
* no waivers, so a damaged file can only ever block, never permit. */
|
|
@@ -12015,7 +12234,7 @@ const requestRefusals = ({ demand, parsedDemand, rationale }) => [...parsedDeman
|
|
|
12015
12234
|
* the authoring session read below describes a different candidate.
|
|
12016
12235
|
*/
|
|
12017
12236
|
const candidateBindingRefusals = ({ headSha, pr, verifyProof }) => {
|
|
12018
|
-
if (!headSha || !shaSchema$
|
|
12237
|
+
if (!headSha || !shaSchema$2.safeParse(headSha).success) return [`demand:waive could not resolve a pushed head SHA for PR #${pr}; a waiver binds to one candidate.`];
|
|
12019
12238
|
return verifyProof && verifyProof.headSha !== headSha ? [`The pr:verify proof in this checkout records head ${verifyProof.headSha}, not the pushed head ${headSha}; re-run pr:verify on the candidate before waiving one of its demands.`] : [];
|
|
12020
12239
|
};
|
|
12021
12240
|
/**
|
|
@@ -12213,12 +12432,52 @@ const resolveWaveDemand = ({ epic, fetchClosingPullRequests = defaultFetchClosin
|
|
|
12213
12432
|
};
|
|
12214
12433
|
/**
|
|
12215
12434
|
* Whether recorded review evidence (and, for the human rung, live GitHub
|
|
12216
|
-
* approvals) satisfies the demanded review rung.
|
|
12217
|
-
*
|
|
12218
|
-
*
|
|
12435
|
+
* approvals) satisfies the demanded review rung. `pr:ready` — the single
|
|
12436
|
+
* admission decision since #477/#479 — evaluates it over the review proof it
|
|
12437
|
+
* is folding.
|
|
12438
|
+
*/
|
|
12439
|
+
/**
|
|
12440
|
+
* The submission states that carry a verdict. A `COMMENTED` submission does
|
|
12441
|
+
* not supersede an earlier approval — that is GitHub's own rule, and the
|
|
12442
|
+
* reason this cannot be a plain "latest submission wins".
|
|
12443
|
+
*/
|
|
12444
|
+
const VERDICT_REVIEW_STATES = new Set([
|
|
12445
|
+
"APPROVED",
|
|
12446
|
+
"CHANGES_REQUESTED",
|
|
12447
|
+
"DISMISSED"
|
|
12448
|
+
]);
|
|
12449
|
+
/**
|
|
12450
|
+
* Whether GitHub records a live human APPROVED review on this candidate.
|
|
12451
|
+
*
|
|
12452
|
+
* One predicate, two demands: the `human` review rung below, and the
|
|
12453
|
+
* native-approval demand a wave that does not authorize machine merge makes
|
|
12454
|
+
* at readiness (#477). A bot approval is not a human act.
|
|
12455
|
+
*
|
|
12456
|
+
* Latest-per-author, never "any submission ever" (#515 review): the fetch
|
|
12457
|
+
* returns the whole submission history, so a reviewer who approves and then
|
|
12458
|
+
* submits CHANGES_REQUESTED on the same head would otherwise keep satisfying
|
|
12459
|
+
* this predicate through their own standing rejection — the ruleset's
|
|
12460
|
+
* dismiss-stale-reviews-on-push never fires because the head has not moved.
|
|
12461
|
+
* This mirrors GitHub's `latestReviews` semantics.
|
|
12219
12462
|
*/
|
|
12463
|
+
const hasLiveHumanApproval = (reviews) => {
|
|
12464
|
+
const latestByAuthor = /* @__PURE__ */ new Map();
|
|
12465
|
+
for (const [index, review] of reviews.entries()) {
|
|
12466
|
+
const login = review.author?.login;
|
|
12467
|
+
if (!login || isBotLogin(login) || !VERDICT_REVIEW_STATES.has(review.state)) continue;
|
|
12468
|
+
const parsed = Date.parse(review.submittedAt ?? "");
|
|
12469
|
+
const at = Number.isNaN(parsed) ? void 0 : parsed;
|
|
12470
|
+
const previous = latestByAuthor.get(login);
|
|
12471
|
+
if (!previous || (at !== void 0 && previous.at !== void 0 ? at >= previous.at : index > previous.index)) latestByAuthor.set(login, {
|
|
12472
|
+
at,
|
|
12473
|
+
index,
|
|
12474
|
+
state: review.state
|
|
12475
|
+
});
|
|
12476
|
+
}
|
|
12477
|
+
return [...latestByAuthor.values()].some((latest) => latest.state === "APPROVED");
|
|
12478
|
+
};
|
|
12220
12479
|
const demandedRungSatisfactionReasons = ({ demandedRung, liveHumanReviews, reviewRuns }) => {
|
|
12221
|
-
if (demandedRung === "human") return liveHumanReviews
|
|
12480
|
+
if (demandedRung === "human") return hasLiveHumanApproval(liveHumanReviews) ? [] : ["Boundary wave requires the human rung, but GitHub has no live human APPROVED review."];
|
|
12222
12481
|
return reviewRuns?.flatMap((review) => review.outcome === "passed" && review.rung !== void 0 ? [review.rung] : []).find((candidate) => rungMeetsMinimum(candidate, demandedRung)) ? [] : [`Boundary wave requires the ${demandedRung} rung, but the recorded review evidence has no passing review at that authority.`];
|
|
12223
12482
|
};
|
|
12224
12483
|
//#endregion
|
|
@@ -12690,7 +12949,7 @@ const selectReviewProof = ({ reviewProof }) => {
|
|
|
12690
12949
|
if (isExplicitReviewFailure(reviewProof) || isUntypedReviewProof(reviewProof)) return {};
|
|
12691
12950
|
return hasReviewProof(reviewProof) ? reviewProof : {};
|
|
12692
12951
|
};
|
|
12693
|
-
const shaSchema$
|
|
12952
|
+
const shaSchema$1 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
12694
12953
|
const patchIdSchema = z.string().regex(/^[0-9a-f]{40,64}$/u);
|
|
12695
12954
|
const reviewCycleStateSchema = z.object({
|
|
12696
12955
|
autoBlockingFindings: z.number().int().nonnegative(),
|
|
@@ -12785,7 +13044,7 @@ const readinessRepairSchema = z.object({
|
|
|
12785
13044
|
command: z.string().min(1)
|
|
12786
13045
|
});
|
|
12787
13046
|
const managedReadinessLedgerSchema = z.object({
|
|
12788
|
-
baseSha: shaSchema$
|
|
13047
|
+
baseSha: shaSchema$1,
|
|
12789
13048
|
blockingReasons: z.array(z.string()),
|
|
12790
13049
|
classification: z.enum([
|
|
12791
13050
|
"docs/process-only",
|
|
@@ -12836,17 +13095,17 @@ const managedReadinessLedgerSchema = z.object({
|
|
|
12836
13095
|
handledHumanComments: z.array(handledHumanCommentSchema).optional(),
|
|
12837
13096
|
/**
|
|
12838
13097
|
* The evaluated head SHA: the GitHub `pr.headRefOid` captured at pr:ready
|
|
12839
|
-
* evaluation time, not a local git read. This is the identity anchor
|
|
12840
|
-
*
|
|
12841
|
-
*
|
|
13098
|
+
* evaluation time, not a local git read. This is the identity anchor the
|
|
13099
|
+
* branded `patronage-factory/pr-ready` check binds to — GitHub's required
|
|
13100
|
+
* checks compare a new head's checks against exactly this SHA (#477).
|
|
12842
13101
|
*/
|
|
12843
|
-
headSha: shaSchema$
|
|
13102
|
+
headSha: shaSchema$1,
|
|
12844
13103
|
/**
|
|
12845
13104
|
* The merge base used to evaluate tree-scoped external evidence. Optional
|
|
12846
13105
|
* only for backwards-compatible parsing of readiness ledgers written before
|
|
12847
13106
|
* #732; a committed verify-type demand cannot pass the merge gate without it.
|
|
12848
13107
|
*/
|
|
12849
|
-
mergeBaseSha: shaSchema$
|
|
13108
|
+
mergeBaseSha: shaSchema$1.optional(),
|
|
12850
13109
|
patchId: patchIdSchema,
|
|
12851
13110
|
postReadinessHumanComments: z.array(postReadinessCommentSchema).optional(),
|
|
12852
13111
|
postReadinessHumanReviews: z.array(postReadinessCommentSchema).optional(),
|
|
@@ -12984,6 +13243,129 @@ const resolveReviewRequiredness = (input) => {
|
|
|
12984
13243
|
};
|
|
12985
13244
|
};
|
|
12986
13245
|
//#endregion
|
|
13246
|
+
//#region src/pr-readiness/status-check-rollup.ts
|
|
13247
|
+
var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
|
|
13248
|
+
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME$1,
|
|
13249
|
+
hostedVerifyCheckState: () => hostedVerifyCheckState,
|
|
13250
|
+
isFactoryReadyCheck: () => isFactoryReadyCheck,
|
|
13251
|
+
isHostedVerifyCheck: () => isHostedVerifyCheck,
|
|
13252
|
+
statusCheckState: () => statusCheckState
|
|
13253
|
+
});
|
|
13254
|
+
const PASSED_STATES = new Set([
|
|
13255
|
+
"SUCCESS",
|
|
13256
|
+
"SKIPPED",
|
|
13257
|
+
"NEUTRAL"
|
|
13258
|
+
]);
|
|
13259
|
+
const FAILED_STATES = new Set([
|
|
13260
|
+
"FAILURE",
|
|
13261
|
+
"ERROR",
|
|
13262
|
+
"CANCELLED",
|
|
13263
|
+
"TIMED_OUT",
|
|
13264
|
+
"ACTION_REQUIRED"
|
|
13265
|
+
]);
|
|
13266
|
+
const PENDING_STATES = new Set([
|
|
13267
|
+
"PENDING",
|
|
13268
|
+
"QUEUED",
|
|
13269
|
+
"IN_PROGRESS",
|
|
13270
|
+
"EXPECTED"
|
|
13271
|
+
]);
|
|
13272
|
+
const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-factory/") === true || !check.workflowName && check.name?.startsWith("patronage-factory/") === true;
|
|
13273
|
+
/**
|
|
13274
|
+
* The hosted verification gate's context name — the branch ruleset's other
|
|
13275
|
+
* source-pinned required check, alongside `patronage-factory/pr-ready`. One
|
|
13276
|
+
* name across the fleet because one generator emits the workflow that posts
|
|
13277
|
+
* it (ADR 0016, 2026-07-31 amendment).
|
|
13278
|
+
*/
|
|
13279
|
+
const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
|
|
13280
|
+
/**
|
|
13281
|
+
* Is this rollup entry the hosted `verify` gate, from the producer the
|
|
13282
|
+
* ruleset pins it to?
|
|
13283
|
+
*
|
|
13284
|
+
* `workflowName` is the producer signal the rollup actually carries: GitHub
|
|
13285
|
+
* Actions check runs name their workflow, App-posted check runs come back with
|
|
13286
|
+
* an empty one, and a user-token commit status arrives as a `context` with no
|
|
13287
|
+
* `name` at all. Epic #473 wave 2 measured that a same-named check from the
|
|
13288
|
+
* wrong writer is as inert as no check at all, so matching the name alone
|
|
13289
|
+
* would accept exactly the thing the pin rejects.
|
|
13290
|
+
*/
|
|
13291
|
+
const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.workflowName);
|
|
13292
|
+
/**
|
|
13293
|
+
* The hosted `verify` gate's state on this head, read by presence (#477).
|
|
13294
|
+
*
|
|
13295
|
+
* This is the one rollup question the rollup can answer honestly. Wave 2
|
|
13296
|
+
* measured that GitHub omits an *unsatisfied pinned requirement* from
|
|
13297
|
+
* `statusCheckRollup` entirely — there is no "expected" or "missing" row — so
|
|
13298
|
+
* a green rollup is not evidence of mergeability. What the rollup does report
|
|
13299
|
+
* faithfully is the checks that ran. Asking whether this specific check ran,
|
|
13300
|
+
* and passed, on this head turns the rollup's silence from a false green into
|
|
13301
|
+
* a named refusal: `"none"` means the pinned requirement is unsatisfied and
|
|
13302
|
+
* nothing else in the rollup would have said so.
|
|
13303
|
+
*/
|
|
13304
|
+
const hostedVerifyCheckState = (rollup) => statusCheckState(rollup, (check) => !isHostedVerifyCheck(check));
|
|
13305
|
+
const checkContext = (check) => {
|
|
13306
|
+
if (check.workflowName && check.name) return `${check.workflowName} / ${check.name}`;
|
|
13307
|
+
return check.context;
|
|
13308
|
+
};
|
|
13309
|
+
const checkTimestampMs = (check) => {
|
|
13310
|
+
const value = check.startedAt ?? check.completedAt;
|
|
13311
|
+
if (!value) return;
|
|
13312
|
+
const timestamp = Date.parse(value);
|
|
13313
|
+
return Number.isNaN(timestamp) ? void 0 : timestamp;
|
|
13314
|
+
};
|
|
13315
|
+
const effectiveChecks = (rollup) => {
|
|
13316
|
+
const checksByContext = /* @__PURE__ */ new Map();
|
|
13317
|
+
const unkeyed = [];
|
|
13318
|
+
for (const [index, check] of rollup.entries()) {
|
|
13319
|
+
const context = checkContext(check);
|
|
13320
|
+
if (!context) {
|
|
13321
|
+
unkeyed.push(check);
|
|
13322
|
+
continue;
|
|
13323
|
+
}
|
|
13324
|
+
const checks = checksByContext.get(context) ?? [];
|
|
13325
|
+
checks.push({
|
|
13326
|
+
check,
|
|
13327
|
+
index,
|
|
13328
|
+
timestampMs: checkTimestampMs(check)
|
|
13329
|
+
});
|
|
13330
|
+
checksByContext.set(context, checks);
|
|
13331
|
+
}
|
|
13332
|
+
const currentChecks = [...unkeyed];
|
|
13333
|
+
for (const checks of checksByContext.values()) {
|
|
13334
|
+
const [firstCheck] = checks;
|
|
13335
|
+
if (!firstCheck) continue;
|
|
13336
|
+
if (checks.length === 1) {
|
|
13337
|
+
currentChecks.push(firstCheck.check);
|
|
13338
|
+
continue;
|
|
13339
|
+
}
|
|
13340
|
+
if (checks.some((check) => check.timestampMs === void 0)) {
|
|
13341
|
+
currentChecks.push(...checks.map((check) => check.check));
|
|
13342
|
+
continue;
|
|
13343
|
+
}
|
|
13344
|
+
let latest = firstCheck;
|
|
13345
|
+
for (const candidate of checks.slice(1)) {
|
|
13346
|
+
if (latest.timestampMs === void 0 || candidate.timestampMs === void 0) continue;
|
|
13347
|
+
if (candidate.timestampMs > latest.timestampMs) {
|
|
13348
|
+
latest = candidate;
|
|
13349
|
+
continue;
|
|
13350
|
+
}
|
|
13351
|
+
if (candidate.timestampMs === latest.timestampMs && candidate.index > latest.index) latest = candidate;
|
|
13352
|
+
}
|
|
13353
|
+
currentChecks.push(latest.check);
|
|
13354
|
+
}
|
|
13355
|
+
return currentChecks;
|
|
13356
|
+
};
|
|
13357
|
+
const terminalState = (check) => check.conclusion ?? check.state ?? "";
|
|
13358
|
+
const progressState = (check) => check.status ?? check.state ?? "";
|
|
13359
|
+
const statusCheckState = (rollup, exclude) => {
|
|
13360
|
+
const included = rollup?.filter((check) => !exclude?.(check));
|
|
13361
|
+
if (!included || included.length === 0) return "none";
|
|
13362
|
+
const currentChecks = effectiveChecks(included);
|
|
13363
|
+
if (currentChecks.every((check) => PASSED_STATES.has(terminalState(check)))) return "passed";
|
|
13364
|
+
if (currentChecks.some((check) => FAILED_STATES.has(terminalState(check)))) return "failed";
|
|
13365
|
+
if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
|
|
13366
|
+
return "unknown";
|
|
13367
|
+
};
|
|
13368
|
+
//#endregion
|
|
12987
13369
|
//#region src/pr-readiness/readiness-evaluation.ts
|
|
12988
13370
|
var readiness_evaluation_exports = /* @__PURE__ */ __exportAll({
|
|
12989
13371
|
CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON: () => CORRECTNESS_UNTYPED_REVIEW_BLOCKER_REASON,
|
|
@@ -13121,10 +13503,7 @@ const requiredChecksEvaluation = (input) => {
|
|
|
13121
13503
|
candidate,
|
|
13122
13504
|
envelopes: input.evidenceEnvelopes ?? [],
|
|
13123
13505
|
requiredChecks,
|
|
13124
|
-
scopeContext: {
|
|
13125
|
-
classification: input.classification,
|
|
13126
|
-
labels: input.prLabels
|
|
13127
|
-
}
|
|
13506
|
+
scopeContext: { classification: input.classification }
|
|
13128
13507
|
});
|
|
13129
13508
|
const blockers = outcomes.flatMap((outcome) => outcome.reason ? [{
|
|
13130
13509
|
demand: requiredCheckDemand(outcome.name),
|
|
@@ -13188,6 +13567,10 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
13188
13567
|
demand: DEMAND_KEYS.mergeState,
|
|
13189
13568
|
reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
|
|
13190
13569
|
});
|
|
13570
|
+
if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
|
|
13571
|
+
demand: DEMAND_KEYS.githubChecks,
|
|
13572
|
+
reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`
|
|
13573
|
+
});
|
|
13191
13574
|
if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
|
|
13192
13575
|
demand: DEMAND_KEYS.githubChecks,
|
|
13193
13576
|
reason: "GitHub has no current checks for a non-docs PR."
|
|
@@ -13304,6 +13687,88 @@ const waveRungBlockers = (input) => {
|
|
|
13304
13687
|
reason
|
|
13305
13688
|
}));
|
|
13306
13689
|
};
|
|
13690
|
+
const mergeFreezeBlockers = (input) => (input.mergeFreeze?.blockingReasons ?? []).map((reason) => ({
|
|
13691
|
+
demand: DEMAND_KEYS.mergeFreeze,
|
|
13692
|
+
reason
|
|
13693
|
+
}));
|
|
13694
|
+
/**
|
|
13695
|
+
* #477 (#515 review): a candidate GitHub is already scheduled to merge, with
|
|
13696
|
+
* no boundary wave in force to say it may be.
|
|
13697
|
+
*
|
|
13698
|
+
* Refusing to *arm* without a resolved wave was only half the fix. Arming
|
|
13699
|
+
* survives a push, and the durable head binding is the branded check — so a
|
|
13700
|
+
* later epicless run that greens the check re-satisfies the ruleset for a new
|
|
13701
|
+
* head and the still-enabled schedule fires, with no rung and no approval
|
|
13702
|
+
* evaluated, on a head whose approval the ruleset just dismissed. The branded
|
|
13703
|
+
* check cannot simply refuse to go green without `--epic`: it is required on
|
|
13704
|
+
* every pull request into the default branch, and most work carries no epic.
|
|
13705
|
+
* So the refusal is exactly as narrow as the danger — a live enablement is
|
|
13706
|
+
* present, and nothing authorized it.
|
|
13707
|
+
*/
|
|
13708
|
+
const armedWithoutBoundaryBlockers = (input) => {
|
|
13709
|
+
if (!input.autoMergeEnabled || input.waveReviewDemand) return [];
|
|
13710
|
+
return [{
|
|
13711
|
+
demand: DEMAND_KEYS.boundaryWave,
|
|
13712
|
+
reason: "GitHub already has auto-merge enabled on this pull request, but no boundary wave is in force to authorize it; re-run pr:ready --epic <issue> so the wave's review rung and machine-merge authority are evaluated before the branded check goes green."
|
|
13713
|
+
}];
|
|
13714
|
+
};
|
|
13715
|
+
const nativeApprovalBlockers = (input) => {
|
|
13716
|
+
const demanded = input.waveReviewDemand;
|
|
13717
|
+
if (!demanded) return [];
|
|
13718
|
+
if (demanded.autoMerge && demanded.review !== "human" || hasLiveHumanApproval(input.reviews ?? [])) return [];
|
|
13719
|
+
return [{
|
|
13720
|
+
demand: DEMAND_KEYS.nativeApproval,
|
|
13721
|
+
reason: `Boundary wave ${demanded.wave} does not authorize machine merge; GitHub must record a human APPROVED review on this PR before the branded readiness check can go green.`
|
|
13722
|
+
}];
|
|
13723
|
+
};
|
|
13724
|
+
/**
|
|
13725
|
+
* The demands an operator waiver can reach (#354, moved here from
|
|
13726
|
+
* `pr:merge-check` by #477). Exactly the set the merge-time site could waive:
|
|
13727
|
+
* the merge freeze, the profile's external required checks, and the boundary
|
|
13728
|
+
* wave's review rung — the demands the resolver puts *in force* for one
|
|
13729
|
+
* candidate.
|
|
13730
|
+
*
|
|
13731
|
+
* Everything else stays unwaivable, and relocating the consumption site does
|
|
13732
|
+
* not widen the operator's override. Structural refusals — candidate identity,
|
|
13733
|
+
* proof binding, an unreadable demand authority, wave membership — were never
|
|
13734
|
+
* waivable at merge time and are not waivable here; in `pr:ready` most of them
|
|
13735
|
+
* refuse by throwing before any blocker list exists. The evidence gates
|
|
13736
|
+
* readiness has always owned (verify, review modes, draft, human blockers) are
|
|
13737
|
+
* satisfied or not; a waiver is not a second way to pass them.
|
|
13738
|
+
*/
|
|
13739
|
+
const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
|
|
13740
|
+
const isWaivableDemand = (demand) => demand === DEMAND_KEYS.mergeFreeze || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
|
|
13741
|
+
/**
|
|
13742
|
+
* Fold the operator's waivers through the blockers, one call per demand so
|
|
13743
|
+
* `demand-waiver.ts` stays the single authority on what a waiver means. A
|
|
13744
|
+
* waived demand keeps every refusal it made, verbatim, on the `WaivedDemand`
|
|
13745
|
+
* record — it is never rewritten as satisfied.
|
|
13746
|
+
*/
|
|
13747
|
+
const foldWaivers = ({ blockers, waivers }) => {
|
|
13748
|
+
if (waivers.length === 0) return {
|
|
13749
|
+
blockers,
|
|
13750
|
+
waivedDemands: []
|
|
13751
|
+
};
|
|
13752
|
+
const reasonsByDemand = /* @__PURE__ */ new Map();
|
|
13753
|
+
for (const blocker of blockers) {
|
|
13754
|
+
if (!isWaivableDemand(blocker.demand)) continue;
|
|
13755
|
+
reasonsByDemand.set(blocker.demand, [...reasonsByDemand.get(blocker.demand) ?? [], blocker.reason]);
|
|
13756
|
+
}
|
|
13757
|
+
const waivedDemands = [];
|
|
13758
|
+
for (const [demand, reasons] of reasonsByDemand) {
|
|
13759
|
+
const { waived } = applyDemandWaiver({
|
|
13760
|
+
demand,
|
|
13761
|
+
reasons,
|
|
13762
|
+
waivers
|
|
13763
|
+
});
|
|
13764
|
+
if (waived) waivedDemands.push(waived);
|
|
13765
|
+
}
|
|
13766
|
+
const waivedKeys = new Set(waivedDemands.map((waived) => waived.demand));
|
|
13767
|
+
return {
|
|
13768
|
+
blockers: blockers.filter((blocker) => !waivedKeys.has(blocker.demand)),
|
|
13769
|
+
waivedDemands
|
|
13770
|
+
};
|
|
13771
|
+
};
|
|
13307
13772
|
const previewDeployRequired = false;
|
|
13308
13773
|
const evaluateReadiness = (input) => {
|
|
13309
13774
|
const bodyMetadata = readPrBodyMetadata(input.body);
|
|
@@ -13326,26 +13791,40 @@ const evaluateReadiness = (input) => {
|
|
|
13326
13791
|
});
|
|
13327
13792
|
const { docsOnlyVerifiedHeadSha, docsOnlyVerifyDeltaAccepted, prVerifyStatus, trivialVerifiedHeadSha, trivialVerifyDeltaAccepted, verifiedHeadSha } = verifyGate;
|
|
13328
13793
|
const requiredChecks = requiredChecksEvaluation(input);
|
|
13329
|
-
const blockers =
|
|
13330
|
-
|
|
13331
|
-
|
|
13332
|
-
|
|
13333
|
-
|
|
13334
|
-
|
|
13335
|
-
|
|
13336
|
-
|
|
13337
|
-
|
|
13338
|
-
|
|
13339
|
-
|
|
13340
|
-
|
|
13341
|
-
|
|
13342
|
-
|
|
13343
|
-
|
|
13344
|
-
|
|
13345
|
-
|
|
13346
|
-
|
|
13347
|
-
|
|
13348
|
-
|
|
13794
|
+
const { blockers, waivedDemands } = foldWaivers({
|
|
13795
|
+
blockers: [
|
|
13796
|
+
...collectBlockers({
|
|
13797
|
+
correctnessRequired,
|
|
13798
|
+
correctnessStatus,
|
|
13799
|
+
correctnessUntyped,
|
|
13800
|
+
input,
|
|
13801
|
+
prVerifyStatus,
|
|
13802
|
+
sectionReady: bodyMetadata.requiredSectionsPresent,
|
|
13803
|
+
securityRequired,
|
|
13804
|
+
securityStatus,
|
|
13805
|
+
securityUntyped,
|
|
13806
|
+
trivialWaiverPresent: bodyMetadata.trivialWaiverAccepted,
|
|
13807
|
+
verificationProof: verifyGate.verification.verificationProof
|
|
13808
|
+
}),
|
|
13809
|
+
...requiredChecks.blockers,
|
|
13810
|
+
...gateCapMismatchBlockers.map((blocker) => ({
|
|
13811
|
+
demand: DEMAND_KEYS.reviewLadder,
|
|
13812
|
+
reason: blocker.reason
|
|
13813
|
+
})),
|
|
13814
|
+
...waveRungBlockers(input),
|
|
13815
|
+
...nativeApprovalBlockers(input),
|
|
13816
|
+
...armedWithoutBoundaryBlockers(input),
|
|
13817
|
+
...mergeFreezeBlockers(input)
|
|
13818
|
+
],
|
|
13819
|
+
waivers: input.waivers ?? []
|
|
13820
|
+
});
|
|
13821
|
+
const notices = [
|
|
13822
|
+
...input.mergeFreeze?.notices ?? [],
|
|
13823
|
+
...waivedDemands.map(waivedDemandNotice),
|
|
13824
|
+
...unusedWaiverNotices({
|
|
13825
|
+
applied: waivedDemands,
|
|
13826
|
+
waivers: input.waivers ?? []
|
|
13827
|
+
})
|
|
13349
13828
|
];
|
|
13350
13829
|
const blockingReasons = blockers.map((blocker) => blocker.reason);
|
|
13351
13830
|
const humanBlockingReasons = blockers.flatMap((blocker) => blocker.repair ? [] : [blocker.reason]);
|
|
@@ -13442,8 +13921,16 @@ const evaluateReadiness = (input) => {
|
|
|
13442
13921
|
blockingReasons: finalBlockingReasons,
|
|
13443
13922
|
humanBlockingReasons,
|
|
13444
13923
|
ledger,
|
|
13924
|
+
/**
|
|
13925
|
+
* Facts a reader needs that are not refusals (#477): the settle-window
|
|
13926
|
+
* arming notice, and every waived demand rendered so it can never read as
|
|
13927
|
+
* a met one.
|
|
13928
|
+
*/
|
|
13929
|
+
notices,
|
|
13445
13930
|
repairs,
|
|
13446
|
-
status: postReadiness.status(status)
|
|
13931
|
+
status: postReadiness.status(status),
|
|
13932
|
+
/** Demands that were in force, were NOT met, and the operator waived. */
|
|
13933
|
+
waivedDemands
|
|
13447
13934
|
};
|
|
13448
13935
|
};
|
|
13449
13936
|
//#endregion
|
|
@@ -13599,10 +14086,7 @@ const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyPr
|
|
|
13599
14086
|
},
|
|
13600
14087
|
envelopes: loadEvidenceEnvelopes(cwd),
|
|
13601
14088
|
requiredChecks,
|
|
13602
|
-
scopeContext: {
|
|
13603
|
-
classification: candidate.classification,
|
|
13604
|
-
labels: void 0
|
|
13605
|
-
}
|
|
14089
|
+
scopeContext: { classification: candidate.classification }
|
|
13606
14090
|
});
|
|
13607
14091
|
return outcomes.map((outcome) => ({
|
|
13608
14092
|
message: requiredCheckMessage(outcome),
|
|
@@ -14478,17 +14962,203 @@ const FollowUpActionSchema = z.object({
|
|
|
14478
14962
|
command: z.string()
|
|
14479
14963
|
}).strict();
|
|
14480
14964
|
//#endregion
|
|
14965
|
+
//#region src/pr-publish-transaction-error.ts
|
|
14966
|
+
/**
|
|
14967
|
+
* Publish's admission read is a bounded transaction (#348): undraft, await the
|
|
14968
|
+
* newest hosted run for this head, re-evaluate, and return the current
|
|
14969
|
+
* outcome. Neither abort path returns a verdict the command cannot currently
|
|
14970
|
+
* substantiate — an unsubstantiated verdict is this error, never a guess.
|
|
14971
|
+
*/
|
|
14972
|
+
var PrPublishTransactionAbortedError = class extends Error {
|
|
14973
|
+
constructor(message) {
|
|
14974
|
+
super(`pr:publish aborted: ${message}`);
|
|
14975
|
+
this.name = "PrPublishTransactionAbortedError";
|
|
14976
|
+
}
|
|
14977
|
+
};
|
|
14978
|
+
//#endregion
|
|
14979
|
+
//#region src/pr-readiness/pr-body-patch.ts
|
|
14980
|
+
const markerSlug = (heading) => heading.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-");
|
|
14981
|
+
const markerFor = (heading, edge) => `<!-- patronage-factory:${markerSlug(heading)}:${edge} -->`;
|
|
14982
|
+
const renderManagedPrBodyBlock = (heading, content) => `${markerFor(heading, "start")}\n${content.trim()}\n${markerFor(heading, "end")}`;
|
|
14983
|
+
const isLegacyFactoryOnlyContent = (heading, content) => {
|
|
14984
|
+
const value = content.trim();
|
|
14985
|
+
if (!value) return true;
|
|
14986
|
+
if (heading === "Verification") return value === "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`." || value.split("\n").filter(Boolean).every((line) => /^patronage-factory pr:verify(?: --(?:docs-only|trivial))? passed at head [0-9a-f]{40}$/u.test(line));
|
|
14987
|
+
if (heading === "Review proof") return value === "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`." || value.split("\n").filter(Boolean).every((line) => /^- (?:correctness|security): (?:passed with no actionable findings|[a-z-]+ with \d+ finding\(s\)) at patch-id [0-9a-f]{40}$/u.test(line));
|
|
14988
|
+
return heading === "How to review" && value === "Review the changed files and branded patronage-factory check runs.";
|
|
14989
|
+
};
|
|
14990
|
+
const patchExistingSection = (body, part) => {
|
|
14991
|
+
const bounds = findSectionBounds(body, part.heading);
|
|
14992
|
+
if (!bounds) return;
|
|
14993
|
+
const rawContent = body.slice(bounds.contentStart, bounds.contentEnd);
|
|
14994
|
+
const block = renderManagedPrBodyBlock(part.heading, part.content);
|
|
14995
|
+
const startMarker = markerFor(part.heading, "start");
|
|
14996
|
+
const endMarker = markerFor(part.heading, "end");
|
|
14997
|
+
const markerStart = rawContent.indexOf(startMarker);
|
|
14998
|
+
const markerEnd = rawContent.indexOf(endMarker);
|
|
14999
|
+
const startCount = rawContent.split(startMarker).length - 1;
|
|
15000
|
+
if (startCount !== rawContent.split(endMarker).length - 1 || startCount > 1 || startCount === 1 && markerEnd < markerStart) throw new Error(`Malformed patronage-factory managed markers in the ${part.heading} section.`);
|
|
15001
|
+
if (startCount === 1) {
|
|
15002
|
+
const absoluteStart = bounds.contentStart + markerStart;
|
|
15003
|
+
const absoluteEnd = bounds.contentStart + markerEnd + endMarker.length;
|
|
15004
|
+
return body.slice(0, absoluteStart) + block + body.slice(absoluteEnd);
|
|
15005
|
+
}
|
|
15006
|
+
if (isLegacyFactoryOnlyContent(part.heading, rawContent)) return `${body.slice(0, bounds.contentStart)}\n\n${block}\n\n${body.slice(bounds.contentEnd)}`;
|
|
15007
|
+
const separator = rawContent.endsWith("\n\n") ? "" : "\n\n";
|
|
15008
|
+
return `${body.slice(0, bounds.contentEnd)}${separator}${block}\n\n${body.slice(bounds.contentEnd)}`;
|
|
15009
|
+
};
|
|
15010
|
+
const patchPrBodySections = (body, parts) => {
|
|
15011
|
+
let result = body;
|
|
15012
|
+
const missing = [];
|
|
15013
|
+
for (const part of parts) {
|
|
15014
|
+
const patched = patchExistingSection(result, part);
|
|
15015
|
+
if (patched === void 0) missing.push(part);
|
|
15016
|
+
else result = patched;
|
|
15017
|
+
}
|
|
15018
|
+
for (const part of missing) {
|
|
15019
|
+
const separator = result.length === 0 || result.endsWith("\n\n") ? "" : "\n\n";
|
|
15020
|
+
result += `${separator}## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}\n`;
|
|
15021
|
+
}
|
|
15022
|
+
return result;
|
|
15023
|
+
};
|
|
15024
|
+
//#endregion
|
|
15025
|
+
//#region src/pr-readiness/pr-body-renderer.ts
|
|
15026
|
+
var pr_body_renderer_exports = /* @__PURE__ */ __exportAll({
|
|
15027
|
+
PR_BODY_MANAGED_SECTIONS: () => MANAGED_SECTION_NAMES,
|
|
15028
|
+
renderPrBodySectionParts: () => renderPrBodySectionParts,
|
|
15029
|
+
renderPrBodySections: () => renderPrBodySections
|
|
15030
|
+
});
|
|
15031
|
+
const commandFor = (proof) => {
|
|
15032
|
+
if (proof.mode === "docs-only") return "patronage-factory pr:verify --docs-only";
|
|
15033
|
+
if (proof.mode === "trivial") return "patronage-factory pr:verify --trivial";
|
|
15034
|
+
return "patronage-factory pr:verify";
|
|
15035
|
+
};
|
|
15036
|
+
const renderPrBodySectionParts = ({ reviewProof, verifyProof }) => {
|
|
15037
|
+
const verification = verifyProof ? [...verifyProof.mode === "docs-only" ? verifyProof.baselineFullProofs?.map((proof) => `patronage-factory pr:verify passed at head ${proof.headSha}`) ?? [] : [], `${commandFor(verifyProof)} passed at head ${verifyProof.headSha}`].join("\n") : "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`.";
|
|
15038
|
+
const reviewRuns = reviewProof?.reviews.map((review) => {
|
|
15039
|
+
const patch = reviewProof.patchId;
|
|
15040
|
+
const result = review.outcome === "passed" ? "passed with no actionable findings" : `${review.outcome} with ${review.issuesFlagged} finding(s)`;
|
|
15041
|
+
return `- ${review.kind}: ${result} at patch-id ${patch}`;
|
|
15042
|
+
}).join("\n") ?? "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`.";
|
|
15043
|
+
return [
|
|
15044
|
+
{
|
|
15045
|
+
content: verification,
|
|
15046
|
+
heading: "Verification"
|
|
15047
|
+
},
|
|
15048
|
+
{
|
|
15049
|
+
content: reviewRuns,
|
|
15050
|
+
heading: "Review proof"
|
|
15051
|
+
},
|
|
15052
|
+
{
|
|
15053
|
+
content: "Review the changed files and branded patronage-factory check runs.",
|
|
15054
|
+
heading: "How to review"
|
|
15055
|
+
}
|
|
15056
|
+
];
|
|
15057
|
+
};
|
|
15058
|
+
const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySectionParts({
|
|
15059
|
+
reviewProof,
|
|
15060
|
+
verifyProof
|
|
15061
|
+
}).map((part) => `## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}`).join("\n\n")}\n`;
|
|
15062
|
+
//#endregion
|
|
15063
|
+
//#region src/arm-auto-merge.ts
|
|
15064
|
+
/**
|
|
15065
|
+
* The read-back, parsed rather than asserted. `state` is read loosely so a
|
|
15066
|
+
* merged pull request is still recognised as merged even when the enablement
|
|
15067
|
+
* record is junk; the enablement itself must be a real timestamped record,
|
|
15068
|
+
* because that record IS the claim that a merge is scheduled.
|
|
15069
|
+
*/
|
|
15070
|
+
const readBackSchema = z.object({
|
|
15071
|
+
autoMergeRequest: z.unknown().optional(),
|
|
15072
|
+
state: z.string().optional()
|
|
15073
|
+
});
|
|
15074
|
+
const autoMergeEnablementSchema = z.object({ enabledAt: z.iso.datetime() });
|
|
15075
|
+
/**
|
|
15076
|
+
* The arming invocation, as a literal argv. Exported because it is also the
|
|
15077
|
+
* re-dispatch `pr:ready` records when the arming did not take effect: arming
|
|
15078
|
+
* is idempotent on an unchanged head (measured — re-arming leaves `enabledAt`
|
|
15079
|
+
* untouched), so running it again is safe.
|
|
15080
|
+
*/
|
|
15081
|
+
const armAutoMergeArgv = ({ headSha, owner, pr, repo }) => [
|
|
15082
|
+
"gh",
|
|
15083
|
+
"pr",
|
|
15084
|
+
"merge",
|
|
15085
|
+
String(pr),
|
|
15086
|
+
"--repo",
|
|
15087
|
+
`${owner}/${repo}`,
|
|
15088
|
+
"--auto",
|
|
15089
|
+
"--squash",
|
|
15090
|
+
"--match-head-commit",
|
|
15091
|
+
headSha
|
|
15092
|
+
];
|
|
15093
|
+
const defaultRun = (argv, cwd) => {
|
|
15094
|
+
const [command = "gh", ...args] = argv;
|
|
15095
|
+
runCapture(command, args, cwd);
|
|
15096
|
+
};
|
|
15097
|
+
const defaultReadState = ({ owner, pr, repo }) => runGhJson([
|
|
15098
|
+
"pr",
|
|
15099
|
+
"view",
|
|
15100
|
+
String(pr),
|
|
15101
|
+
"--repo",
|
|
15102
|
+
`${owner}/${repo}`,
|
|
15103
|
+
"--json",
|
|
15104
|
+
"autoMergeRequest,state"
|
|
15105
|
+
]);
|
|
15106
|
+
/**
|
|
15107
|
+
* Arm native auto-merge for one candidate and report what GitHub actually did.
|
|
15108
|
+
*
|
|
15109
|
+
* `autoMergeRequest` survives the merge, so it is not on its own a
|
|
15110
|
+
* "still pending" signal; the discriminator pairs it with `state`.
|
|
15111
|
+
*/
|
|
15112
|
+
function armAutoMerge(input, dependencies = {}) {
|
|
15113
|
+
const run = dependencies.run ?? defaultRun;
|
|
15114
|
+
const readState = dependencies.readState ?? defaultReadState;
|
|
15115
|
+
let invocationDetail;
|
|
15116
|
+
try {
|
|
15117
|
+
run(armAutoMergeArgv(input), input.cwd);
|
|
15118
|
+
} catch (error) {
|
|
15119
|
+
invocationDetail = error instanceof Error ? error.message : String(error);
|
|
15120
|
+
}
|
|
15121
|
+
let observed;
|
|
15122
|
+
try {
|
|
15123
|
+
observed = readBackSchema.parse(readState(input));
|
|
15124
|
+
} catch (error) {
|
|
15125
|
+
return {
|
|
15126
|
+
detail: `Could not read the pull request back after arming, so the handoff is unproven: ${error instanceof Error ? error.message : String(error)}`,
|
|
15127
|
+
headSha: input.headSha,
|
|
15128
|
+
outcome: "not-armed"
|
|
15129
|
+
};
|
|
15130
|
+
}
|
|
15131
|
+
if (observed.state === "MERGED") return {
|
|
15132
|
+
detail: "The candidate was already mergeable, so the arming call merged it synchronously.",
|
|
15133
|
+
headSha: input.headSha,
|
|
15134
|
+
outcome: "merged"
|
|
15135
|
+
};
|
|
15136
|
+
const enablement = autoMergeEnablementSchema.safeParse(observed.autoMergeRequest);
|
|
15137
|
+
if (observed.state === "OPEN" && enablement.success) return {
|
|
15138
|
+
headSha: input.headSha,
|
|
15139
|
+
outcome: "armed"
|
|
15140
|
+
};
|
|
15141
|
+
return {
|
|
15142
|
+
detail: invocationDetail ?? `The arming call reported success but the pull request read back as ${observed.state ?? "an unknown state"} with no valid auto-merge enablement.`,
|
|
15143
|
+
headSha: input.headSha,
|
|
15144
|
+
outcome: "not-armed"
|
|
15145
|
+
};
|
|
15146
|
+
}
|
|
15147
|
+
//#endregion
|
|
14481
15148
|
//#region src/merge-freeze.ts
|
|
14482
15149
|
const MERGE_FREEZE_CHECK_NAME = "patronage-factory/merge-freeze";
|
|
14483
15150
|
const MERGE_FREEZE_APP_SLUG = "patronage-factory";
|
|
15151
|
+
const HOSTED_VERIFY_CHECK_NAME = "verify";
|
|
15152
|
+
const GITHUB_ACTIONS_APP_ID = 15368;
|
|
15153
|
+
const GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
14484
15154
|
const MERGE_FREEZE_SCHEMA_VERSION = 1;
|
|
14485
15155
|
const CHECK_RUNS_PER_PAGE = 100;
|
|
14486
15156
|
const CHECK_RUN_PAGE_LIMIT = 10;
|
|
14487
|
-
const shaSchema
|
|
15157
|
+
const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
14488
15158
|
const activeMergeFreezeStateSchema = z.object({
|
|
14489
15159
|
active: z.literal(true),
|
|
14490
15160
|
generationId: z.number().int().positive(),
|
|
14491
|
-
headSha: shaSchema
|
|
15161
|
+
headSha: shaSchema,
|
|
14492
15162
|
outcome: z.enum(["active", "stale"]),
|
|
14493
15163
|
reason: z.string().min(1),
|
|
14494
15164
|
recordedAt: z.iso.datetime(),
|
|
@@ -14498,7 +15168,7 @@ const inactiveMergeFreezeStateSchema = z.object({
|
|
|
14498
15168
|
active: z.literal(false),
|
|
14499
15169
|
clearRationale: z.string().min(1).optional(),
|
|
14500
15170
|
generationId: z.number().int().positive(),
|
|
14501
|
-
headSha: shaSchema
|
|
15171
|
+
headSha: shaSchema,
|
|
14502
15172
|
outcome: z.literal("inactive"),
|
|
14503
15173
|
reason: z.string().min(1),
|
|
14504
15174
|
recordedAt: z.iso.datetime(),
|
|
@@ -14522,7 +15192,7 @@ const mergeFreezeCheckRunListItemSchema = z.object({
|
|
|
14522
15192
|
"neutral",
|
|
14523
15193
|
"success"
|
|
14524
15194
|
]).nullable().optional(),
|
|
14525
|
-
head_sha: shaSchema
|
|
15195
|
+
head_sha: shaSchema,
|
|
14526
15196
|
id: z.number().int().positive(),
|
|
14527
15197
|
name: z.literal(MERGE_FREEZE_CHECK_NAME),
|
|
14528
15198
|
output: z.object({ text: z.string().nullable().optional() }).optional(),
|
|
@@ -14542,395 +15212,200 @@ const completedMergeFreezeCheckRunSchema = mergeFreezeCheckRunListItemSchema.ext
|
|
|
14542
15212
|
output: z.object({ text: z.string().min(1) }),
|
|
14543
15213
|
status: z.literal("completed")
|
|
14544
15214
|
});
|
|
14545
|
-
|
|
14546
|
-
|
|
14547
|
-
|
|
14548
|
-
|
|
14549
|
-
|
|
14550
|
-
|
|
14551
|
-
|
|
14552
|
-
|
|
14553
|
-
|
|
14554
|
-
|
|
14555
|
-
|
|
14556
|
-
|
|
14557
|
-
|
|
14558
|
-
|
|
14559
|
-
|
|
14560
|
-
|
|
14561
|
-
|
|
14562
|
-
|
|
15215
|
+
const hostedVerifyCheckRunSchema = z.object({
|
|
15216
|
+
app: z.object({
|
|
15217
|
+
id: z.literal(GITHUB_ACTIONS_APP_ID),
|
|
15218
|
+
slug: z.literal(GITHUB_ACTIONS_APP_SLUG)
|
|
15219
|
+
}),
|
|
15220
|
+
head_sha: shaSchema,
|
|
15221
|
+
id: z.number().int().positive(),
|
|
15222
|
+
name: z.literal(HOSTED_VERIFY_CHECK_NAME),
|
|
15223
|
+
started_at: z.iso.datetime().nullable(),
|
|
15224
|
+
status: z.enum([
|
|
15225
|
+
"completed",
|
|
15226
|
+
"in_progress",
|
|
15227
|
+
"queued"
|
|
15228
|
+
])
|
|
15229
|
+
});
|
|
15230
|
+
const commitParentsSchema = z.object({
|
|
15231
|
+
parents: z.array(z.object({ sha: shaSchema })),
|
|
15232
|
+
sha: shaSchema
|
|
15233
|
+
});
|
|
15234
|
+
function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
|
|
15235
|
+
const runs = z.object({ check_runs: z.array(z.unknown()) }).parse(listed).check_runs.map((run) => mergeFreezeCheckRunListItemSchema.parse(run));
|
|
15236
|
+
if (runs.length === 0) return { kind: "missing" };
|
|
15237
|
+
const selected = selectNewestGeneration(runs.map((run) => ({
|
|
15238
|
+
orderMs: generationOrderMs(run.started_at),
|
|
15239
|
+
run
|
|
15240
|
+
})));
|
|
15241
|
+
if (selected.kind === "ambiguous") return {
|
|
15242
|
+
kind: "unreadable",
|
|
15243
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`
|
|
15244
|
+
};
|
|
15245
|
+
if (selected.kind === "none") return { kind: "missing" };
|
|
15246
|
+
const newest = selected.generation.run;
|
|
15247
|
+
if (newest.head_sha !== expectedHeadSha) return {
|
|
15248
|
+
kind: "unreadable",
|
|
15249
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${expectedHeadSha}.`
|
|
15250
|
+
};
|
|
15251
|
+
if (newest.status !== "completed") return {
|
|
15252
|
+
kind: "settling",
|
|
15253
|
+
reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
|
|
15254
|
+
running: {
|
|
14563
15255
|
headSha: newest.head_sha,
|
|
14564
|
-
|
|
14565
|
-
reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
|
|
14566
|
-
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14567
|
-
schemaVersion: MERGE_FREEZE_SCHEMA_VERSION
|
|
14568
|
-
};
|
|
14569
|
-
const completed = completedMergeFreezeCheckRunSchema.parse(newest);
|
|
14570
|
-
const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
|
|
14571
|
-
if (state.generationId !== completed.id || state.headSha !== completed.head_sha || state.outcome === "active" && completed.conclusion !== "failure" || state.outcome === "stale" && completed.conclusion !== "neutral" || !state.active && completed.conclusion !== "success") throw new Error(`Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`);
|
|
14572
|
-
return state;
|
|
14573
|
-
} };
|
|
14574
|
-
}
|
|
14575
|
-
function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
|
|
14576
|
-
return { list({ cwd, headSha, name, repository }) {
|
|
14577
|
-
const checkRuns = [];
|
|
14578
|
-
for (let page = 1; page <= CHECK_RUN_PAGE_LIMIT; page += 1) {
|
|
14579
|
-
const response = z.object({ check_runs: z.array(z.unknown()) }).parse((dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=${CHECK_RUNS_PER_PAGE}&page=${page}`], cwd));
|
|
14580
|
-
checkRuns.push(...response.check_runs);
|
|
14581
|
-
if (response.check_runs.length < CHECK_RUNS_PER_PAGE) break;
|
|
14582
|
-
if (page === CHECK_RUN_PAGE_LIMIT) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check-run pagination limit was exhausted before GitHub returned a final page.`);
|
|
15256
|
+
id: newest.id
|
|
14583
15257
|
}
|
|
14584
|
-
|
|
14585
|
-
|
|
15258
|
+
};
|
|
15259
|
+
const completed = completedMergeFreezeCheckRunSchema.parse(newest);
|
|
15260
|
+
const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
|
|
15261
|
+
if (state.generationId !== completed.id || state.headSha !== completed.head_sha || state.outcome === "active" && completed.conclusion !== "failure" || state.outcome === "stale" && completed.conclusion !== "neutral" || !state.active && completed.conclusion !== "success") return {
|
|
15262
|
+
kind: "unreadable",
|
|
15263
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
|
|
15264
|
+
};
|
|
15265
|
+
return {
|
|
15266
|
+
kind: "settled",
|
|
15267
|
+
state
|
|
15268
|
+
};
|
|
14586
15269
|
}
|
|
14587
|
-
|
|
14588
|
-
function unavailableMergeFreeze(reason) {
|
|
15270
|
+
function unconfiguredMergeFreeze(input, detail) {
|
|
14589
15271
|
return {
|
|
14590
|
-
|
|
14591
|
-
reason,
|
|
14592
|
-
|
|
14593
|
-
|
|
15272
|
+
kind: "unreadable",
|
|
15273
|
+
reason: `The ${MERGE_FREEZE_CHECK_NAME} writer is unconfigured or its settle window cannot be confirmed for base ${input.headSha}: ${detail} Generate the main-push verify workflow, configure FACTORY_GITHUB_APP_PRIVATE_KEY, and rerun pr:ready after the first App-owned freeze generation appears.`
|
|
15274
|
+
};
|
|
15275
|
+
}
|
|
15276
|
+
function selectMissingMergeFreezeGenerationUnchecked(api, input) {
|
|
15277
|
+
if (!api.parent) return unconfiguredMergeFreeze(input, "the authority cannot read the base commit's parent.");
|
|
15278
|
+
const commit = commitParentsSchema.parse(api.parent(input));
|
|
15279
|
+
if (commit.sha !== input.headSha) return unconfiguredMergeFreeze(input, `the history query returned ${commit.sha} instead of the requested base tip.`);
|
|
15280
|
+
if (commit.parents.length !== 1) return unconfiguredMergeFreeze(input, `the base tip has ${commit.parents.length} parents rather than exactly one.`);
|
|
15281
|
+
const parentSha = commit.parents[0]?.sha;
|
|
15282
|
+
if (!parentSha) return unconfiguredMergeFreeze(input, "the base tip's immediate parent is unreadable.");
|
|
15283
|
+
const parentGeneration = selectListedMergeFreezeGeneration(api.list({
|
|
15284
|
+
...input,
|
|
15285
|
+
headSha: parentSha,
|
|
15286
|
+
name: MERGE_FREEZE_CHECK_NAME
|
|
15287
|
+
}), parentSha);
|
|
15288
|
+
if (parentGeneration.kind !== "settled") return unconfiguredMergeFreeze(input, `the immediate parent ${parentSha} has no settled App-owned freeze generation (${parentGeneration.kind}).`);
|
|
15289
|
+
const verifyRuns = z.object({ check_runs: z.array(z.unknown()) }).parse(api.list({
|
|
15290
|
+
...input,
|
|
15291
|
+
name: HOSTED_VERIFY_CHECK_NAME
|
|
15292
|
+
})).check_runs.map((run) => hostedVerifyCheckRunSchema.parse(run));
|
|
15293
|
+
const completedVerify = verifyRuns.find((run) => run.status === "completed");
|
|
15294
|
+
if (completedVerify) return unconfiguredMergeFreeze(input, `the current tip's source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${completedVerify.id} already completed without producing a freeze generation.`);
|
|
15295
|
+
const orderedVerifyRuns = verifyRuns.flatMap((run) => run.started_at ? [{
|
|
15296
|
+
orderMs: generationOrderMs(run.started_at),
|
|
15297
|
+
run
|
|
15298
|
+
}] : []);
|
|
15299
|
+
if (verifyRuns.length > 1 && orderedVerifyRuns.length !== verifyRuns.length) return unconfiguredMergeFreeze(input, `multiple pending source-pinned ${HOSTED_VERIFY_CHECK_NAME} runs cannot be ordered because at least one has no start time.`);
|
|
15300
|
+
const selectedVerify = verifyRuns.length === 1 ? {
|
|
15301
|
+
generation: { run: verifyRuns[0] },
|
|
15302
|
+
kind: "selected"
|
|
15303
|
+
} : selectNewestGeneration(orderedVerifyRuns);
|
|
15304
|
+
if (selectedVerify.kind === "ambiguous") return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} generation is ambiguous.`);
|
|
15305
|
+
if (selectedVerify.kind === "none") return unconfiguredMergeFreeze(input, `the current tip has no source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run.`);
|
|
15306
|
+
const verify = selectedVerify.generation.run;
|
|
15307
|
+
if (verify.head_sha !== input.headSha) return unconfiguredMergeFreeze(input, `the newest source-pinned ${HOSTED_VERIFY_CHECK_NAME} run targets ${verify.head_sha}.`);
|
|
15308
|
+
const refreshed = selectListedMergeFreezeGeneration(api.list({
|
|
15309
|
+
...input,
|
|
15310
|
+
name: MERGE_FREEZE_CHECK_NAME
|
|
15311
|
+
}), input.headSha);
|
|
15312
|
+
if (refreshed.kind !== "missing") return refreshed;
|
|
15313
|
+
return {
|
|
15314
|
+
kind: "settling",
|
|
15315
|
+
reason: `No ${MERGE_FREEZE_CHECK_NAME} generation exists yet for ${input.headSha}; the immediate parent has a settled generation and source-pinned GitHub Actions ${HOSTED_VERIFY_CHECK_NAME} run ${verify.id} is ${verify.status}.`
|
|
14594
15316
|
};
|
|
14595
15317
|
}
|
|
14596
|
-
function
|
|
15318
|
+
function selectMissingMergeFreezeGeneration(api, input) {
|
|
14597
15319
|
try {
|
|
14598
|
-
return
|
|
15320
|
+
return selectMissingMergeFreezeGenerationUnchecked(api, input);
|
|
14599
15321
|
} catch (error) {
|
|
14600
|
-
return
|
|
15322
|
+
return unconfiguredMergeFreeze(input, `the required GitHub history or check-run evidence is unreadable: ${error instanceof Error ? error.message : String(error)}.`);
|
|
14601
15323
|
}
|
|
14602
15324
|
}
|
|
14603
|
-
function
|
|
14604
|
-
const
|
|
14605
|
-
|
|
14606
|
-
|
|
15325
|
+
function selectMergeFreezeGeneration(api, input) {
|
|
15326
|
+
const selected = selectListedMergeFreezeGeneration(api.list({
|
|
15327
|
+
...input,
|
|
15328
|
+
name: MERGE_FREEZE_CHECK_NAME
|
|
15329
|
+
}), input.headSha);
|
|
15330
|
+
if (selected.kind === "missing") return selectMissingMergeFreezeGeneration(api, input);
|
|
15331
|
+
return selected;
|
|
14607
15332
|
}
|
|
14608
|
-
|
|
14609
|
-
|
|
14610
|
-
|
|
14611
|
-
|
|
14612
|
-
|
|
14613
|
-
|
|
14614
|
-
|
|
14615
|
-
|
|
14616
|
-
|
|
14617
|
-
worktreeHeldBranchNotice: () => worktreeHeldBranchNotice
|
|
14618
|
-
});
|
|
14619
|
-
/**
|
|
14620
|
-
* Parses `git worktree list --porcelain` output. Entries are separated by
|
|
14621
|
-
* blank lines; each starts with `worktree <path>` and carries an optional
|
|
14622
|
-
* `branch refs/heads/<name>` attribute (detached worktrees have none).
|
|
14623
|
-
*/
|
|
14624
|
-
function parseGitWorktreeList(porcelain) {
|
|
14625
|
-
const entries = [];
|
|
14626
|
-
let current = {};
|
|
14627
|
-
const flush = () => {
|
|
14628
|
-
if (current.path !== void 0) entries.push({
|
|
14629
|
-
path: current.path,
|
|
14630
|
-
...current.branch === void 0 ? {} : { branch: current.branch }
|
|
14631
|
-
});
|
|
14632
|
-
current = {};
|
|
14633
|
-
};
|
|
14634
|
-
for (const line of porcelain.split(/\r?\n/u)) {
|
|
14635
|
-
if (line === "") {
|
|
14636
|
-
flush();
|
|
14637
|
-
continue;
|
|
14638
|
-
}
|
|
14639
|
-
if (line.startsWith("worktree ")) {
|
|
14640
|
-
flush();
|
|
14641
|
-
current.path = line.slice(9);
|
|
14642
|
-
continue;
|
|
15333
|
+
function createGitHubCheckRunMergeFreezeStore(api) {
|
|
15334
|
+
const readGeneration = (input) => {
|
|
15335
|
+
try {
|
|
15336
|
+
return selectMergeFreezeGeneration(api, input);
|
|
15337
|
+
} catch (error) {
|
|
15338
|
+
return {
|
|
15339
|
+
kind: "unreadable",
|
|
15340
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
15341
|
+
};
|
|
14643
15342
|
}
|
|
14644
|
-
if (line.startsWith("branch refs/heads/")) current.branch = line.slice(18);
|
|
14645
|
-
}
|
|
14646
|
-
flush();
|
|
14647
|
-
return entries;
|
|
14648
|
-
}
|
|
14649
|
-
/**
|
|
14650
|
-
* Returns the worktree holding `branch`, or undefined when no local worktree
|
|
14651
|
-
* has it checked out (or the porcelain listing was unavailable).
|
|
14652
|
-
*/
|
|
14653
|
-
function findWorktreeHeldBranch({ branch, worktreeListPorcelain }) {
|
|
14654
|
-
if (worktreeListPorcelain === void 0) return;
|
|
14655
|
-
const held = parseGitWorktreeList(worktreeListPorcelain).find((entry) => entry.branch === branch);
|
|
14656
|
-
return held === void 0 ? void 0 : {
|
|
14657
|
-
branch,
|
|
14658
|
-
worktreePath: held.path
|
|
14659
15343
|
};
|
|
14660
|
-
|
|
14661
|
-
|
|
14662
|
-
|
|
14663
|
-
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
|
|
14670
|
-
*/
|
|
14671
|
-
|
|
14672
|
-
|
|
14673
|
-
|
|
14674
|
-
|
|
14675
|
-
|
|
14676
|
-
return heldBranch === void 0 ? { notices: [] } : {
|
|
14677
|
-
notices: [worktreeHeldBranchNotice(heldBranch)],
|
|
14678
|
-
worktreeHeldBranch: heldBranch,
|
|
14679
|
-
worktreeHeldBranches: [heldBranch]
|
|
15344
|
+
return {
|
|
15345
|
+
read(input) {
|
|
15346
|
+
const generation = readGeneration(input);
|
|
15347
|
+
if (generation.kind === "settled") return generation.state;
|
|
15348
|
+
if (generation.kind === "settling" && generation.running) return {
|
|
15349
|
+
active: true,
|
|
15350
|
+
generationId: generation.running.id,
|
|
15351
|
+
headSha: generation.running.headSha,
|
|
15352
|
+
outcome: "active",
|
|
15353
|
+
reason: generation.reason,
|
|
15354
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15355
|
+
schemaVersion: MERGE_FREEZE_SCHEMA_VERSION
|
|
15356
|
+
};
|
|
15357
|
+
throw new Error(generation.reason);
|
|
15358
|
+
},
|
|
15359
|
+
readGeneration
|
|
14680
15360
|
};
|
|
14681
15361
|
}
|
|
14682
|
-
|
|
14683
|
-
|
|
14684
|
-
|
|
14685
|
-
|
|
14686
|
-
|
|
14687
|
-
|
|
14688
|
-
|
|
14689
|
-
|
|
14690
|
-
|
|
14691
|
-
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
14695
|
-
cwd
|
|
14696
|
-
|
|
14697
|
-
|
|
14698
|
-
"ignore",
|
|
14699
|
-
"pipe",
|
|
14700
|
-
"ignore"
|
|
14701
|
-
]
|
|
14702
|
-
});
|
|
14703
|
-
} catch {
|
|
14704
|
-
return;
|
|
14705
|
-
}
|
|
15362
|
+
function createGitHubCheckRunMergeFreezeApi(dependencies = {}) {
|
|
15363
|
+
return {
|
|
15364
|
+
list({ cwd, headSha, name, repository }) {
|
|
15365
|
+
const checkRuns = [];
|
|
15366
|
+
for (let page = 1; page <= CHECK_RUN_PAGE_LIMIT; page += 1) {
|
|
15367
|
+
const response = z.object({ check_runs: z.array(z.unknown()) }).parse((dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(name)}&filter=all&per_page=${CHECK_RUNS_PER_PAGE}&page=${page}`], cwd));
|
|
15368
|
+
checkRuns.push(...response.check_runs);
|
|
15369
|
+
if (response.check_runs.length < CHECK_RUNS_PER_PAGE) break;
|
|
15370
|
+
if (page === CHECK_RUN_PAGE_LIMIT) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check-run pagination limit was exhausted before GitHub returned a final page.`);
|
|
15371
|
+
}
|
|
15372
|
+
return { check_runs: checkRuns };
|
|
15373
|
+
},
|
|
15374
|
+
parent({ cwd, headSha, repository }) {
|
|
15375
|
+
return (dependencies.readJson ?? runGhJsonAt)(["api", `/repos/${repository.owner}/${repository.name}/commits/${headSha}`], cwd);
|
|
15376
|
+
}
|
|
15377
|
+
};
|
|
14706
15378
|
}
|
|
15379
|
+
const githubMergeFreezeStore = createGitHubCheckRunMergeFreezeStore(createGitHubCheckRunMergeFreezeApi());
|
|
15380
|
+
/** The one refusal sentence, naming both exits, wherever the freeze refuses. */
|
|
15381
|
+
const activeMergeFreezeReason = (reason) => `Merge freeze is active (${reason}). It clears automatically when the main-push verify workflow completes green after a fix-forward or revert; an operator may waive it for one candidate with patronage-factory demand:waive --pr <pr> --demand merge-freeze --rationale <why>.`;
|
|
14707
15382
|
/**
|
|
14708
|
-
*
|
|
14709
|
-
*
|
|
14710
|
-
*
|
|
15383
|
+
* The freeze read at arming time (#477, ADR 0016 as amended 2026-07-31).
|
|
15384
|
+
*
|
|
15385
|
+
* An **active** generation refuses arming and names both exits. **Unreadable**
|
|
15386
|
+
* authority refuses too — the identity and ordering predicates are unchanged,
|
|
15387
|
+
* so ambiguous, malformed, or foreign-App state still fails closed. A
|
|
15388
|
+
* **settling** generation arms with a notice only when the writer is observable:
|
|
15389
|
+
* either its generation is running, or the prior tip has a settled generation
|
|
15390
|
+
* while this tip's source-pinned hosted verify is running (#521). Epic #473
|
|
15391
|
+
* decision 5 deliberately trades the settle-window wait against the measured
|
|
15392
|
+
* rarity of a red main. A writerless or unprovable repository fails closed.
|
|
14711
15393
|
*/
|
|
14712
|
-
function
|
|
14713
|
-
|
|
14714
|
-
|
|
14715
|
-
|
|
14716
|
-
|
|
14717
|
-
});
|
|
14718
|
-
return held === void 0 ? { status: "clean" } : {
|
|
14719
|
-
held,
|
|
14720
|
-
status: "held"
|
|
15394
|
+
function armingMergeFreezeOutcome(input, authority) {
|
|
15395
|
+
const generation = authority.readGeneration(input);
|
|
15396
|
+
if (generation.kind === "settling") return {
|
|
15397
|
+
blockingReasons: [],
|
|
15398
|
+
notices: [`Merge freeze generation for base ${input.headSha} is still settling (${generation.reason}); arming proceeds (ADR 0016, 2026-07-31 amendment). The main-push verify for that tip still refuses every later arming if it goes red.`]
|
|
14721
15399
|
};
|
|
14722
|
-
|
|
14723
|
-
|
|
14724
|
-
|
|
14725
|
-
if (check.status === "held" && check.held) return `Held branch: '${check.held.branch}' is still checked out in the worktree at ${check.held.worktreePath}. Finish the deferred merge cleanup: remove the worktree, then delete the local branch.`;
|
|
14726
|
-
if (check.status === "clean") return "Held branch: none";
|
|
14727
|
-
return "Held branch: not checked (no branch recorded or worktrees unlistable)";
|
|
14728
|
-
}
|
|
14729
|
-
//#endregion
|
|
14730
|
-
//#region src/pr-readiness/merge-identity.ts
|
|
14731
|
-
var merge_identity_exports = /* @__PURE__ */ __exportAll({
|
|
14732
|
-
evaluateMergeIdentity: () => evaluateMergeIdentity,
|
|
14733
|
-
mergeGuardBlockingReasons: () => mergeGuardBlockingReasons,
|
|
14734
|
-
mergeGuardIdentitySchema: () => mergeGuardIdentitySchema,
|
|
14735
|
-
resolveMergeGuardIdentity: () => resolveMergeGuardIdentity
|
|
14736
|
-
});
|
|
14737
|
-
const evaluateMergeIdentity = ({ liveHeadSha, postProofCommits, proofHeadSha }) => {
|
|
14738
|
-
if (proofHeadSha === liveHeadSha) return {
|
|
14739
|
-
headSha: liveHeadSha,
|
|
14740
|
-
kind: "match"
|
|
15400
|
+
if (generation.kind === "unreadable") return {
|
|
15401
|
+
blockingReasons: [activeMergeFreezeReason(`Authoritative GitHub merge freeze check-run state is unavailable or invalid: ${generation.reason}`)],
|
|
15402
|
+
notices: []
|
|
14741
15403
|
};
|
|
14742
15404
|
return {
|
|
14743
|
-
|
|
14744
|
-
|
|
14745
|
-
|
|
14746
|
-
|
|
14747
|
-
};
|
|
14748
|
-
};
|
|
14749
|
-
const mergeGuardIdentityFor = ({ commitsBetween, liveHeadSha, readyProof, readyProofError, readyProofPath, requestedPr }) => {
|
|
14750
|
-
if (!readyProof) return {
|
|
14751
|
-
kind: "ready-proof-missing",
|
|
14752
|
-
...readyProofError === void 0 ? {} : { errorDetail: readyProofError instanceof Error ? readyProofError.message : String(readyProofError) },
|
|
14753
|
-
readyProofPath
|
|
14754
|
-
};
|
|
14755
|
-
if (readyProof.ledger.pr !== requestedPr) return {
|
|
14756
|
-
kind: "ready-proof-pr-mismatch",
|
|
14757
|
-
proofPr: readyProof.ledger.pr,
|
|
14758
|
-
requestedPr
|
|
14759
|
-
};
|
|
14760
|
-
if (readyProof.status !== "ready") return {
|
|
14761
|
-
blockingReasons: readyProof.blockingReasons,
|
|
14762
|
-
kind: "ready-proof-not-ready",
|
|
14763
|
-
status: readyProof.status
|
|
14764
|
-
};
|
|
14765
|
-
const proofHeadSha = readyProof.ledger.headSha;
|
|
14766
|
-
return evaluateMergeIdentity({
|
|
14767
|
-
liveHeadSha,
|
|
14768
|
-
...proofHeadSha === liveHeadSha ? {} : { postProofCommits: commitsBetween?.(proofHeadSha, liveHeadSha) },
|
|
14769
|
-
proofHeadSha
|
|
14770
|
-
});
|
|
14771
|
-
};
|
|
14772
|
-
/**
|
|
14773
|
-
* Single entry point that owns construction of every MergeGuardIdentity
|
|
14774
|
-
* kind, so adding or changing a kind never requires coordinated edits in the
|
|
14775
|
-
* command runner.
|
|
14776
|
-
*/
|
|
14777
|
-
const resolveMergeGuardIdentity = ({ commitsBetween, liveHead, pr, readyProof, readyProofError, readyProofPath }) => {
|
|
14778
|
-
if ("invalidResponse" in liveHead) return {
|
|
14779
|
-
kind: "live-head-invalid",
|
|
14780
|
-
pr,
|
|
14781
|
-
received: liveHead.invalidResponse
|
|
14782
|
-
};
|
|
14783
|
-
return mergeGuardIdentityFor({
|
|
14784
|
-
commitsBetween,
|
|
14785
|
-
liveHeadSha: liveHead.headSha,
|
|
14786
|
-
readyProof,
|
|
14787
|
-
readyProofError,
|
|
14788
|
-
readyProofPath,
|
|
14789
|
-
requestedPr: pr
|
|
14790
|
-
});
|
|
14791
|
-
};
|
|
14792
|
-
const shaSchema$1 = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
14793
|
-
const postProofCommitSchema = z.object({
|
|
14794
|
-
sha: z.string().min(1),
|
|
14795
|
-
subject: z.string()
|
|
14796
|
-
});
|
|
14797
|
-
const mergeGuardIdentitySchema = z.discriminatedUnion("kind", [
|
|
14798
|
-
z.object({
|
|
14799
|
-
headSha: shaSchema$1,
|
|
14800
|
-
kind: z.literal("match")
|
|
14801
|
-
}),
|
|
14802
|
-
z.object({
|
|
14803
|
-
kind: z.literal("diverged"),
|
|
14804
|
-
liveHeadSha: shaSchema$1,
|
|
14805
|
-
postProofCommits: z.array(postProofCommitSchema).optional(),
|
|
14806
|
-
proofHeadSha: shaSchema$1
|
|
14807
|
-
}),
|
|
14808
|
-
z.object({
|
|
14809
|
-
kind: z.literal("live-head-invalid"),
|
|
14810
|
-
pr: z.number().int().positive(),
|
|
14811
|
-
received: z.string()
|
|
14812
|
-
}),
|
|
14813
|
-
z.object({
|
|
14814
|
-
errorDetail: z.string().optional(),
|
|
14815
|
-
kind: z.literal("ready-proof-missing"),
|
|
14816
|
-
readyProofPath: z.string().min(1)
|
|
14817
|
-
}),
|
|
14818
|
-
z.object({
|
|
14819
|
-
kind: z.literal("ready-proof-pr-mismatch"),
|
|
14820
|
-
proofPr: z.number().int().positive(),
|
|
14821
|
-
requestedPr: z.number().int().positive()
|
|
14822
|
-
}),
|
|
14823
|
-
z.object({
|
|
14824
|
-
blockingReasons: z.array(z.string()),
|
|
14825
|
-
kind: z.literal("ready-proof-not-ready"),
|
|
14826
|
-
status: z.string().min(1)
|
|
14827
|
-
})
|
|
14828
|
-
]);
|
|
14829
|
-
const RERUN_INSTRUCTION = "Re-run patronage-factory pr:verify and pr:ready on the current pushed head, then re-check before merging.";
|
|
14830
|
-
const postProofCommitLines = (postProofCommits) => {
|
|
14831
|
-
if (!postProofCommits) return "Unable to enumerate the post-proof commits locally; run `git fetch` and inspect the range before proceeding.";
|
|
14832
|
-
if (postProofCommits.length === 0) return "No commits found between the proofed head and the pushed head, so branch history may have been rewritten (for example by a force push).";
|
|
14833
|
-
return `Commits pushed after the proof: ${postProofCommits.map((commit) => `${commit.sha} (${commit.subject})`).join(", ")}.`;
|
|
14834
|
-
};
|
|
14835
|
-
const mergeGuardBlockingReasons = (identity) => {
|
|
14836
|
-
switch (identity.kind) {
|
|
14837
|
-
case "match": return [];
|
|
14838
|
-
case "diverged": return [`Proof ledger headSha ${identity.proofHeadSha} does not match the pushed PR head ${identity.liveHeadSha}. ${postProofCommitLines(identity.postProofCommits)} ${RERUN_INSTRUCTION}`];
|
|
14839
|
-
case "live-head-invalid": return [`GitHub did not return a valid 40-hex headRefOid for PR #${identity.pr} (got ${identity.received}). Refusing to compare proof identity; re-fetch the PR head and retry.`];
|
|
14840
|
-
case "ready-proof-missing": return [`Typed pr:ready proof is missing or invalid at ${identity.readyProofPath}${identity.errorDetail ? ` (${identity.errorDetail})` : ""}; run patronage-factory pr:ready before merging.`];
|
|
14841
|
-
case "ready-proof-pr-mismatch": return [`Typed pr:ready proof was evaluated for PR #${identity.proofPr}, not PR #${identity.requestedPr}; run patronage-factory pr:ready for this PR before merging.`];
|
|
14842
|
-
case "ready-proof-not-ready": return [`Typed pr:ready proof status is ${identity.status}, not ready: ${identity.blockingReasons.join(" ") || "no blocking reasons recorded."}`];
|
|
14843
|
-
default: return identity;
|
|
14844
|
-
}
|
|
14845
|
-
};
|
|
14846
|
-
//#endregion
|
|
14847
|
-
//#region src/pr-readiness/status-check-rollup.ts
|
|
14848
|
-
var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
|
|
14849
|
-
isFactoryReadyCheck: () => isFactoryReadyCheck,
|
|
14850
|
-
statusCheckState: () => statusCheckState
|
|
14851
|
-
});
|
|
14852
|
-
const PASSED_STATES = new Set([
|
|
14853
|
-
"SUCCESS",
|
|
14854
|
-
"SKIPPED",
|
|
14855
|
-
"NEUTRAL"
|
|
14856
|
-
]);
|
|
14857
|
-
const FAILED_STATES = new Set([
|
|
14858
|
-
"FAILURE",
|
|
14859
|
-
"ERROR",
|
|
14860
|
-
"CANCELLED",
|
|
14861
|
-
"TIMED_OUT",
|
|
14862
|
-
"ACTION_REQUIRED"
|
|
14863
|
-
]);
|
|
14864
|
-
const PENDING_STATES = new Set([
|
|
14865
|
-
"PENDING",
|
|
14866
|
-
"QUEUED",
|
|
14867
|
-
"IN_PROGRESS",
|
|
14868
|
-
"EXPECTED"
|
|
14869
|
-
]);
|
|
14870
|
-
const isFactoryReadyCheck = (check) => check.context?.startsWith("patronage-factory/") === true || !check.workflowName && check.name?.startsWith("patronage-factory/") === true;
|
|
14871
|
-
const checkContext = (check) => {
|
|
14872
|
-
if (check.workflowName && check.name) return `${check.workflowName} / ${check.name}`;
|
|
14873
|
-
return check.context;
|
|
14874
|
-
};
|
|
14875
|
-
const checkTimestampMs = (check) => {
|
|
14876
|
-
const value = check.startedAt ?? check.completedAt;
|
|
14877
|
-
if (!value) return;
|
|
14878
|
-
const timestamp = Date.parse(value);
|
|
14879
|
-
return Number.isNaN(timestamp) ? void 0 : timestamp;
|
|
14880
|
-
};
|
|
14881
|
-
const effectiveChecks = (rollup) => {
|
|
14882
|
-
const checksByContext = /* @__PURE__ */ new Map();
|
|
14883
|
-
const unkeyed = [];
|
|
14884
|
-
for (const [index, check] of rollup.entries()) {
|
|
14885
|
-
const context = checkContext(check);
|
|
14886
|
-
if (!context) {
|
|
14887
|
-
unkeyed.push(check);
|
|
14888
|
-
continue;
|
|
14889
|
-
}
|
|
14890
|
-
const checks = checksByContext.get(context) ?? [];
|
|
14891
|
-
checks.push({
|
|
14892
|
-
check,
|
|
14893
|
-
index,
|
|
14894
|
-
timestampMs: checkTimestampMs(check)
|
|
14895
|
-
});
|
|
14896
|
-
checksByContext.set(context, checks);
|
|
14897
|
-
}
|
|
14898
|
-
const currentChecks = [...unkeyed];
|
|
14899
|
-
for (const checks of checksByContext.values()) {
|
|
14900
|
-
const [firstCheck] = checks;
|
|
14901
|
-
if (!firstCheck) continue;
|
|
14902
|
-
if (checks.length === 1) {
|
|
14903
|
-
currentChecks.push(firstCheck.check);
|
|
14904
|
-
continue;
|
|
14905
|
-
}
|
|
14906
|
-
if (checks.some((check) => check.timestampMs === void 0)) {
|
|
14907
|
-
currentChecks.push(...checks.map((check) => check.check));
|
|
14908
|
-
continue;
|
|
14909
|
-
}
|
|
14910
|
-
let latest = firstCheck;
|
|
14911
|
-
for (const candidate of checks.slice(1)) {
|
|
14912
|
-
if (latest.timestampMs === void 0 || candidate.timestampMs === void 0) continue;
|
|
14913
|
-
if (candidate.timestampMs > latest.timestampMs) {
|
|
14914
|
-
latest = candidate;
|
|
14915
|
-
continue;
|
|
14916
|
-
}
|
|
14917
|
-
if (candidate.timestampMs === latest.timestampMs && candidate.index > latest.index) latest = candidate;
|
|
14918
|
-
}
|
|
14919
|
-
currentChecks.push(latest.check);
|
|
14920
|
-
}
|
|
14921
|
-
return currentChecks;
|
|
14922
|
-
};
|
|
14923
|
-
const terminalState = (check) => check.conclusion ?? check.state ?? "";
|
|
14924
|
-
const progressState = (check) => check.status ?? check.state ?? "";
|
|
14925
|
-
const statusCheckState = (rollup, exclude) => {
|
|
14926
|
-
const included = rollup?.filter((check) => !exclude?.(check));
|
|
14927
|
-
if (!included || included.length === 0) return "none";
|
|
14928
|
-
const currentChecks = effectiveChecks(included);
|
|
14929
|
-
if (currentChecks.every((check) => PASSED_STATES.has(terminalState(check)))) return "passed";
|
|
14930
|
-
if (currentChecks.some((check) => FAILED_STATES.has(terminalState(check)))) return "failed";
|
|
14931
|
-
if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
|
|
14932
|
-
return "unknown";
|
|
14933
|
-
};
|
|
15405
|
+
blockingReasons: generation.state.active ? [activeMergeFreezeReason(generation.state.reason)] : [],
|
|
15406
|
+
notices: []
|
|
15407
|
+
};
|
|
15408
|
+
}
|
|
14934
15409
|
//#endregion
|
|
14935
15410
|
//#region src/pr-ready.ts
|
|
14936
15411
|
const CHECK_CONCLUSION_BY_READINESS_STATUS = {
|
|
@@ -14938,7 +15413,6 @@ const CHECK_CONCLUSION_BY_READINESS_STATUS = {
|
|
|
14938
15413
|
ready: "success",
|
|
14939
15414
|
"slice-ready/not-final": "failure"
|
|
14940
15415
|
};
|
|
14941
|
-
const DEFAULT_PR_READY_PROOF_PATH = ".factory-memory/pr-ready.json";
|
|
14942
15416
|
function defaultPrReadyGit() {
|
|
14943
15417
|
return {
|
|
14944
15418
|
canResolveCommit,
|
|
@@ -14968,7 +15442,7 @@ function defaultPrReadyGit() {
|
|
|
14968
15442
|
], cwd).stdout.trim();
|
|
14969
15443
|
} catch {}
|
|
14970
15444
|
},
|
|
14971
|
-
showFileAtRef
|
|
15445
|
+
showFileAtRef,
|
|
14972
15446
|
stablePatchId
|
|
14973
15447
|
};
|
|
14974
15448
|
}
|
|
@@ -15016,22 +15490,33 @@ function defaultPrReadyGithub() {
|
|
|
15016
15490
|
};
|
|
15017
15491
|
}
|
|
15018
15492
|
const prReadyProofSchema = z.object({
|
|
15493
|
+
arming: z.object({
|
|
15494
|
+
detail: z.string().min(1).optional(),
|
|
15495
|
+
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
15496
|
+
outcome: z.enum([
|
|
15497
|
+
"armed",
|
|
15498
|
+
"merged",
|
|
15499
|
+
"not-armed"
|
|
15500
|
+
])
|
|
15501
|
+
}).optional(),
|
|
15019
15502
|
blockedReasons: blockedReasonsSchema.optional(),
|
|
15020
15503
|
blockingReasons: z.array(z.string()),
|
|
15021
15504
|
command: z.literal("patronage-factory pr:ready"),
|
|
15022
15505
|
followUp: FollowUpActionSchema.optional(),
|
|
15023
15506
|
humanBlockingReasons: z.array(z.string()).default([]),
|
|
15024
15507
|
ledger: managedReadinessLedgerSchema,
|
|
15508
|
+
notices: z.array(z.string().min(1)).optional(),
|
|
15025
15509
|
profileBlobSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
|
|
15026
15510
|
profilePath: z.string().min(1).optional(),
|
|
15027
15511
|
repairs: z.array(readinessRepairSchema).default([]),
|
|
15028
15512
|
repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
|
|
15029
|
-
schemaVersion: z.literal(
|
|
15513
|
+
schemaVersion: z.literal(3),
|
|
15030
15514
|
status: z.enum([
|
|
15031
15515
|
"ready",
|
|
15032
15516
|
"blocked",
|
|
15033
15517
|
"slice-ready/not-final"
|
|
15034
|
-
])
|
|
15518
|
+
]),
|
|
15519
|
+
waivedDemands: z.array(waivedDemandSchema).optional()
|
|
15035
15520
|
}).superRefine((proof, context) => {
|
|
15036
15521
|
for (const issue of blockedReasonIssues({
|
|
15037
15522
|
...proof,
|
|
@@ -15044,18 +15529,12 @@ function validatePrReadyProof(value) {
|
|
|
15044
15529
|
function readPrReadyProof(filePath) {
|
|
15045
15530
|
return readProofFromPath(validatePrReadyProof, filePath);
|
|
15046
15531
|
}
|
|
15047
|
-
const PR_READY_PROOF_DESCRIPTOR = {
|
|
15048
|
-
defaultPath: DEFAULT_PR_READY_PROOF_PATH,
|
|
15049
|
-
label: "pr:ready proof",
|
|
15050
|
-
parse: validatePrReadyProof,
|
|
15051
|
-
schemaVersion: 2
|
|
15052
|
-
};
|
|
15053
15532
|
const PR_VIEW_JSON_FIELDS = [
|
|
15533
|
+
"autoMergeRequest",
|
|
15054
15534
|
"baseRefName",
|
|
15055
15535
|
"body",
|
|
15056
15536
|
"headRefOid",
|
|
15057
15537
|
"isDraft",
|
|
15058
|
-
"labels",
|
|
15059
15538
|
"mergeStateStatus",
|
|
15060
15539
|
"mergeable",
|
|
15061
15540
|
"number",
|
|
@@ -15267,7 +15746,7 @@ function policyProfileAtCommittedHead({ cwd, git, headSha, profilePath }) {
|
|
|
15267
15746
|
profile: committedProfile
|
|
15268
15747
|
};
|
|
15269
15748
|
}
|
|
15270
|
-
function checkoutRelativeProfilePath
|
|
15749
|
+
function checkoutRelativeProfilePath(cwd, profilePath) {
|
|
15271
15750
|
const relativePath = path.relative(cwd, profilePath);
|
|
15272
15751
|
if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error("The readiness profile must be inside the trusted checkout.");
|
|
15273
15752
|
return relativePath.split(path.sep).join("/");
|
|
@@ -15286,6 +15765,12 @@ function docsOnlyDeltaSinceReviewProof({ classification, cwd, git, profile, prof
|
|
|
15286
15765
|
targetHeadSha
|
|
15287
15766
|
});
|
|
15288
15767
|
}
|
|
15768
|
+
/** How each arming outcome reads to a human, including the one that failed. */
|
|
15769
|
+
const armingNotice = (arming) => {
|
|
15770
|
+
if (arming.outcome === "armed") return `Native auto-merge is armed for ${arming.headSha}; GitHub merges this candidate once both source-pinned required checks are green on that head.`;
|
|
15771
|
+
if (arming.outcome === "merged") return `This candidate merged during the arming call: ${arming.detail ?? "GitHub found it already mergeable."}`;
|
|
15772
|
+
return `Native auto-merge was NOT armed for ${arming.headSha}: ${arming.detail ?? "unknown reason"} Nothing will merge this candidate until the recorded follow-up runs.`;
|
|
15773
|
+
};
|
|
15289
15774
|
async function runPrReady(args, dependencies = {}) {
|
|
15290
15775
|
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
15291
15776
|
const profilePath = readinessProfilePath(cwd, args.profilePath);
|
|
@@ -15301,19 +15786,6 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15301
15786
|
}),
|
|
15302
15787
|
sleep: dependencies.sleep
|
|
15303
15788
|
});
|
|
15304
|
-
publishFactoryCheckSafely(dependencies.publishCheckRun, {
|
|
15305
|
-
cwd,
|
|
15306
|
-
gate: "pr-ready",
|
|
15307
|
-
owner: repository.owner,
|
|
15308
|
-
pr: args.pr,
|
|
15309
|
-
proof: {
|
|
15310
|
-
schemaVersion: 2,
|
|
15311
|
-
status: "evaluating"
|
|
15312
|
-
},
|
|
15313
|
-
repo: repository.name,
|
|
15314
|
-
sha: pr.headRefOid,
|
|
15315
|
-
status: "in_progress"
|
|
15316
|
-
});
|
|
15317
15789
|
let finalCheckPublished = false;
|
|
15318
15790
|
try {
|
|
15319
15791
|
const authoritativeBaseSha = authoritativePrBase({
|
|
@@ -15336,7 +15808,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15336
15808
|
});
|
|
15337
15809
|
const policyProfile = committedPolicy.profile;
|
|
15338
15810
|
if (repositorySlug(policyProfile.repository) !== checkoutRepositorySlug) throw new Error(`Committed readiness profile repository ${repositorySlug(policyProfile.repository)} does not match trusted checkout repository ${checkoutRepositorySlug}.`);
|
|
15339
|
-
const committedProfilePath = checkoutRelativeProfilePath
|
|
15811
|
+
const committedProfilePath = checkoutRelativeProfilePath(cwd, profilePath);
|
|
15340
15812
|
const verifyProofPath = path.resolve(cwd, args.verifyProof ?? ".factory-memory/pr-verify.json");
|
|
15341
15813
|
const reviewProofPath = path.resolve(cwd, args.reviewProof ?? ".factory-memory/pr-review.json");
|
|
15342
15814
|
const verifyProofRead = tryReadProof(readPrVerifyProof, verifyProofPath);
|
|
@@ -15404,9 +15876,21 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15404
15876
|
sha: pr.headRefOid
|
|
15405
15877
|
});
|
|
15406
15878
|
const externalCheckState = statusCheckState(pr.statusCheckRollup, isFactoryReadyCheck);
|
|
15879
|
+
const hostedVerifyCheck = hostedVerifyCheckState(pr.statusCheckRollup);
|
|
15407
15880
|
const effectiveMergeStateStatus = pr.mergeStateStatus === "UNSTABLE" && (externalCheckState === "passed" || externalCheckState === "pending" || externalCheckState === "none") ? "CLEAN" : pr.mergeStateStatus;
|
|
15881
|
+
const mergeFreeze = armingMergeFreezeOutcome({
|
|
15882
|
+
cwd,
|
|
15883
|
+
headSha: pr.baseRefOid,
|
|
15884
|
+
repository
|
|
15885
|
+
}, dependencies.mergeFreeze ?? githubMergeFreezeStore);
|
|
15886
|
+
const candidateWaivers = selectWaiversForCandidate({
|
|
15887
|
+
headSha: pr.headRefOid,
|
|
15888
|
+
pr: args.pr,
|
|
15889
|
+
waivers: readDemandWaivers(cwd)
|
|
15890
|
+
});
|
|
15408
15891
|
const evaluation = evaluateReadiness({
|
|
15409
15892
|
...externalEvidence,
|
|
15893
|
+
autoMergeEnabled: Boolean(pr.autoMergeRequest),
|
|
15410
15894
|
baseSha: pr.baseRefOid,
|
|
15411
15895
|
body: args.bodyForEvaluation ?? pr.body,
|
|
15412
15896
|
...handledCommentInputs,
|
|
@@ -15417,14 +15901,15 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15417
15901
|
docsOnlyVerifyBaselineHeadShas: verification.fullVerifiedHeadShas,
|
|
15418
15902
|
draft: pr.isDraft,
|
|
15419
15903
|
headSha: pr.headRefOid,
|
|
15904
|
+
hostedVerifyCheck,
|
|
15420
15905
|
ladderPolicy,
|
|
15421
15906
|
localHeadSha,
|
|
15422
15907
|
mergeBaseSha: candidateIdentityForBase(authoritativeBaseSha).mergeBaseSha,
|
|
15908
|
+
mergeFreeze,
|
|
15423
15909
|
mergeStateStatus: effectiveMergeStateStatus,
|
|
15424
15910
|
mergeable: pr.mergeable,
|
|
15425
15911
|
patchId,
|
|
15426
15912
|
pr: args.pr,
|
|
15427
|
-
prLabels: pr.labels?.map((label) => label.name),
|
|
15428
15913
|
prTitle: pr.title,
|
|
15429
15914
|
requiredChecks: externalCheckState,
|
|
15430
15915
|
reviewModes: demands.reviewModes,
|
|
@@ -15432,7 +15917,9 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15432
15917
|
reviews: pr.reviews,
|
|
15433
15918
|
unresolvedReviewThreads: pr.unresolvedReviewThreads,
|
|
15434
15919
|
verificationProof: verification.verificationProof,
|
|
15920
|
+
waivers: candidateWaivers,
|
|
15435
15921
|
...waveDemand?.demand ? { waveReviewDemand: {
|
|
15922
|
+
autoMerge: waveDemand.demand.autoMerge,
|
|
15436
15923
|
review: waveDemand.demand.review,
|
|
15437
15924
|
wave: waveDemand.demand.wave
|
|
15438
15925
|
} } : {}
|
|
@@ -15443,16 +15930,26 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15443
15930
|
});
|
|
15444
15931
|
const undraftRepair = evaluation.repairs.find((repair) => repair.code === "undraft-pr");
|
|
15445
15932
|
const pendingExternalChecksOnly = externalCheckState === "pending" && evaluation.blockingReasons.length === 1;
|
|
15446
|
-
|
|
15447
|
-
|
|
15933
|
+
const boundary = waveDemand?.demand;
|
|
15934
|
+
const arming = evaluation.status === "ready" && boundary ? (dependencies.armAutoMerge ?? armAutoMerge)({
|
|
15935
|
+
cwd,
|
|
15936
|
+
headSha: pr.headRefOid,
|
|
15937
|
+
owner: repository.owner,
|
|
15938
|
+
pr: args.pr,
|
|
15939
|
+
repo: repository.name
|
|
15940
|
+
}) : void 0;
|
|
15941
|
+
const readyRedispatch = () => followUpFromArgv([
|
|
15448
15942
|
"patronage-factory",
|
|
15449
|
-
"pr:
|
|
15943
|
+
"pr:ready",
|
|
15450
15944
|
"--pr",
|
|
15451
15945
|
String(args.pr),
|
|
15452
15946
|
"--profile",
|
|
15453
15947
|
committedProfilePath,
|
|
15454
|
-
...args.
|
|
15948
|
+
...args.epic === void 0 ? [] : ["--epic", String(args.epic)],
|
|
15949
|
+
...args.output ? ["--output", args.output] : []
|
|
15455
15950
|
]);
|
|
15951
|
+
let followUp;
|
|
15952
|
+
if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
|
|
15456
15953
|
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = followUpFromArgv([
|
|
15457
15954
|
"gh",
|
|
15458
15955
|
"pr",
|
|
@@ -15460,32 +15957,29 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15460
15957
|
String(args.pr)
|
|
15461
15958
|
]);
|
|
15462
15959
|
else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
|
|
15960
|
+
const notices = [
|
|
15961
|
+
...evaluation.notices,
|
|
15962
|
+
...arming ? [armingNotice(arming)] : [],
|
|
15963
|
+
...evaluation.status === "ready" && !boundary ? ["No boundary wave authorized this candidate, so nothing was armed: pr:ready admits it but schedules no merge. Re-run with --epic <issue> to resolve the wave demand and arm."] : []
|
|
15964
|
+
];
|
|
15463
15965
|
const proof = {
|
|
15966
|
+
...arming ? { arming } : {},
|
|
15464
15967
|
...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
|
|
15465
15968
|
blockingReasons: evaluation.blockingReasons,
|
|
15466
15969
|
command: "patronage-factory pr:ready",
|
|
15467
15970
|
...followUp ? { followUp } : {},
|
|
15468
15971
|
humanBlockingReasons: evaluation.humanBlockingReasons,
|
|
15469
15972
|
ledger: evaluation.ledger,
|
|
15973
|
+
...notices.length > 0 ? { notices } : {},
|
|
15470
15974
|
profileBlobSha: committedPolicy.blobSha,
|
|
15471
15975
|
profilePath: committedProfilePath,
|
|
15472
15976
|
repairs: evaluation.repairs,
|
|
15473
15977
|
repository: checkoutRepositorySlug,
|
|
15474
|
-
schemaVersion:
|
|
15475
|
-
status: evaluation.status
|
|
15978
|
+
schemaVersion: 3,
|
|
15979
|
+
status: evaluation.status,
|
|
15980
|
+
...evaluation.waivedDemands.length > 0 ? { waivedDemands: evaluation.waivedDemands } : {}
|
|
15476
15981
|
};
|
|
15477
|
-
|
|
15478
|
-
enqueueHqIngest({
|
|
15479
|
-
cwd,
|
|
15480
|
-
kind: "pr-ready-proof",
|
|
15481
|
-
payload: proof,
|
|
15482
|
-
payloadPath: output,
|
|
15483
|
-
profile: policyProfile,
|
|
15484
|
-
subject: { prNumber: args.pr }
|
|
15485
|
-
}, dependencies.hq);
|
|
15486
|
-
reportProof(proof, output, Boolean(args.json), args.report ?? true);
|
|
15487
|
-
publishFactoryCheckSafely(dependencies.publishCheckRun, {
|
|
15488
|
-
...pendingExternalChecksOnly ? { status: "in_progress" } : { conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status] },
|
|
15982
|
+
const finalCheck = {
|
|
15489
15983
|
cwd,
|
|
15490
15984
|
gate: "pr-ready",
|
|
15491
15985
|
hqLaneBaseUrl: hqLaneRefBaseUrlFromProfile(policyProfile),
|
|
@@ -15494,14 +15988,34 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15494
15988
|
proof,
|
|
15495
15989
|
repo: repository.name,
|
|
15496
15990
|
sha: pr.headRefOid
|
|
15991
|
+
};
|
|
15992
|
+
const publishedFinalCheck = await (dependencies.publishFinalCheckRun ?? ensureFactoryCheckRunPublished)({
|
|
15993
|
+
...finalCheck,
|
|
15994
|
+
conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status]
|
|
15497
15995
|
});
|
|
15498
15996
|
finalCheckPublished = true;
|
|
15997
|
+
if (!publishedFinalCheck) {
|
|
15998
|
+
notices.push(`The ${FACTORY_CHECK_NAMES["pr-ready"]} check run was NOT confirmed for ${pr.headRefOid}, so GitHub is not serving this run's verdict as the settled state of the required check. Either the publication never landed there, or another run of that name is still unfinished on this head (#524) — an unfinished run blocks the merge whatever this run published, and publishing a newer one does not clear it. GitHub reports no rollup reason for either. Re-run pr:ready once the factory GitHub App can post; if it stays unconfirmed, look for a non-terminal ${FACTORY_CHECK_NAMES["pr-ready"]} run on ${pr.headRefOid}.`);
|
|
15999
|
+
proof.notices = [...notices];
|
|
16000
|
+
proof.followUp ??= readyRedispatch();
|
|
16001
|
+
}
|
|
16002
|
+
for (const notice of notices) console.warn(`pr:ready notice: ${notice}`);
|
|
16003
|
+
writeProof(proof, output);
|
|
16004
|
+
enqueueHqIngest({
|
|
16005
|
+
cwd,
|
|
16006
|
+
kind: "pr-ready-proof",
|
|
16007
|
+
payload: proof,
|
|
16008
|
+
payloadPath: output,
|
|
16009
|
+
profile: policyProfile,
|
|
16010
|
+
subject: { prNumber: args.pr }
|
|
16011
|
+
}, dependencies.hq);
|
|
16012
|
+
reportProof(proof, output, Boolean(args.json), args.report ?? true);
|
|
15499
16013
|
if (proof.status !== "ready" && (args.throwWhenBlocked ?? true)) throw new Error(`pr:ready blocked: ${proof.blockingReasons.join(" ")}`);
|
|
15500
16014
|
return proof;
|
|
15501
16015
|
} catch (error) {
|
|
15502
16016
|
if (!finalCheckPublished) {
|
|
15503
16017
|
const message = error instanceof Error ? error.message : String(error);
|
|
15504
|
-
|
|
16018
|
+
if (!await (dependencies.publishFinalCheckRun ?? ensureFactoryCheckRunPublished)({
|
|
15505
16019
|
conclusion: "failure",
|
|
15506
16020
|
cwd,
|
|
15507
16021
|
gate: "pr-ready",
|
|
@@ -15509,506 +16023,17 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
15509
16023
|
pr: args.pr,
|
|
15510
16024
|
proof: {
|
|
15511
16025
|
blockingReasons: [message],
|
|
15512
|
-
schemaVersion:
|
|
16026
|
+
schemaVersion: 3,
|
|
15513
16027
|
status: "blocked"
|
|
15514
16028
|
},
|
|
15515
16029
|
repo: repository.name,
|
|
15516
16030
|
sha: pr.headRefOid
|
|
15517
|
-
});
|
|
16031
|
+
})) console.warn(`pr:ready could not publish the failing ${FACTORY_CHECK_NAMES["pr-ready"]} check run for ${pr.headRefOid}; the required check is absent rather than red.`);
|
|
15518
16032
|
}
|
|
15519
16033
|
throw error;
|
|
15520
16034
|
}
|
|
15521
16035
|
}
|
|
15522
16036
|
//#endregion
|
|
15523
|
-
//#region src/pr-merge-check.ts
|
|
15524
|
-
const PR_MERGE_CHECK_SCHEMA_VERSION = 1;
|
|
15525
|
-
const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
15526
|
-
const prMergeCheckProofSchema = z.object({
|
|
15527
|
-
blockingReasons: z.array(z.string()),
|
|
15528
|
-
boundary: z.object({
|
|
15529
|
-
autoMerge: z.boolean(),
|
|
15530
|
-
epic: z.number().int().positive(),
|
|
15531
|
-
review: z.enum([
|
|
15532
|
-
"independent-model",
|
|
15533
|
-
"oracle",
|
|
15534
|
-
"human"
|
|
15535
|
-
]),
|
|
15536
|
-
wave: z.string().min(1)
|
|
15537
|
-
}).optional(),
|
|
15538
|
-
command: z.literal("patronage-factory pr:merge-check"),
|
|
15539
|
-
followUp: FollowUpActionSchema.optional(),
|
|
15540
|
-
identity: mergeGuardIdentitySchema,
|
|
15541
|
-
liveHeadSha: shaSchema.optional(),
|
|
15542
|
-
notices: z.array(z.string()).optional(),
|
|
15543
|
-
pr: z.number().int().positive(),
|
|
15544
|
-
schemaVersion: z.literal(PR_MERGE_CHECK_SCHEMA_VERSION),
|
|
15545
|
-
status: z.enum(["pass", "fail"]),
|
|
15546
|
-
waivedDemands: z.array(waivedDemandSchema).optional(),
|
|
15547
|
-
worktreeHeldBranch: z.object({
|
|
15548
|
-
branch: z.string().min(1),
|
|
15549
|
-
worktreePath: z.string().min(1)
|
|
15550
|
-
}).optional(),
|
|
15551
|
-
worktreeHeldBranches: z.array(z.object({
|
|
15552
|
-
branch: z.string().min(1),
|
|
15553
|
-
worktreePath: z.string().min(1)
|
|
15554
|
-
})).optional()
|
|
15555
|
-
});
|
|
15556
|
-
function validatePrMergeCheckProof(value) {
|
|
15557
|
-
return prMergeCheckProofSchema.parse(value);
|
|
15558
|
-
}
|
|
15559
|
-
function readPrMergeCheckProof(filePath) {
|
|
15560
|
-
return readProofFromPath(validatePrMergeCheckProof, filePath);
|
|
15561
|
-
}
|
|
15562
|
-
function commitsBetween(cwd, fromSha, toSha) {
|
|
15563
|
-
try {
|
|
15564
|
-
return runCapture("git", [
|
|
15565
|
-
"log",
|
|
15566
|
-
"--format=%H%x09%s",
|
|
15567
|
-
`${fromSha}..${toSha}`
|
|
15568
|
-
], cwd).stdout.split("\n").filter(Boolean).map((line) => {
|
|
15569
|
-
const [sha = "", ...subject] = line.split(" ");
|
|
15570
|
-
return {
|
|
15571
|
-
sha,
|
|
15572
|
-
subject: subject.join(" ")
|
|
15573
|
-
};
|
|
15574
|
-
});
|
|
15575
|
-
} catch {
|
|
15576
|
-
return;
|
|
15577
|
-
}
|
|
15578
|
-
}
|
|
15579
|
-
function fetchPullRequestHead({ cwd, owner, pr, repo }) {
|
|
15580
|
-
const head = JSON.parse(runCapture("gh", [
|
|
15581
|
-
"pr",
|
|
15582
|
-
"view",
|
|
15583
|
-
String(pr),
|
|
15584
|
-
"--repo",
|
|
15585
|
-
`${owner}/${repo}`,
|
|
15586
|
-
"--json",
|
|
15587
|
-
"baseRefName,headRefName,headRefOid,headRepository,headRepositoryOwner,labels,mergedAt"
|
|
15588
|
-
], cwd).stdout);
|
|
15589
|
-
const baseRefOid = runCapture("gh", [
|
|
15590
|
-
"api",
|
|
15591
|
-
`repos/${owner}/${repo}/pulls/${pr}`,
|
|
15592
|
-
"--jq",
|
|
15593
|
-
".base.sha"
|
|
15594
|
-
], cwd).stdout.trim();
|
|
15595
|
-
let remoteHeadRefExists;
|
|
15596
|
-
const headOwner = head.headRepositoryOwner?.login;
|
|
15597
|
-
const headRepo = head.headRepository?.name;
|
|
15598
|
-
if (head.headRefName && headOwner && headRepo) try {
|
|
15599
|
-
remoteHeadRefExists = JSON.parse(runCapture("gh", ["api", `repos/${headOwner}/${headRepo}/git/matching-refs/heads/${head.headRefName}`], cwd).stdout).some((entry) => entry.ref === `refs/heads/${head.headRefName}`);
|
|
15600
|
-
} catch {}
|
|
15601
|
-
return {
|
|
15602
|
-
...head,
|
|
15603
|
-
baseRefOid,
|
|
15604
|
-
remoteHeadRefExists
|
|
15605
|
-
};
|
|
15606
|
-
}
|
|
15607
|
-
const pullRequestHeadOidSchema = z.object({ headRefOid: shaSchema });
|
|
15608
|
-
const pullRequestHeadRefNameSchema = z.object({ headRefName: z.string().min(1) });
|
|
15609
|
-
const pullRequestLabelsSchema = z.object({ labels: z.array(z.object({ name: z.string().min(1) })) });
|
|
15610
|
-
const pullRequestBaseOidSchema = z.object({ baseRefOid: shaSchema });
|
|
15611
|
-
function showFileAtRef(cwd, ref, absolutePath) {
|
|
15612
|
-
const relativePath = path.relative(cwd, absolutePath);
|
|
15613
|
-
const refPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
15614
|
-
try {
|
|
15615
|
-
return runCapture("git", ["show", `${ref}:${refPath}`], cwd).stdout;
|
|
15616
|
-
} catch {
|
|
15617
|
-
return;
|
|
15618
|
-
}
|
|
15619
|
-
}
|
|
15620
|
-
function objectIdAtRef(cwd, ref, absolutePath) {
|
|
15621
|
-
const relativePath = path.relative(cwd, absolutePath);
|
|
15622
|
-
try {
|
|
15623
|
-
return runCapture("git", ["rev-parse", `${ref}:${relativePath}`], cwd).stdout.trim();
|
|
15624
|
-
} catch {
|
|
15625
|
-
return;
|
|
15626
|
-
}
|
|
15627
|
-
}
|
|
15628
|
-
function profileAtRef({ cwd, git, profilePath, ref }) {
|
|
15629
|
-
if (!ref) return;
|
|
15630
|
-
const content = git.showFileAtRef(cwd, ref, profilePath);
|
|
15631
|
-
return content ? tryParseProfileContent(content) : void 0;
|
|
15632
|
-
}
|
|
15633
|
-
function committedPolicyState({ cwd, git, liveHeadResponse, profilePath }) {
|
|
15634
|
-
const parsedHead = pullRequestHeadOidSchema.safeParse(liveHeadResponse);
|
|
15635
|
-
const parsedBase = pullRequestBaseOidSchema.safeParse(liveHeadResponse);
|
|
15636
|
-
const parsedLabels = pullRequestLabelsSchema.safeParse(liveHeadResponse);
|
|
15637
|
-
return {
|
|
15638
|
-
committedProfile: profileAtRef({
|
|
15639
|
-
cwd,
|
|
15640
|
-
git,
|
|
15641
|
-
profilePath,
|
|
15642
|
-
ref: parsedHead.success ? parsedHead.data.headRefOid : void 0
|
|
15643
|
-
}),
|
|
15644
|
-
committedProfileBlobSha: parsedHead.success ? git.objectIdAtRef(cwd, parsedHead.data.headRefOid, profilePath) : void 0,
|
|
15645
|
-
currentMergeBaseSha: parsedBase.success ? parsedBase.data.baseRefOid : void 0,
|
|
15646
|
-
liveLabels: parsedLabels.success ? parsedLabels.data.labels.map((label) => label.name) : void 0
|
|
15647
|
-
};
|
|
15648
|
-
}
|
|
15649
|
-
function checkoutRelativeProfilePath(cwd, profilePath) {
|
|
15650
|
-
const relativePath = path.relative(cwd, profilePath);
|
|
15651
|
-
if (relativePath.length === 0 || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) throw new Error("The merge-check profile must be inside the trusted checkout.");
|
|
15652
|
-
return relativePath.split(path.sep).join("/");
|
|
15653
|
-
}
|
|
15654
|
-
function proofBindingReasons({ committedProfile, committedProfileBlobSha, profilePath, readyProof, repository }) {
|
|
15655
|
-
if (!readyProof) return [];
|
|
15656
|
-
const checkoutSlug = repositorySlug(repository);
|
|
15657
|
-
const reasons = [];
|
|
15658
|
-
if (!readyProof.repository || normalizeRepositorySlug(readyProof.repository) !== checkoutSlug) reasons.push(`Readiness proof repository ${readyProof.repository ?? "missing"} does not match trusted checkout repository ${checkoutSlug}; re-run pr:ready in this checkout.`);
|
|
15659
|
-
if (readyProof.profilePath !== profilePath) reasons.push(`Readiness proof profile path ${readyProof.profilePath ?? "missing"} does not match merge-check profile path ${profilePath}; re-run pr:ready with the same committed profile.`);
|
|
15660
|
-
if (!readyProof.profileBlobSha || !committedProfileBlobSha || readyProof.profileBlobSha !== committedProfileBlobSha) reasons.push(`Readiness proof profile blob ${readyProof.profileBlobSha ?? "missing"} does not match committed profile blob ${committedProfileBlobSha ?? "unresolvable"}; re-run pr:ready from the committed PR head.`);
|
|
15661
|
-
if (committedProfile && repositorySlug(committedProfile.repository) !== checkoutSlug) reasons.push(`Committed profile repository ${repositorySlug(committedProfile.repository)} does not match trusted checkout repository ${checkoutSlug}; merge is blocked.`);
|
|
15662
|
-
return reasons;
|
|
15663
|
-
}
|
|
15664
|
-
function requiredCheckDemandGroups({ committedProfile, liveLabels, readyProof }) {
|
|
15665
|
-
const ledger = readyProof?.ledger;
|
|
15666
|
-
if (!committedProfile) return [{ reasons: ["Could not read the project profile from the committed PR candidate; merge-time requiredChecks policy must come from committed refs. Fetch the PR head, then re-run pr:merge-check."] }];
|
|
15667
|
-
const recordedChecks = ledger?.externalChecks ?? [];
|
|
15668
|
-
return (committedProfile.requiredChecks ?? []).map((requiredCheck) => ({
|
|
15669
|
-
demand: requiredCheckDemand(requiredCheck.name),
|
|
15670
|
-
reasons: requiredCheckDriftReasons({
|
|
15671
|
-
classification: ledger?.classification,
|
|
15672
|
-
liveLabels,
|
|
15673
|
-
recordedChecks,
|
|
15674
|
-
requiredCheck
|
|
15675
|
-
})
|
|
15676
|
-
}));
|
|
15677
|
-
}
|
|
15678
|
-
function requiredCheckDriftReasons({ classification, liveLabels, recordedChecks, requiredCheck }) {
|
|
15679
|
-
const scopeDecision = requiredCheckInScope({
|
|
15680
|
-
context: {
|
|
15681
|
-
classification,
|
|
15682
|
-
labels: liveLabels
|
|
15683
|
-
},
|
|
15684
|
-
scope: requiredCheck.scope
|
|
15685
|
-
});
|
|
15686
|
-
const recorded = recordedChecks.find((candidate) => candidate.name === requiredCheck.name && candidate.checkType === requiredCheck.checkType);
|
|
15687
|
-
if (!recorded) return [`Committed required check "${requiredCheck.name}" is missing from the ready proof; re-run pr:ready from the committed PR head.`];
|
|
15688
|
-
if (JSON.stringify(recorded.scope) !== JSON.stringify(requiredCheck.scope)) return [`Committed required check "${requiredCheck.name}" does not match the scope recorded by pr:ready; re-run pr:ready from the committed PR head.`];
|
|
15689
|
-
if (!scopeDecision.inScope) return [];
|
|
15690
|
-
if (requiredCheck.scope?.labels !== void 0 && liveLabels === void 0) return [`Could not read the live PR labels to re-check conditional required check "${requiredCheck.name}"; fail closed — re-run pr:ready.`];
|
|
15691
|
-
if (recorded.status !== "satisfied") return [`Required check "${requiredCheck.name}" is in scope for the merge candidate but the ready proof recorded it "${recorded.status}"; re-run pr:ready.`];
|
|
15692
|
-
return [];
|
|
15693
|
-
}
|
|
15694
|
-
function mergeCheckFollowUp(status, pr, identity, profilePath, repository, matchedHeadSha, mergeFreezeActive) {
|
|
15695
|
-
if (status === "pass") {
|
|
15696
|
-
if (!matchedHeadSha) throw new Error("Passing merge-check is missing its validated head SHA.");
|
|
15697
|
-
return followUpFromArgv([
|
|
15698
|
-
"gh",
|
|
15699
|
-
"api",
|
|
15700
|
-
"--method",
|
|
15701
|
-
"PUT",
|
|
15702
|
-
`repos/${repositorySlug(repository)}/pulls/${pr}/merge`,
|
|
15703
|
-
"-f",
|
|
15704
|
-
`sha=${matchedHeadSha}`,
|
|
15705
|
-
"-f",
|
|
15706
|
-
"merge_method=squash"
|
|
15707
|
-
]);
|
|
15708
|
-
}
|
|
15709
|
-
if (mergeFreezeActive) return;
|
|
15710
|
-
if (identity.kind === "diverged") return followUpFromArgv([
|
|
15711
|
-
...prVerifyFollowUp().argv,
|
|
15712
|
-
"--profile",
|
|
15713
|
-
profilePath
|
|
15714
|
-
]);
|
|
15715
|
-
return followUpFromArgv([
|
|
15716
|
-
"patronage-factory",
|
|
15717
|
-
"pr:ready",
|
|
15718
|
-
"--pr",
|
|
15719
|
-
String(pr),
|
|
15720
|
-
"--profile",
|
|
15721
|
-
profilePath
|
|
15722
|
-
]);
|
|
15723
|
-
}
|
|
15724
|
-
function validatedHeadSha(liveHead) {
|
|
15725
|
-
return "headSha" in liveHead ? liveHead.headSha : void 0;
|
|
15726
|
-
}
|
|
15727
|
-
function runPrMergeCheck(args, dependencies = {}) {
|
|
15728
|
-
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
15729
|
-
const profilePath = args.profilePath ? path.resolve(cwd, args.profilePath) : defaultProfilePath(cwd);
|
|
15730
|
-
const git = {
|
|
15731
|
-
checkoutRepository,
|
|
15732
|
-
commitsBetween,
|
|
15733
|
-
objectIdAtRef,
|
|
15734
|
-
showFileAtRef,
|
|
15735
|
-
worktreeListPorcelain: listWorktreesPorcelain,
|
|
15736
|
-
...dependencies.git
|
|
15737
|
-
};
|
|
15738
|
-
const readyProofPath = path.resolve(cwd, args.readyProof ?? ".factory-memory/pr-ready.json");
|
|
15739
|
-
const readyProofRead = tryReadProof((p) => readProofFor(PR_READY_PROOF_DESCRIPTOR, cwd, p), readyProofPath);
|
|
15740
|
-
const repository = git.checkoutRepository(cwd);
|
|
15741
|
-
const relativeProfilePath = checkoutRelativeProfilePath(cwd, profilePath);
|
|
15742
|
-
const github = dependencies.github ?? { fetchPullRequestHead };
|
|
15743
|
-
const fetchIssueBody = github.fetchIssueBody ?? defaultFetchIssueBody;
|
|
15744
|
-
const fetchClosingPullRequests = github.fetchClosingPullRequests ?? defaultFetchClosingPullRequests;
|
|
15745
|
-
const fetchReviews = github.fetchPullRequestReviews ?? ((input) => fetchPullRequestReviews(`${input.owner}/${input.repo}`, input.pr));
|
|
15746
|
-
const liveHeadResponse = github.fetchPullRequestHead({
|
|
15747
|
-
cwd,
|
|
15748
|
-
owner: repository.owner,
|
|
15749
|
-
pr: args.pr,
|
|
15750
|
-
repo: repository.name
|
|
15751
|
-
});
|
|
15752
|
-
const parsedLiveHead = pullRequestHeadOidSchema.safeParse(liveHeadResponse);
|
|
15753
|
-
const parsedBase = pullRequestBaseOidSchema.safeParse(liveHeadResponse);
|
|
15754
|
-
const liveHead = parsedLiveHead.success ? { headSha: parsedLiveHead.data.headRefOid } : { invalidResponse: JSON.stringify(liveHeadResponse) ?? "undefined" };
|
|
15755
|
-
const liveHeadSha = validatedHeadSha(liveHead);
|
|
15756
|
-
const parsedHeadRefName = pullRequestHeadRefNameSchema.safeParse(liveHeadResponse);
|
|
15757
|
-
const identity = resolveMergeGuardIdentity({
|
|
15758
|
-
commitsBetween: (fromSha, toSha) => git.commitsBetween(cwd, fromSha, toSha),
|
|
15759
|
-
liveHead,
|
|
15760
|
-
pr: args.pr,
|
|
15761
|
-
readyProof: readyProofRead.proof,
|
|
15762
|
-
readyProofError: readyProofRead.error,
|
|
15763
|
-
readyProofPath
|
|
15764
|
-
});
|
|
15765
|
-
const policyState = committedPolicyState({
|
|
15766
|
-
cwd,
|
|
15767
|
-
git,
|
|
15768
|
-
liveHeadResponse,
|
|
15769
|
-
profilePath
|
|
15770
|
-
});
|
|
15771
|
-
const freezeReasons = parsedBase.success ? sharedMergeFreezeBlockingReasons({
|
|
15772
|
-
cwd,
|
|
15773
|
-
headSha: parsedBase.data.baseRefOid,
|
|
15774
|
-
repository
|
|
15775
|
-
}, dependencies.mergeFreeze ?? githubMergeFreezeStore) : ["Authoritative GitHub merge freeze check-run state is unavailable or invalid: GitHub did not return a valid current base tip."];
|
|
15776
|
-
let boundary;
|
|
15777
|
-
const waveMembershipReasons = [];
|
|
15778
|
-
const rungDemandGroups = [];
|
|
15779
|
-
if (args.epic !== void 0) {
|
|
15780
|
-
const membership = resolveWaveDemand({
|
|
15781
|
-
epic: args.epic,
|
|
15782
|
-
fetchClosingPullRequests,
|
|
15783
|
-
fetchIssueBody,
|
|
15784
|
-
owner: repository.owner,
|
|
15785
|
-
pr: args.pr,
|
|
15786
|
-
repo: repository.name
|
|
15787
|
-
});
|
|
15788
|
-
waveMembershipReasons.push(...membership.reasons);
|
|
15789
|
-
if (membership.demand) {
|
|
15790
|
-
boundary = {
|
|
15791
|
-
...membership.demand,
|
|
15792
|
-
epic: args.epic
|
|
15793
|
-
};
|
|
15794
|
-
rungDemandGroups.push({
|
|
15795
|
-
demand: reviewRungDemand(boundary.review),
|
|
15796
|
-
reasons: demandedRungSatisfactionReasons({
|
|
15797
|
-
demandedRung: boundary.review,
|
|
15798
|
-
liveHumanReviews: boundary.review === "human" ? fetchReviews({
|
|
15799
|
-
owner: repository.owner,
|
|
15800
|
-
pr: args.pr,
|
|
15801
|
-
repo: repository.name
|
|
15802
|
-
}) : [],
|
|
15803
|
-
reviewRuns: readyProofRead.proof?.ledger.reviewRuns
|
|
15804
|
-
})
|
|
15805
|
-
});
|
|
15806
|
-
}
|
|
15807
|
-
}
|
|
15808
|
-
const candidateWaivers = selectWaiversForCandidate({
|
|
15809
|
-
headSha: liveHeadSha,
|
|
15810
|
-
pr: args.pr,
|
|
15811
|
-
waivers: readDemandWaivers(cwd)
|
|
15812
|
-
});
|
|
15813
|
-
const demandOutcomes = [
|
|
15814
|
-
{ reasons: mergeGuardBlockingReasons(identity) },
|
|
15815
|
-
{ reasons: proofBindingReasons({
|
|
15816
|
-
...policyState,
|
|
15817
|
-
profilePath: relativeProfilePath,
|
|
15818
|
-
readyProof: readyProofRead.proof,
|
|
15819
|
-
repository
|
|
15820
|
-
}) },
|
|
15821
|
-
{
|
|
15822
|
-
demand: DEMAND_KEYS.mergeFreeze,
|
|
15823
|
-
reasons: freezeReasons
|
|
15824
|
-
},
|
|
15825
|
-
...requiredCheckDemandGroups({
|
|
15826
|
-
...policyState,
|
|
15827
|
-
readyProof: readyProofRead.proof
|
|
15828
|
-
}),
|
|
15829
|
-
{ reasons: waveMembershipReasons },
|
|
15830
|
-
...rungDemandGroups
|
|
15831
|
-
].map((group) => group.demand === void 0 ? { blockingReasons: group.reasons } : applyDemandWaiver({
|
|
15832
|
-
demand: group.demand,
|
|
15833
|
-
reasons: group.reasons,
|
|
15834
|
-
waivers: candidateWaivers
|
|
15835
|
-
}));
|
|
15836
|
-
const blockingReasons = demandOutcomes.flatMap((outcome) => outcome.blockingReasons);
|
|
15837
|
-
const waivedDemands = demandOutcomes.flatMap((outcome) => outcome.waived ? [outcome.waived] : []);
|
|
15838
|
-
const worktreeListPorcelain = git.worktreeListPorcelain(cwd);
|
|
15839
|
-
const headTopology = resolveMergeOperationalNotices({
|
|
15840
|
-
headRefName: parsedHeadRefName.success ? parsedHeadRefName.data.headRefName : void 0,
|
|
15841
|
-
worktreeListPorcelain
|
|
15842
|
-
});
|
|
15843
|
-
const defaultTopology = resolveMergeOperationalNotices({
|
|
15844
|
-
headRefName: liveHeadResponse.baseRefName ?? policyState.committedProfile?.repository.defaultBranch,
|
|
15845
|
-
worktreeListPorcelain
|
|
15846
|
-
});
|
|
15847
|
-
const { worktreeHeldBranch } = headTopology;
|
|
15848
|
-
const worktreeHeldBranches = [...defaultTopology.worktreeHeldBranches ?? [], ...headTopology.worktreeHeldBranches ?? []].filter((held, index, all) => all.findIndex((candidate) => candidate.branch === held.branch) === index);
|
|
15849
|
-
const notices = [
|
|
15850
|
-
...worktreeHeldBranches.map(worktreeHeldBranchNotice),
|
|
15851
|
-
...waivedDemands.map(waivedDemandNotice),
|
|
15852
|
-
...unusedWaiverNotices({
|
|
15853
|
-
applied: waivedDemands,
|
|
15854
|
-
waivers: candidateWaivers
|
|
15855
|
-
})
|
|
15856
|
-
];
|
|
15857
|
-
let cleanupNotice = "Merge handoff uses GitHub's remote API; remote branch state is unverified and cleanup is deferred to closeout.";
|
|
15858
|
-
if (liveHeadResponse.mergedAt) cleanupNotice = `PR was already merged at ${liveHeadResponse.mergedAt}; no merge command is required. Local branch cleanup remains deferred to closeout.`;
|
|
15859
|
-
else if (liveHeadResponse.remoteHeadRefExists === false) cleanupNotice = "The remote PR branch is already deleted. The remote-only merge handoff leaves only local worktree and branch cleanup for closeout.";
|
|
15860
|
-
else if (liveHeadResponse.remoteHeadRefExists) cleanupNotice = "Merge handoff uses GitHub's remote API; remote and local branch deletion is deferred to closeout.";
|
|
15861
|
-
notices.push(cleanupNotice);
|
|
15862
|
-
if (args.epic === void 0) notices.push("No epic was supplied; merge-check permits a human handoff but emits no automated merge action.");
|
|
15863
|
-
else if (boundary && !boundary.autoMerge) notices.push(`Boundary wave ${boundary.wave} does not authorize auto-merge; merge-check permits a human handoff but emits no automated merge action.`);
|
|
15864
|
-
const status = blockingReasons.length === 0 ? "pass" : "fail";
|
|
15865
|
-
const followUp = liveHeadResponse.mergedAt || status === "pass" && !boundary?.autoMerge ? void 0 : mergeCheckFollowUp(status, args.pr, identity, relativeProfilePath, repository, liveHeadSha, parsedBase.success && freezeReasons.length > 0);
|
|
15866
|
-
const proof = {
|
|
15867
|
-
blockingReasons,
|
|
15868
|
-
...boundary ? { boundary: {
|
|
15869
|
-
autoMerge: boundary.autoMerge,
|
|
15870
|
-
epic: boundary.epic,
|
|
15871
|
-
review: boundary.review,
|
|
15872
|
-
wave: boundary.wave
|
|
15873
|
-
} } : {},
|
|
15874
|
-
command: "patronage-factory pr:merge-check",
|
|
15875
|
-
...followUp ? { followUp } : {},
|
|
15876
|
-
identity,
|
|
15877
|
-
...liveHeadSha ? { liveHeadSha } : {},
|
|
15878
|
-
...notices.length > 0 ? { notices } : {},
|
|
15879
|
-
pr: args.pr,
|
|
15880
|
-
schemaVersion: PR_MERGE_CHECK_SCHEMA_VERSION,
|
|
15881
|
-
status,
|
|
15882
|
-
...waivedDemands.length > 0 ? { waivedDemands } : {},
|
|
15883
|
-
...worktreeHeldBranch ? { worktreeHeldBranch } : {},
|
|
15884
|
-
...worktreeHeldBranches ? { worktreeHeldBranches } : {}
|
|
15885
|
-
};
|
|
15886
|
-
const output = path.resolve(cwd, args.output ?? ".factory-memory/pr-merge-check.json");
|
|
15887
|
-
writeProofJson(proof, output);
|
|
15888
|
-
console.log(`pr:merge-check proof written to ${output}`);
|
|
15889
|
-
for (const notice of notices) console.warn(`pr:merge-check notice: ${notice}`);
|
|
15890
|
-
if (args.json) console.log(JSON.stringify(proof, null, 2));
|
|
15891
|
-
if (proof.status !== "pass") throw new Error(`pr:merge-check blocked: ${blockingReasons.join(" ")}`);
|
|
15892
|
-
return proof;
|
|
15893
|
-
}
|
|
15894
|
-
//#endregion
|
|
15895
|
-
//#region src/commands/pr-merge-check.ts
|
|
15896
|
-
function createPrMergeCheckCommand(output, action = runPrMergeCheck) {
|
|
15897
|
-
return new Command("pr:merge-check").description("Revalidate the ready candidate and emit a remote-only, worktree-safe merge handoff").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest", positiveInteger("--epic")).option("--json", "print the merge-check proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--ready-proof <path>", "pr:ready proof JSON path").action(withGateTiming({
|
|
15898
|
-
gate: "pr:merge-check",
|
|
15899
|
-
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
15900
|
-
stderr: output.stderr
|
|
15901
|
-
}, (options) => {
|
|
15902
|
-
action({
|
|
15903
|
-
cwd: resolveCwdOption(options.cwd),
|
|
15904
|
-
epic: options.epic,
|
|
15905
|
-
json: Boolean(options.json),
|
|
15906
|
-
output: options.output,
|
|
15907
|
-
pr: options.pr,
|
|
15908
|
-
profilePath: options.profile,
|
|
15909
|
-
readyProof: options.readyProof
|
|
15910
|
-
});
|
|
15911
|
-
}));
|
|
15912
|
-
}
|
|
15913
|
-
//#endregion
|
|
15914
|
-
//#region src/pr-publish-transaction-error.ts
|
|
15915
|
-
/**
|
|
15916
|
-
* Publish's admission read is a bounded transaction (#348): undraft, await the
|
|
15917
|
-
* newest hosted run for this head, re-evaluate, and return the current
|
|
15918
|
-
* outcome. Neither abort path returns a verdict the command cannot currently
|
|
15919
|
-
* substantiate — an unsubstantiated verdict is this error, never a guess.
|
|
15920
|
-
*/
|
|
15921
|
-
var PrPublishTransactionAbortedError = class extends Error {
|
|
15922
|
-
constructor(message) {
|
|
15923
|
-
super(`pr:publish aborted: ${message}`);
|
|
15924
|
-
this.name = "PrPublishTransactionAbortedError";
|
|
15925
|
-
}
|
|
15926
|
-
};
|
|
15927
|
-
//#endregion
|
|
15928
|
-
//#region src/pr-readiness/pr-body-patch.ts
|
|
15929
|
-
const markerSlug = (heading) => heading.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-");
|
|
15930
|
-
const markerFor = (heading, edge) => `<!-- patronage-factory:${markerSlug(heading)}:${edge} -->`;
|
|
15931
|
-
const renderManagedPrBodyBlock = (heading, content) => `${markerFor(heading, "start")}\n${content.trim()}\n${markerFor(heading, "end")}`;
|
|
15932
|
-
const isLegacyFactoryOnlyContent = (heading, content) => {
|
|
15933
|
-
const value = content.trim();
|
|
15934
|
-
if (!value) return true;
|
|
15935
|
-
if (heading === "Verification") return value === "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`." || value.split("\n").filter(Boolean).every((line) => /^patronage-factory pr:verify(?: --(?:docs-only|trivial))? passed at head [0-9a-f]{40}$/u.test(line));
|
|
15936
|
-
if (heading === "Review proof") return value === "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`." || value.split("\n").filter(Boolean).every((line) => /^- (?:correctness|security): (?:passed with no actionable findings|[a-z-]+ with \d+ finding\(s\)) at patch-id [0-9a-f]{40}$/u.test(line));
|
|
15937
|
-
return heading === "How to review" && value === "Review the changed files and branded patronage-factory check runs.";
|
|
15938
|
-
};
|
|
15939
|
-
const patchExistingSection = (body, part) => {
|
|
15940
|
-
const bounds = findSectionBounds(body, part.heading);
|
|
15941
|
-
if (!bounds) return;
|
|
15942
|
-
const rawContent = body.slice(bounds.contentStart, bounds.contentEnd);
|
|
15943
|
-
const block = renderManagedPrBodyBlock(part.heading, part.content);
|
|
15944
|
-
const startMarker = markerFor(part.heading, "start");
|
|
15945
|
-
const endMarker = markerFor(part.heading, "end");
|
|
15946
|
-
const markerStart = rawContent.indexOf(startMarker);
|
|
15947
|
-
const markerEnd = rawContent.indexOf(endMarker);
|
|
15948
|
-
const startCount = rawContent.split(startMarker).length - 1;
|
|
15949
|
-
if (startCount !== rawContent.split(endMarker).length - 1 || startCount > 1 || startCount === 1 && markerEnd < markerStart) throw new Error(`Malformed patronage-factory managed markers in the ${part.heading} section.`);
|
|
15950
|
-
if (startCount === 1) {
|
|
15951
|
-
const absoluteStart = bounds.contentStart + markerStart;
|
|
15952
|
-
const absoluteEnd = bounds.contentStart + markerEnd + endMarker.length;
|
|
15953
|
-
return body.slice(0, absoluteStart) + block + body.slice(absoluteEnd);
|
|
15954
|
-
}
|
|
15955
|
-
if (isLegacyFactoryOnlyContent(part.heading, rawContent)) return `${body.slice(0, bounds.contentStart)}\n\n${block}\n\n${body.slice(bounds.contentEnd)}`;
|
|
15956
|
-
const separator = rawContent.endsWith("\n\n") ? "" : "\n\n";
|
|
15957
|
-
return `${body.slice(0, bounds.contentEnd)}${separator}${block}\n\n${body.slice(bounds.contentEnd)}`;
|
|
15958
|
-
};
|
|
15959
|
-
const patchPrBodySections = (body, parts) => {
|
|
15960
|
-
let result = body;
|
|
15961
|
-
const missing = [];
|
|
15962
|
-
for (const part of parts) {
|
|
15963
|
-
const patched = patchExistingSection(result, part);
|
|
15964
|
-
if (patched === void 0) missing.push(part);
|
|
15965
|
-
else result = patched;
|
|
15966
|
-
}
|
|
15967
|
-
for (const part of missing) {
|
|
15968
|
-
const separator = result.length === 0 || result.endsWith("\n\n") ? "" : "\n\n";
|
|
15969
|
-
result += `${separator}## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}\n`;
|
|
15970
|
-
}
|
|
15971
|
-
return result;
|
|
15972
|
-
};
|
|
15973
|
-
//#endregion
|
|
15974
|
-
//#region src/pr-readiness/pr-body-renderer.ts
|
|
15975
|
-
var pr_body_renderer_exports = /* @__PURE__ */ __exportAll({
|
|
15976
|
-
PR_BODY_MANAGED_SECTIONS: () => MANAGED_SECTION_NAMES,
|
|
15977
|
-
renderPrBodySectionParts: () => renderPrBodySectionParts,
|
|
15978
|
-
renderPrBodySections: () => renderPrBodySections
|
|
15979
|
-
});
|
|
15980
|
-
const commandFor = (proof) => {
|
|
15981
|
-
if (proof.mode === "docs-only") return "patronage-factory pr:verify --docs-only";
|
|
15982
|
-
if (proof.mode === "trivial") return "patronage-factory pr:verify --trivial";
|
|
15983
|
-
return "patronage-factory pr:verify";
|
|
15984
|
-
};
|
|
15985
|
-
const renderPrBodySectionParts = ({ reviewProof, verifyProof }) => {
|
|
15986
|
-
const verification = verifyProof ? [...verifyProof.mode === "docs-only" ? verifyProof.baselineFullProofs?.map((proof) => `patronage-factory pr:verify passed at head ${proof.headSha}`) ?? [] : [], `${commandFor(verifyProof)} passed at head ${verifyProof.headSha}`].join("\n") : "Run `patronage-factory pr:verify` and attach the typed proof to `pr:ready --verify-proof`.";
|
|
15987
|
-
const reviewRuns = reviewProof?.reviews.map((review) => {
|
|
15988
|
-
const patch = reviewProof.patchId;
|
|
15989
|
-
const result = review.outcome === "passed" ? "passed with no actionable findings" : `${review.outcome} with ${review.issuesFlagged} finding(s)`;
|
|
15990
|
-
return `- ${review.kind}: ${result} at patch-id ${patch}`;
|
|
15991
|
-
}).join("\n") ?? "Have a clean session write the typed findings file, then run `patronage-factory pr:review --mode all --findings <path> --output .factory-memory/pr-review.json` and attach the proof to `pr:ready --review-proof`.";
|
|
15992
|
-
return [
|
|
15993
|
-
{
|
|
15994
|
-
content: verification,
|
|
15995
|
-
heading: "Verification"
|
|
15996
|
-
},
|
|
15997
|
-
{
|
|
15998
|
-
content: reviewRuns,
|
|
15999
|
-
heading: "Review proof"
|
|
16000
|
-
},
|
|
16001
|
-
{
|
|
16002
|
-
content: "Review the changed files and branded patronage-factory check runs.",
|
|
16003
|
-
heading: "How to review"
|
|
16004
|
-
}
|
|
16005
|
-
];
|
|
16006
|
-
};
|
|
16007
|
-
const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySectionParts({
|
|
16008
|
-
reviewProof,
|
|
16009
|
-
verifyProof
|
|
16010
|
-
}).map((part) => `## ${part.heading}\n\n${renderManagedPrBodyBlock(part.heading, part.content)}`).join("\n\n")}\n`;
|
|
16011
|
-
//#endregion
|
|
16012
16037
|
//#region src/pr-review-gate-trace.ts
|
|
16013
16038
|
const eventTime = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
16014
16039
|
const buildReviewGateNotRequiredTraceEvent = ({ createdAt = eventTime(), identity, proof }) => ReviewGateTraceEventSchema.parse({
|
|
@@ -16429,6 +16454,7 @@ const summarizeHumanHandoff = (proof) => {
|
|
|
16429
16454
|
return {
|
|
16430
16455
|
humanActions: proof.status === "ready" ? [] : [...humanActions].toSorted((left, right) => left.localeCompare(right)),
|
|
16431
16456
|
routeOwnedRepairs,
|
|
16457
|
+
scheduled: proof.status === "ready" && (proof.arming?.outcome === "armed" || proof.arming?.outcome === "merged") && proof.followUp === void 0,
|
|
16432
16458
|
status: proof.status
|
|
16433
16459
|
};
|
|
16434
16460
|
};
|
|
@@ -16505,8 +16531,9 @@ const followUpSummaryLine = (outcome) => {
|
|
|
16505
16531
|
return "Follow-up: not requested.";
|
|
16506
16532
|
};
|
|
16507
16533
|
const verificationReuseSummaryLine = (reuse) => reuse.reused ? `Verification reused the existing pr:verify proof: ${reuse.reason}.` : `Verification ran pr:verify: ${reuse.reason}.`;
|
|
16534
|
+
const readySummaryLine = (handoff) => handoff.scheduled ? "pr:publish ready — the merge is on GitHub's schedule; no human actions owed." : "pr:publish admitted, not handed off — the candidate passed but nothing is scheduled to merge it. See the readiness proof's notices and follow-up.";
|
|
16508
16535
|
const renderPublishSummary = (result) => {
|
|
16509
|
-
const lines = result.handoff.status === "ready" ? [
|
|
16536
|
+
const lines = result.handoff.status === "ready" ? [readySummaryLine(result.handoff)] : [`pr:publish blocked (${result.handoff.status}). Human actions owed:`, ...result.handoff.humanActions.length === 0 ? ["- Route-owned repairs remain; see readiness proof repairs."] : result.handoff.humanActions.map((action) => `- ${action}`)];
|
|
16510
16537
|
if (result.verificationReuse) lines.push(verificationReuseSummaryLine(result.verificationReuse));
|
|
16511
16538
|
lines.push(followUpSummaryLine(result.followUp));
|
|
16512
16539
|
const binding = result.verifyProofBinding ? verifyProofBindingSummaryLine(result.verifyProofBinding) : void 0;
|
|
@@ -16528,7 +16555,14 @@ async function ensureVerifyProof({ args, cwd, dependencies, headSha, verifyProof
|
|
|
16528
16555
|
let declineReason = "no pr:verify proof was available to reuse";
|
|
16529
16556
|
if (typeof headSha === "string") {
|
|
16530
16557
|
const applicability = resolveVerifyProofApplicability({
|
|
16531
|
-
currentVerifyIdentityForBase: () =>
|
|
16558
|
+
currentVerifyIdentityForBase: () => {
|
|
16559
|
+
const selectedStablePatchId = dependencies.git?.stablePatchId ?? stablePatchId;
|
|
16560
|
+
return { patchId: resolveCandidatePatchId({
|
|
16561
|
+
base: args.base,
|
|
16562
|
+
cwd,
|
|
16563
|
+
stablePatchId: selectedStablePatchId
|
|
16564
|
+
}) };
|
|
16565
|
+
},
|
|
16532
16566
|
explicitProof: false,
|
|
16533
16567
|
files: (dependencies.git?.changedFiles ?? changedFiles)(cwd, args.base),
|
|
16534
16568
|
headSha,
|
|
@@ -16622,8 +16656,9 @@ async function ensureReviewProof({ args, cwd, dependencies, currentReviewIdentit
|
|
|
16622
16656
|
*
|
|
16623
16657
|
* Still best effort by construction: the sink never throws and never
|
|
16624
16658
|
* substitutes the commit-status mirror, so a failed proof mirror cannot fail
|
|
16625
|
-
* the gate. The sink is injected by the CLI
|
|
16626
|
-
* an absent sink means no republish and unit
|
|
16659
|
+
* the gate. The sink is injected by the CLI — it is the only check-run sink
|
|
16660
|
+
* publish injects now (#526) — so an absent sink means no republish and unit
|
|
16661
|
+
* fixtures never reach GitHub.
|
|
16627
16662
|
*/
|
|
16628
16663
|
async function republishVerifyProofCheckRun({ cwd, dependencies, pr, profile, verified }) {
|
|
16629
16664
|
const { proof, verifiedHeadSha } = verified;
|
|
@@ -16696,6 +16731,19 @@ const planPublishFollowUp = ({ binding, followUp, proofHeadSha, verifiedHeadSha
|
|
|
16696
16731
|
* pre-action verdict it holds no longer describes the candidate, full stop.
|
|
16697
16732
|
*/
|
|
16698
16733
|
const isUndraftFollowUp = (action) => action.argv[0] === "gh" && action.argv[1] === "pr" && action.argv[2] === "ready";
|
|
16734
|
+
/**
|
|
16735
|
+
* Whether the settled follow-up was readiness re-dispatching itself (#477):
|
|
16736
|
+
* the arming did not take effect, or the branded check's publication was not
|
|
16737
|
+
* confirmed. Self-invalidating for the same reason the undraft repair is —
|
|
16738
|
+
* publish just performed the action, so the pre-action proof no longer
|
|
16739
|
+
* describes what is scheduled.
|
|
16740
|
+
*/
|
|
16741
|
+
const isReadyRedispatch = (action) => action.argv[0] === "patronage-factory" && action.argv[1] === "pr:ready";
|
|
16742
|
+
/**
|
|
16743
|
+
* The follow-ups publish must not report a pre-action verdict for, because
|
|
16744
|
+
* publish itself performed the action they name.
|
|
16745
|
+
*/
|
|
16746
|
+
const isSelfInvalidatingFollowUp = (action) => isUndraftFollowUp(action) || isReadyRedispatch(action);
|
|
16699
16747
|
const PUBLISH_HOSTED_RUN_POLL_MS = 15e3;
|
|
16700
16748
|
/**
|
|
16701
16749
|
* Re-evaluate readiness for the same candidate (#348). Used both to capture
|
|
@@ -16763,10 +16811,17 @@ async function settleFollowUp({ action, cwd, factoryCliInvocation, onFailure, pl
|
|
|
16763
16811
|
status: "succeeded"
|
|
16764
16812
|
};
|
|
16765
16813
|
}
|
|
16766
|
-
const currentReviewIdentityFor = ({ args, cwd, dependencies, headSha, verifyProof }) =>
|
|
16767
|
-
|
|
16768
|
-
|
|
16769
|
-
|
|
16814
|
+
const currentReviewIdentityFor = ({ args, cwd, dependencies, headSha, verifyProof }) => {
|
|
16815
|
+
const selectedStablePatchId = dependencies.git?.stablePatchId ?? stablePatchId;
|
|
16816
|
+
return {
|
|
16817
|
+
headSha: headSha ?? verifyProof.headSha,
|
|
16818
|
+
patchId: resolveCandidatePatchId({
|
|
16819
|
+
base: args.base,
|
|
16820
|
+
cwd,
|
|
16821
|
+
stablePatchId: selectedStablePatchId
|
|
16822
|
+
})
|
|
16823
|
+
};
|
|
16824
|
+
};
|
|
16770
16825
|
async function runPrPublish(args, dependencies = {}) {
|
|
16771
16826
|
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
16772
16827
|
const factoryCliInvocation = prPublishCliInvocation(dependencies.factoryCliInvocation);
|
|
@@ -16887,7 +16942,7 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
16887
16942
|
}),
|
|
16888
16943
|
runFollowUp: dependencies.runFollowUp
|
|
16889
16944
|
});
|
|
16890
|
-
if (followUp.status === "succeeded" &&
|
|
16945
|
+
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(initialProof.followUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
16891
16946
|
}
|
|
16892
16947
|
const proof = await awaitCurrentReadyVerdict({
|
|
16893
16948
|
args: readyEvalArgs,
|
|
@@ -16977,7 +17032,7 @@ function createPrReadyCommand(output, action) {
|
|
|
16977
17032
|
reviewProof: options.reviewProof,
|
|
16978
17033
|
verifyProof: options.verifyProof
|
|
16979
17034
|
};
|
|
16980
|
-
await (action ? action(args) : runPrReady(args
|
|
17035
|
+
await (action ? action(args) : runPrReady(args));
|
|
16981
17036
|
}));
|
|
16982
17037
|
}
|
|
16983
17038
|
//#endregion
|
|
@@ -17112,8 +17167,8 @@ function checkWorktreeScratchFiles(worktreePath, listRootEntries = listWorktreeR
|
|
|
17112
17167
|
}
|
|
17113
17168
|
/**
|
|
17114
17169
|
* Human-readable one-line summary of a scratch-file check, owned beside the
|
|
17115
|
-
* detection logic
|
|
17116
|
-
*
|
|
17170
|
+
* detection logic so planner output, docs, and future surfaces cannot drift
|
|
17171
|
+
* apart.
|
|
17117
17172
|
*/
|
|
17118
17173
|
function formatScratchFileCheckSummary(check) {
|
|
17119
17174
|
if (check.status === "flagged") return `Scratch files: flagged — ${check.files.join(", ")} in ${check.worktreePath ?? "worktree"}. PR-body scratch files belong in /tmp; remove these before archiving the worktree.`;
|
|
@@ -17306,11 +17361,7 @@ function createProgram(options = {}) {
|
|
|
17306
17361
|
};
|
|
17307
17362
|
const prPublishAction = options.actions?.prPublish ?? ((args) => runPrPublish(args, {
|
|
17308
17363
|
ensureVerifyCheckRun: ensureFactoryCheckRunPublished,
|
|
17309
|
-
factoryCliInvocation
|
|
17310
|
-
publishCheckRun: createFactoryCheckPublisher({
|
|
17311
|
-
stderr: output.stderr,
|
|
17312
|
-
stdout: output.stderr
|
|
17313
|
-
})
|
|
17364
|
+
factoryCliInvocation
|
|
17314
17365
|
}));
|
|
17315
17366
|
const program = new Command();
|
|
17316
17367
|
program.name("patronage-factory").description("Shared Patronage software factory CLI").version(version);
|
|
@@ -17326,7 +17377,6 @@ function createProgram(options = {}) {
|
|
|
17326
17377
|
program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
|
|
17327
17378
|
program.addCommand(createPrReadyCommand(output, options.actions?.prReady));
|
|
17328
17379
|
program.addCommand(createPrPublishCommand(output, prPublishAction));
|
|
17329
|
-
program.addCommand(createPrMergeCheckCommand(output, options.actions?.prMergeCheck));
|
|
17330
17380
|
program.addCommand(createDemandWaiveCommand(output, options.actions?.demandWaive));
|
|
17331
17381
|
return program;
|
|
17332
17382
|
}
|
|
@@ -17350,4 +17400,4 @@ if (isDirectCliExecution()) try {
|
|
|
17350
17400
|
process.exit(1);
|
|
17351
17401
|
}
|
|
17352
17402
|
//#endregion
|
|
17353
|
-
export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPublishFollowUp,
|
|
17403
|
+
export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DemandWaiveRefusalError, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, REVIEW_FOCUS_SECTION, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, WorkerCheckoutGuardError, appendReviewLadderStageTraceEvent, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertWorkerCheckoutAllowed, authorizeDemandWaiver, blockingLadderFindings, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildRetroEnvelope, buildReviewGateNotRequiredTraceEvent, buildReviewLadderStageTraceEvent, comment_provenance_exports as commentProvenance, createLocalJsonlTraceSink, createProgram, doctorProjectProfile, epicStructureEventId, evaluateReviewLadder, evidenceEnvelopeFilename, findingKey, followUpFromArgv, inferFixedInThreadDispositions, isProductionHqUrl, isRetroEnvelopeWireComplete, loadProjectProfile, normalizeIssueComments, openLadderFindings, parseRetroEnvelope, planPublishFollowUp, pr_body_metadata_exports as prReadinessBodyMetadata, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, post_readiness_comments_exports as prReadinessPostComments, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, publishEpicStructure, readDemandWaivers, readPrReadyProof, readPrReviewProof, resolveFactoryRepository, resolveFindingBlocking, resolveReviewFindingCategory, resolveReviewFindingSeverity, resolveReviewLadderPolicy, resolveTraceWriteSinks, retroEnvelopeSchemaVersionOf, retroEnvelopeV1Schema, retroEpicReference, reviewCycleStateFor, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, staleRepeatLadderFindings, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, tryAppendReviewLadderStageTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrReviewProof, validatePrVerifyProof, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };
|