@mutmutco/cli 3.75.0 → 3.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.cjs +1040 -186
- package/package.json +1 -1
package/dist/main.cjs
CHANGED
|
@@ -3579,7 +3579,12 @@ var ERROR_CODES = {
|
|
|
3579
3579
|
/** Missing / rejected credentials on a path that needs auth. */
|
|
3580
3580
|
ERR_NO_AUTH: "ERR_NO_AUTH",
|
|
3581
3581
|
/** The operation partially succeeded (some units done, some failed). */
|
|
3582
|
-
ERR_PARTIAL: "ERR_PARTIAL"
|
|
3582
|
+
ERR_PARTIAL: "ERR_PARTIAL",
|
|
3583
|
+
/** The request was well-formed and every referent resolved, but the target's CURRENT STATE forbids the
|
|
3584
|
+
* mutation (HTTP 409 semantics) — e.g. reparenting an issue that already has a parent. Deliberately not
|
|
3585
|
+
* one code per API constraint: retry is futile until the named state is changed, and that is the fact a
|
|
3586
|
+
* caller has to act on, whichever rule produced it. */
|
|
3587
|
+
ERR_STATE_CONFLICT: "ERR_STATE_CONFLICT"
|
|
3583
3588
|
};
|
|
3584
3589
|
var ERROR_CODE_REFERENCE = [
|
|
3585
3590
|
{
|
|
@@ -3621,6 +3626,11 @@ var ERROR_CODE_REFERENCE = [
|
|
|
3621
3626
|
code: ERROR_CODES.ERR_PARTIAL,
|
|
3622
3627
|
meaning: "A batch operation completed some units and failed others.",
|
|
3623
3628
|
typical_fix: "Read the per-unit results, fix the failed inputs, and rerun only the failed units."
|
|
3629
|
+
},
|
|
3630
|
+
{
|
|
3631
|
+
code: ERROR_CODES.ERR_STATE_CONFLICT,
|
|
3632
|
+
meaning: "The request was valid and every referent resolved, but the target resource's current state forbids it.",
|
|
3633
|
+
typical_fix: "A plain retry fails identically. Read the state field the envelope names (e.g. `current_parent`), change that state deliberately with the command that owns it, then retry."
|
|
3624
3634
|
}
|
|
3625
3635
|
];
|
|
3626
3636
|
function buildErrorEnvelope(message, payload) {
|
|
@@ -3629,6 +3639,7 @@ function buildErrorEnvelope(message, payload) {
|
|
|
3629
3639
|
if (payload.expected !== void 0) env.expected = payload.expected;
|
|
3630
3640
|
if (payload.did_you_mean !== void 0) env.did_you_mean = payload.did_you_mean;
|
|
3631
3641
|
if (payload.corrected_command !== void 0) env.corrected_command = payload.corrected_command;
|
|
3642
|
+
if (payload.current_parent !== void 0) env.current_parent = payload.current_parent;
|
|
3632
3643
|
return env;
|
|
3633
3644
|
}
|
|
3634
3645
|
function formatErrorEnvelope(message, payload) {
|
|
@@ -4067,11 +4078,18 @@ function fail(msg, payload) {
|
|
|
4067
4078
|
}
|
|
4068
4079
|
hardExit(1);
|
|
4069
4080
|
}
|
|
4070
|
-
function
|
|
4081
|
+
async function failGracefulEnvelope(msg, payload) {
|
|
4082
|
+
if (payload && argvWantsMachineFailure()) {
|
|
4083
|
+
console.error(formatErrorEnvelope(msg, payload));
|
|
4084
|
+
return cleanExit(1);
|
|
4085
|
+
}
|
|
4086
|
+
return failGraceful(msg);
|
|
4087
|
+
}
|
|
4088
|
+
async function planMutation(opts, args, commandName, planFn) {
|
|
4071
4089
|
const validateOnly = opts.validateOnly === true;
|
|
4072
4090
|
const dryRun = opts.dryRun === true;
|
|
4073
4091
|
if (!validateOnly && !dryRun) return null;
|
|
4074
|
-
const planned = planFn ? planFn(opts, args) : { command: commandName, args };
|
|
4092
|
+
const planned = planFn ? await planFn(opts, args) : { command: commandName, args };
|
|
4075
4093
|
return validateOnly ? { ok: true, planned } : { dry_run: true, planned };
|
|
4076
4094
|
}
|
|
4077
4095
|
function commandPath(cmd) {
|
|
@@ -4097,11 +4115,11 @@ function mutating(cmd, planFn) {
|
|
|
4097
4115
|
cmd.option("--dry-run", "resolve + validate, print the planned action as JSON, and exit without writing");
|
|
4098
4116
|
cmd.option("--validate-only", "validate flags/enums/refs, print {ok,planned} or the C2 error envelope, and exit without writing");
|
|
4099
4117
|
const registerAction = cmd.action.bind(cmd);
|
|
4100
|
-
cmd.action = ((handler) => registerAction((...actionArgs) => {
|
|
4118
|
+
cmd.action = ((handler) => registerAction(async (...actionArgs) => {
|
|
4101
4119
|
const command = actionArgs[actionArgs.length - 1];
|
|
4102
4120
|
const opts = command.opts();
|
|
4103
4121
|
const positionals = actionArgs.slice(0, -2);
|
|
4104
|
-
const out = planMutation(opts, positionals, commandPath(command), planFn);
|
|
4122
|
+
const out = await planMutation(opts, positionals, commandPath(command), planFn);
|
|
4105
4123
|
if (out) {
|
|
4106
4124
|
console.log(JSON.stringify(out));
|
|
4107
4125
|
return;
|
|
@@ -4128,6 +4146,97 @@ function printLine(value) {
|
|
|
4128
4146
|
`);
|
|
4129
4147
|
}
|
|
4130
4148
|
|
|
4149
|
+
// src/issue-surface.ts
|
|
4150
|
+
var SURFACE_PREFIX = "surface:";
|
|
4151
|
+
var SURFACE_READ_TIMEOUT_MS = 1e4;
|
|
4152
|
+
function labelsCarrySurface(labels) {
|
|
4153
|
+
return (labels ?? []).some((l) => l.trim().toLowerCase().startsWith(SURFACE_PREFIX));
|
|
4154
|
+
}
|
|
4155
|
+
function surfaceLabel(value) {
|
|
4156
|
+
const v = value.trim();
|
|
4157
|
+
return v.toLowerCase().startsWith(SURFACE_PREFIX) ? v : `${SURFACE_PREFIX}${v}`;
|
|
4158
|
+
}
|
|
4159
|
+
async function readRepoSurfaceLabels(repo, deps = {}) {
|
|
4160
|
+
const run = deps.run ?? execFileP2;
|
|
4161
|
+
try {
|
|
4162
|
+
const { stdout } = await run(
|
|
4163
|
+
"gh",
|
|
4164
|
+
["label", "list", "--repo", repo, "--limit", "1000", "--json", "name"],
|
|
4165
|
+
{ timeout: SURFACE_READ_TIMEOUT_MS }
|
|
4166
|
+
);
|
|
4167
|
+
const parsed = JSON.parse(stdout);
|
|
4168
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
4169
|
+
return parsed.map((l) => l.name).filter((n) => typeof n === "string" && n.toLowerCase().startsWith(SURFACE_PREFIX));
|
|
4170
|
+
} catch {
|
|
4171
|
+
return void 0;
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
async function checkSurfaceRequirement(input, deps = {}) {
|
|
4175
|
+
if (input.waiver) {
|
|
4176
|
+
const reason = input.waiver.reason.trim();
|
|
4177
|
+
if (!reason) {
|
|
4178
|
+
return {
|
|
4179
|
+
enforcing: false,
|
|
4180
|
+
refusal: {
|
|
4181
|
+
message: `${input.command ?? "issue create"}: a surface waiver without a reason is not a ruling \u2014 declare WHY this filing is genuinely surface-less, or drop the waiver and let the preflight run`,
|
|
4182
|
+
payload: {
|
|
4183
|
+
code: ERROR_CODES.ERR_MISSING_FLAG,
|
|
4184
|
+
offending_flag: "waiver.reason",
|
|
4185
|
+
expected: ["a non-empty waiver reason"]
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
};
|
|
4189
|
+
}
|
|
4190
|
+
return {
|
|
4191
|
+
enforcing: false,
|
|
4192
|
+
waiver: { applied: true, reason }
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
const read = deps.read ?? readRepoSurfaceLabels;
|
|
4196
|
+
const known = await read(input.repo, { run: deps.run });
|
|
4197
|
+
if (known === void 0) {
|
|
4198
|
+
return {
|
|
4199
|
+
enforcing: false,
|
|
4200
|
+
warn: `warning: could not read ${input.repo}'s labels, so the surface-label requirement was not checked; if that board enforces one surface:* label per open issue, add one with \`mmi-cli issue edit <n> --add-label surface:<value>\``
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
if (known.length === 0) return { enforcing: false };
|
|
4204
|
+
if (labelsCarrySurface(input.labels)) return { enforcing: true };
|
|
4205
|
+
const command = input.command ?? "issue create";
|
|
4206
|
+
const where = input.rowLabel ? `${input.rowLabel}: ` : "";
|
|
4207
|
+
return {
|
|
4208
|
+
enforcing: true,
|
|
4209
|
+
refusal: {
|
|
4210
|
+
message: `${command}: ${where}${input.repo} requires every open issue to carry exactly one surface:* label, and this one sets none \u2014 filing it unlabeled reds the board check on every open PR in that repo. Pass --surface <value>, or --no-surface if this filing is genuinely exempt`,
|
|
4211
|
+
payload: {
|
|
4212
|
+
code: ERROR_CODES.ERR_MISSING_FLAG,
|
|
4213
|
+
offending_flag: "--surface",
|
|
4214
|
+
// Advisory, not a closed enum: the board rule is "exactly one surface:* label", whatever its value.
|
|
4215
|
+
expected: [...known].sort()
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
};
|
|
4219
|
+
}
|
|
4220
|
+
function conflictingSurfaceInputs(surfaceFlag, labels) {
|
|
4221
|
+
if (!surfaceFlag) return void 0;
|
|
4222
|
+
const wanted = surfaceLabel(surfaceFlag).toLowerCase();
|
|
4223
|
+
const fromLabels = (labels ?? []).map((l) => l.trim()).filter((l) => l.toLowerCase().startsWith(SURFACE_PREFIX));
|
|
4224
|
+
const disagreeing = fromLabels.filter((l) => l.toLowerCase() !== wanted);
|
|
4225
|
+
if (!disagreeing.length) return void 0;
|
|
4226
|
+
return {
|
|
4227
|
+
message: `issue create: --surface ${surfaceLabel(surfaceFlag)} contradicts --label ${disagreeing.join(", ")} \u2014 an issue carries exactly one surface, so name it once`,
|
|
4228
|
+
payload: {
|
|
4229
|
+
code: ERROR_CODES.ERR_CONFLICTING_FLAGS,
|
|
4230
|
+
offending_flag: "--surface",
|
|
4231
|
+
expected: [surfaceLabel(surfaceFlag), ...disagreeing]
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
async function surfaceLabelApplies(repo, deps = {}) {
|
|
4236
|
+
const known = await (deps.read ?? readRepoSurfaceLabels)(repo, { run: deps.run });
|
|
4237
|
+
return (known?.length ?? 0) > 0;
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4131
4240
|
// src/session-start.ts
|
|
4132
4241
|
var import_node_fs8 = require("node:fs");
|
|
4133
4242
|
var import_node_path6 = require("node:path");
|
|
@@ -6378,10 +6487,12 @@ async function primaryCheckoutRootOf(git2) {
|
|
|
6378
6487
|
return void 0;
|
|
6379
6488
|
}
|
|
6380
6489
|
}
|
|
6490
|
+
var SHA_LIKE_RE = /^[0-9a-f]{7,40}$/i;
|
|
6381
6491
|
function resolveWorktreeBase(from, remote) {
|
|
6382
6492
|
const remotePrefix = `${remote}/`;
|
|
6383
|
-
|
|
6384
|
-
return { base: from
|
|
6493
|
+
if (from.startsWith(remotePrefix)) return { base: from, fetchBranch: from.slice(remotePrefix.length) };
|
|
6494
|
+
if (SHA_LIKE_RE.test(from)) return { base: from };
|
|
6495
|
+
return { base: from, fetchBranch: from, preferRemote: `${remotePrefix}${from}` };
|
|
6385
6496
|
}
|
|
6386
6497
|
var GIT_CONFIG_LOCK_RE = /could not lock config file|unable to write upstream branch configuration/i;
|
|
6387
6498
|
function isGitConfigLockError(error) {
|
|
@@ -6491,7 +6602,7 @@ function commandLadderHint() {
|
|
|
6491
6602
|
}
|
|
6492
6603
|
|
|
6493
6604
|
// src/index.ts
|
|
6494
|
-
var
|
|
6605
|
+
var import_node_path32 = require("node:path");
|
|
6495
6606
|
|
|
6496
6607
|
// src/merge-ci-policy.ts
|
|
6497
6608
|
function resolveMergeCiPolicy(input) {
|
|
@@ -6776,22 +6887,80 @@ function patchRulesetRequiredContexts(body, contexts) {
|
|
|
6776
6887
|
function findProductRuleset(rulesets) {
|
|
6777
6888
|
return rulesets.find((r) => r.name === PRODUCT_RULESET_NAME);
|
|
6778
6889
|
}
|
|
6779
|
-
async function
|
|
6780
|
-
const
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6890
|
+
async function completedGateRuns(repo, client, query, perPage = 1, gateFiles = DEFAULT_GATE_FILES) {
|
|
6891
|
+
const merged = [];
|
|
6892
|
+
let lastError;
|
|
6893
|
+
let anyRead = false;
|
|
6894
|
+
for (const file of gateFiles) {
|
|
6895
|
+
try {
|
|
6896
|
+
const res = await client.rest(
|
|
6897
|
+
"GET",
|
|
6898
|
+
`repos/${repo}/actions/workflows/${encodeURIComponent(file)}/runs?status=completed&per_page=${perPage}${query}`,
|
|
6899
|
+
{ timeoutMs: 2e4 }
|
|
6900
|
+
);
|
|
6901
|
+
anyRead = true;
|
|
6902
|
+
merged.push(...res?.workflow_runs ?? []);
|
|
6903
|
+
} catch (e) {
|
|
6904
|
+
lastError = e;
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
6907
|
+
if (!anyRead && lastError) throw lastError;
|
|
6908
|
+
return merged.sort((a, b) => Date.parse(b.updated_at ?? "") - Date.parse(a.updated_at ?? ""));
|
|
6786
6909
|
}
|
|
6787
|
-
|
|
6910
|
+
var GATE_STREAK_PAGE = 30;
|
|
6911
|
+
var DEFAULT_GATE_FILES = ["gate.yml"];
|
|
6912
|
+
async function readDefaultBranchGate(repo, client, baseBranch, now = Date.now(), gateFiles = DEFAULT_GATE_FILES) {
|
|
6913
|
+
const named = gateFiles.length === 1 ? gateFiles[0] : `[${gateFiles.join(", ")}]`;
|
|
6914
|
+
let runs;
|
|
6915
|
+
let onBaseBranch = true;
|
|
6788
6916
|
try {
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6917
|
+
runs = await completedGateRuns(repo, client, `&branch=${encodeURIComponent(baseBranch)}`, GATE_STREAK_PAGE, gateFiles);
|
|
6918
|
+
if (!runs.length) {
|
|
6919
|
+
runs = await completedGateRuns(repo, client, "", GATE_STREAK_PAGE, gateFiles);
|
|
6920
|
+
onBaseBranch = false;
|
|
6921
|
+
}
|
|
6922
|
+
} catch (e) {
|
|
6923
|
+
return { state: "unknown", detail: `${named} run history unreadable \u2014 ${e.message}` };
|
|
6924
|
+
}
|
|
6925
|
+
const latest = runs[0];
|
|
6926
|
+
if (!latest) {
|
|
6927
|
+
return {
|
|
6928
|
+
state: "unknown",
|
|
6929
|
+
detail: `no completed ${named} run on ${baseBranch} or any branch \u2014 a freshly seeded gate and a gate that cannot pass look the same from here`
|
|
6930
|
+
};
|
|
6794
6931
|
}
|
|
6932
|
+
const where = onBaseBranch ? baseBranch : `${latest.head_branch ?? "any branch"} (no completed run on ${baseBranch})`;
|
|
6933
|
+
const common = {
|
|
6934
|
+
conclusion: latest.conclusion ?? void 0,
|
|
6935
|
+
branch: latest.head_branch ?? void 0,
|
|
6936
|
+
runUrl: latest.html_url ?? void 0
|
|
6937
|
+
};
|
|
6938
|
+
if (latest.conclusion === "success") {
|
|
6939
|
+
return { ...common, state: "green", detail: `latest completed ${named} run on ${where} succeeded at ${latest.updated_at ?? "unknown time"}` };
|
|
6940
|
+
}
|
|
6941
|
+
let since = latest;
|
|
6942
|
+
let bounded = runs.length >= GATE_STREAK_PAGE;
|
|
6943
|
+
for (const run of runs) {
|
|
6944
|
+
if (run.conclusion === "success") {
|
|
6945
|
+
bounded = false;
|
|
6946
|
+
break;
|
|
6947
|
+
}
|
|
6948
|
+
since = run;
|
|
6949
|
+
}
|
|
6950
|
+
const sinceAt = since.updated_at ?? latest.updated_at ?? void 0;
|
|
6951
|
+
const redDays = sinceAt ? Math.max(0, Math.floor((now - Date.parse(sinceAt)) / 864e5)) : void 0;
|
|
6952
|
+
const age = redDays == null ? "unknown duration" : `${redDays} day${redDays === 1 ? "" : "s"}${bounded ? " or longer" : ""}`;
|
|
6953
|
+
return {
|
|
6954
|
+
...common,
|
|
6955
|
+
state: "red",
|
|
6956
|
+
redSince: sinceAt,
|
|
6957
|
+
redDays,
|
|
6958
|
+
redSinceBounded: bounded,
|
|
6959
|
+
detail: `gate.yml is failing on ${where} \u2014 ${latest.conclusion ?? "not successful"} since ${sinceAt ?? "an unknown time"} (${age})`
|
|
6960
|
+
};
|
|
6961
|
+
}
|
|
6962
|
+
async function gateIsProvenGreen(repo, client, baseBranch, gateFiles = DEFAULT_GATE_FILES) {
|
|
6963
|
+
return (await readDefaultBranchGate(repo, client, baseBranch, Date.now(), gateFiles)).state === "green";
|
|
6795
6964
|
}
|
|
6796
6965
|
async function activateProductRuleset(repo, rulesetBody, client, enforcement = "active") {
|
|
6797
6966
|
const body = { ...rulesetBody, enforcement };
|
|
@@ -6919,6 +7088,16 @@ function collectPullRequestWorkflowContexts(workflows) {
|
|
|
6919
7088
|
}
|
|
6920
7089
|
return [...contexts].sort((a, b) => a.localeCompare(b));
|
|
6921
7090
|
}
|
|
7091
|
+
function pathFilteredPullRequestWorkflows(workflows) {
|
|
7092
|
+
const filtered = [];
|
|
7093
|
+
for (const wf of workflows) {
|
|
7094
|
+
if (!workflowTriggersPullRequest(wf.body)) continue;
|
|
7095
|
+
const onBlock = extractOnBlock(wf.body);
|
|
7096
|
+
if (!onBlock) continue;
|
|
7097
|
+
if (/^\s*paths(-ignore)?\s*:/m.test(onBlock)) filtered.push(wf.path);
|
|
7098
|
+
}
|
|
7099
|
+
return filtered.sort((a, b) => a.localeCompare(b));
|
|
7100
|
+
}
|
|
6922
7101
|
function contextsMatchRuleset(required, emitted) {
|
|
6923
7102
|
if (required.size !== emitted.size) return false;
|
|
6924
7103
|
for (const c of required) if (!emitted.has(c)) return false;
|
|
@@ -7732,29 +7911,32 @@ function decideSeedDelivery(branchRules) {
|
|
|
7732
7911
|
if (gating.length) return { mode: "pr", reason: `base branch is protected (${gating.join(", ")}) \u2014 seed via a branch + PR` };
|
|
7733
7912
|
return { mode: "direct", reason: "base branch is unprotected \u2014 direct PUT is safe" };
|
|
7734
7913
|
}
|
|
7735
|
-
function planSeedDelivery(branchRules, slug, baseBranch) {
|
|
7914
|
+
function planSeedDelivery(branchRules, slug, baseBranch, branchPrefix = "bootstrap-seed") {
|
|
7736
7915
|
const decision = decideSeedDelivery(branchRules);
|
|
7737
7916
|
if (decision.mode === "pr") {
|
|
7738
|
-
const branch =
|
|
7917
|
+
const branch = `${branchPrefix}-${slug}`;
|
|
7739
7918
|
return { mode: "pr", ref: branch, branch, reason: decision.reason };
|
|
7740
7919
|
}
|
|
7741
7920
|
return { mode: "direct", ref: baseBranch, reason: decision.reason };
|
|
7742
7921
|
}
|
|
7743
|
-
function
|
|
7744
|
-
const
|
|
7922
|
+
function contentPutBody(path2, content, branch, sha) {
|
|
7923
|
+
const body = {
|
|
7924
|
+
message: `bootstrap: seed ${path2}`,
|
|
7925
|
+
content: Buffer.from(content, "utf8").toString("base64"),
|
|
7926
|
+
branch
|
|
7927
|
+
};
|
|
7928
|
+
if (sha) body.sha = sha;
|
|
7929
|
+
return body;
|
|
7930
|
+
}
|
|
7931
|
+
function contentPutInputArgs(repo, path2, inputFile) {
|
|
7932
|
+
return [
|
|
7745
7933
|
"api",
|
|
7746
7934
|
"-X",
|
|
7747
7935
|
"PUT",
|
|
7748
7936
|
`repos/${repo}/contents/${path2.split("/").map(encodeURIComponent).join("/")}`,
|
|
7749
|
-
"
|
|
7750
|
-
|
|
7751
|
-
"-f",
|
|
7752
|
-
`content=${Buffer.from(content, "utf8").toString("base64")}`,
|
|
7753
|
-
"-f",
|
|
7754
|
-
`branch=${branch}`
|
|
7937
|
+
"--input",
|
|
7938
|
+
inputFile
|
|
7755
7939
|
];
|
|
7756
|
-
if (sha) args.push("-f", `sha=${sha}`);
|
|
7757
|
-
return args;
|
|
7758
7940
|
}
|
|
7759
7941
|
|
|
7760
7942
|
// src/ci-audit.ts
|
|
@@ -7764,6 +7946,9 @@ var PRODUCT_GATE_PATH = ".github/workflows/gate.yml";
|
|
|
7764
7946
|
var PRODUCT_RULESET_REF = ".github/rulesets/mmi-product-required-checks.json";
|
|
7765
7947
|
var GATE_TEMPLATE_SEED = "seed:gate.template.yml";
|
|
7766
7948
|
var RULESET_TEMPLATE_SEED = "seed:mmi-product-required-checks.template.json";
|
|
7949
|
+
var RULESET_REFERENCE_MATCH_LABEL = "committed ruleset reference matches the live ruleset (#3816)";
|
|
7950
|
+
var GATE_GREEN_ON_BASE_LABEL = "gate is green on the default branch (#3819)";
|
|
7951
|
+
var RECONCILE_SEED_BRANCH_PREFIX = "ci-reconcile-ruleset-ref";
|
|
7767
7952
|
function slugFromRepo(repo) {
|
|
7768
7953
|
return (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
|
|
7769
7954
|
}
|
|
@@ -7777,6 +7962,9 @@ function classifyRepo(repo, meta) {
|
|
|
7777
7962
|
function rulesetStatusChecks(rulesets) {
|
|
7778
7963
|
return new Set(rulesets.flatMap((ruleset) => (ruleset.rules || []).filter((rule) => rule.type === "required_status_checks").flatMap((rule) => rule.parameters?.required_status_checks || []).map((check) => check.context).filter((context) => Boolean(context))));
|
|
7779
7964
|
}
|
|
7965
|
+
function sortedUnique(values) {
|
|
7966
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
7967
|
+
}
|
|
7780
7968
|
async function restJson(deps, path2, fallback) {
|
|
7781
7969
|
try {
|
|
7782
7970
|
return await deps.client.rest("GET", path2) ?? fallback;
|
|
@@ -7820,6 +8008,11 @@ async function listWorkflowPaths(deps, repo, branch) {
|
|
|
7820
8008
|
return e?.status === 404 ? [] : void 0;
|
|
7821
8009
|
}
|
|
7822
8010
|
}
|
|
8011
|
+
var AGENT_PR_BOOKKEEPING_CONTEXTS = /* @__PURE__ */ new Set(["guard", "verdict"]);
|
|
8012
|
+
function gateWorkflowFiles(prWorkflowPaths) {
|
|
8013
|
+
const files = prWorkflowPaths.map((p) => p.slice(p.lastIndexOf("/") + 1)).filter((f) => f !== "agent-pr.yml");
|
|
8014
|
+
return files.length ? files : DEFAULT_GATE_FILES;
|
|
8015
|
+
}
|
|
7823
8016
|
async function resolveEmittedPrContexts(deps, repo, branch) {
|
|
7824
8017
|
const paths = await listWorkflowPaths(deps, repo, branch) ?? [];
|
|
7825
8018
|
const workflows = [];
|
|
@@ -7946,7 +8139,7 @@ async function auditRepoCi(repo, deps) {
|
|
|
7946
8139
|
return emittedPrContexts;
|
|
7947
8140
|
};
|
|
7948
8141
|
if (explicitNoCi) {
|
|
7949
|
-
const emitted = await getEmittedPrContexts();
|
|
8142
|
+
const emitted = (await getEmittedPrContexts()).filter((c) => !AGENT_PR_BOOKKEEPING_CONTEXTS.has(c));
|
|
7950
8143
|
if (emitted.length > 0) {
|
|
7951
8144
|
checks.push({
|
|
7952
8145
|
ok: false,
|
|
@@ -8001,6 +8194,34 @@ async function auditRepoCi(repo, deps) {
|
|
|
8001
8194
|
});
|
|
8002
8195
|
}
|
|
8003
8196
|
}
|
|
8197
|
+
const productRuleset = rulesets.find((r) => r.name === PRODUCT_RULESET_NAME);
|
|
8198
|
+
const committedReferenceRaw = await fetchFileContent(deps, repo, baseBranch, PRODUCT_RULESET_REF);
|
|
8199
|
+
if (productRuleset && committedReferenceRaw != null) {
|
|
8200
|
+
let committedContexts = null;
|
|
8201
|
+
try {
|
|
8202
|
+
committedContexts = rulesetRequiredContexts(stripRulesetComment(committedReferenceRaw));
|
|
8203
|
+
} catch {
|
|
8204
|
+
committedContexts = null;
|
|
8205
|
+
}
|
|
8206
|
+
const live = sortedUnique(rulesetRequiredContexts(productRuleset));
|
|
8207
|
+
const declared = committedContexts == null ? null : sortedUnique(committedContexts);
|
|
8208
|
+
const aligned = declared != null && declared.length === live.length && declared.every((c, i) => c === live[i]);
|
|
8209
|
+
checks.push({
|
|
8210
|
+
ok: aligned,
|
|
8211
|
+
label: RULESET_REFERENCE_MATCH_LABEL,
|
|
8212
|
+
detail: aligned ? `[${live.join(", ")}] on both sides` : declared == null ? `${PRODUCT_RULESET_REF} on ${baseBranch} is not parseable JSON \u2014 it cannot be compared to the live ruleset` : `${PRODUCT_RULESET_REF} declares [${declared.join(", ")}] but the live ${PRODUCT_RULESET_NAME} ruleset requires [${live.join(", ")}]`,
|
|
8213
|
+
remediation: aligned ? void 0 : `mmi-cli ci reconcile --repo ${repo} --apply`
|
|
8214
|
+
});
|
|
8215
|
+
}
|
|
8216
|
+
}
|
|
8217
|
+
if (deployableGated || repoClass === "hub") {
|
|
8218
|
+
const gate = await readDefaultBranchGate(repo, deps.client, baseBranch, Date.now(), gateWorkflowFiles(prWorkflowPaths));
|
|
8219
|
+
checks.push({
|
|
8220
|
+
ok: gate.state !== "red",
|
|
8221
|
+
label: GATE_GREEN_ON_BASE_LABEL,
|
|
8222
|
+
detail: gate.state === "green" ? void 0 : gate.detail,
|
|
8223
|
+
remediation: gate.state === "red" ? `Fix the gate in ${repo} \u2014 it blocks every PR there, and ${PRODUCT_RULESET_NAME} activation is held back by the #3694 guard until it is green${gate.runUrl ? ` (${gate.runUrl})` : ""}` : void 0
|
|
8224
|
+
});
|
|
8004
8225
|
}
|
|
8005
8226
|
const workflowPaths = repoClass === "deployable" ? prWorkflowPaths : [];
|
|
8006
8227
|
const { policy, reason } = resolveMergeCiPolicy({
|
|
@@ -8022,16 +8243,21 @@ async function auditRepoCi(repo, deps) {
|
|
|
8022
8243
|
detail: `${policy} (${reason})`
|
|
8023
8244
|
});
|
|
8024
8245
|
}
|
|
8246
|
+
const NO_CANCEL_WAIVER = /ci-audit:\s*allow-no-cancel-in-progress\b[^\n]*#\d+/i;
|
|
8025
8247
|
if (deployableGated || repoClass === "hub") {
|
|
8026
8248
|
const allPaths = await listWorkflowPaths(deps, repo, baseBranch) ?? [];
|
|
8027
8249
|
const prPaths = await filterPullRequestTriggered(deps, repo, baseBranch, allPaths);
|
|
8028
8250
|
const missing = [];
|
|
8251
|
+
const waived = [];
|
|
8029
8252
|
const gateBodies = [];
|
|
8030
8253
|
for (const path2 of prPaths) {
|
|
8031
8254
|
const body = await fetchFileContent(deps, repo, baseBranch, path2);
|
|
8032
8255
|
if (body == null) continue;
|
|
8033
8256
|
if (isGateWorkflowPath(path2)) gateBodies.push({ path: path2, body });
|
|
8034
|
-
if (!/^\s*concurrency:/m.test(body) || !/cancel-in-progress:\s*true/.test(body))
|
|
8257
|
+
if (!/^\s*concurrency:/m.test(body) || !/cancel-in-progress:\s*true/.test(body)) {
|
|
8258
|
+
if (NO_CANCEL_WAIVER.test(body)) waived.push(path2);
|
|
8259
|
+
else missing.push(path2);
|
|
8260
|
+
}
|
|
8035
8261
|
}
|
|
8036
8262
|
if (gateBodies.length > 0) {
|
|
8037
8263
|
const budget = checkGateBudget(gateBodies, { hubLocalAllowed: repoClass === "hub" });
|
|
@@ -8043,10 +8269,14 @@ async function auditRepoCi(repo, deps) {
|
|
|
8043
8269
|
});
|
|
8044
8270
|
}
|
|
8045
8271
|
if (prPaths.length > 0) {
|
|
8272
|
+
const waivedNote = waived.length ? `${waived.length} waived by declared marker: ${waived.join(", ")}` : void 0;
|
|
8046
8273
|
checks.push({
|
|
8047
8274
|
ok: missing.length === 0,
|
|
8048
8275
|
label: "PR workflows cancel superseded runs (#3001)",
|
|
8049
|
-
detail:
|
|
8276
|
+
detail: [
|
|
8277
|
+
missing.length ? `missing per-PR concurrency cancel-in-progress: ${missing.join(", ")}` : void 0,
|
|
8278
|
+
waivedNote
|
|
8279
|
+
].filter(Boolean).join("; ") || void 0,
|
|
8050
8280
|
remediation: missing.length ? `Add the per-PR concurrency block from skills/bootstrap/seeds/gate.template.yml (group: <workflow>-PR-or-ref, cancel-in-progress: true) to: ${missing.join(", ")}` : void 0
|
|
8051
8281
|
});
|
|
8052
8282
|
}
|
|
@@ -8070,24 +8300,38 @@ async function auditRepoCi(repo, deps) {
|
|
|
8070
8300
|
const ok = checks.every((c) => c.ok);
|
|
8071
8301
|
return { repo, class: repoClass, mergePolicy: policy, ok, checks, explicitNoCi };
|
|
8072
8302
|
}
|
|
8303
|
+
var REGISTRY_UNREADABLE_DETAIL = `the registry roster read returned nothing \u2014 the fleet was NOT audited. 0 repos read from the registry; the number left unaudited is unknown because the roster is what names them. This is "cannot audit the fleet", never "the fleet is green". Under GitHub Actions the CLI needs a CI Hub session (MMI_HUB_TOKEN, minted from the job's OIDC claim via POST /auth/session-ci) \u2014 an App installation token has no /user identity and can never pass /auth/session (#3215).`;
|
|
8073
8304
|
async function auditOrgCi(deps, repoFilter) {
|
|
8305
|
+
if (repoFilter) {
|
|
8306
|
+
const report = await auditRepoCi(repoFilter, deps);
|
|
8307
|
+
return { ok: report.ok, scope: "single-repo", repos: [report] };
|
|
8308
|
+
}
|
|
8074
8309
|
const projects = await deps.listProjects();
|
|
8075
|
-
if (!projects) {
|
|
8076
|
-
|
|
8077
|
-
const report = await auditRepoCi(single, deps);
|
|
8078
|
-
return { ok: report.ok, repos: [report] };
|
|
8310
|
+
if (!projects || projects.length === 0) {
|
|
8311
|
+
return { ok: false, scope: "registry-unreadable", repos: [], scopeDetail: REGISTRY_UNREADABLE_DETAIL };
|
|
8079
8312
|
}
|
|
8080
|
-
const targets =
|
|
8313
|
+
const targets = collectRegistryRepos(projects);
|
|
8081
8314
|
const repos = [];
|
|
8082
8315
|
for (const repo of targets) {
|
|
8083
8316
|
repos.push(await auditRepoCi(repo, deps));
|
|
8084
8317
|
}
|
|
8085
|
-
return { ok: repos.every((r) => r.ok), repos };
|
|
8318
|
+
return { ok: repos.every((r) => r.ok), scope: "fleet", repos };
|
|
8319
|
+
}
|
|
8320
|
+
function renderCiAuditScopeLine(report) {
|
|
8321
|
+
if (report.scope === "registry-unreadable") {
|
|
8322
|
+
return `scope: REGISTRY UNREADABLE \u2014 repos audited: 0 \u2014 ${report.scopeDetail ?? REGISTRY_UNREADABLE_DETAIL}`;
|
|
8323
|
+
}
|
|
8324
|
+
if (report.scope === "single-repo") {
|
|
8325
|
+
return `scope: single-repo (explicit --repo) \u2014 repos audited: ${report.repos.length} \u2014 this is NOT a fleet verdict`;
|
|
8326
|
+
}
|
|
8327
|
+
return `scope: fleet (registry roster) \u2014 repos audited: ${report.repos.length}`;
|
|
8086
8328
|
}
|
|
8087
8329
|
function renderCiAuditMarkdown(report) {
|
|
8088
8330
|
const lines = [
|
|
8089
8331
|
`# CI merge-readiness audit`,
|
|
8090
8332
|
"",
|
|
8333
|
+
renderCiAuditScopeLine(report),
|
|
8334
|
+
"",
|
|
8091
8335
|
`Fleet: ${report.ok ? "OK" : "GAPS"} (${report.repos.filter((r) => r.ok).length}/${report.repos.length} repos ready)`,
|
|
8092
8336
|
"",
|
|
8093
8337
|
"| Repo | Class | Policy | OK | Top gap |",
|
|
@@ -8100,7 +8344,10 @@ function renderCiAuditMarkdown(report) {
|
|
|
8100
8344
|
return lines.join("\n");
|
|
8101
8345
|
}
|
|
8102
8346
|
function renderCiAuditText(report) {
|
|
8103
|
-
const lines = [
|
|
8347
|
+
const lines = [
|
|
8348
|
+
`mmi-cli ci audit: ${report.ok ? "OK" : "GAPS"} (${report.repos.length} repos)`,
|
|
8349
|
+
renderCiAuditScopeLine(report)
|
|
8350
|
+
];
|
|
8104
8351
|
for (const r of report.repos) {
|
|
8105
8352
|
lines.push(`
|
|
8106
8353
|
${r.repo} (${r.class}, policy=${r.mergePolicy}) ${r.ok ? "OK" : "GAP"}`);
|
|
@@ -8148,6 +8395,17 @@ async function fetchRulesetSeedBody(deps, repo) {
|
|
|
8148
8395
|
return null;
|
|
8149
8396
|
}
|
|
8150
8397
|
}
|
|
8398
|
+
async function readLiveProductContexts(deps, repo) {
|
|
8399
|
+
try {
|
|
8400
|
+
const list = await deps.client.rest("GET", `repos/${repo}/rulesets`, { timeoutMs: 2e4 });
|
|
8401
|
+
const existing = findProductRuleset(list ?? []);
|
|
8402
|
+
if (existing?.id == null) return null;
|
|
8403
|
+
const detail = await deps.client.rest("GET", `repos/${repo}/rulesets/${existing.id}`, { timeoutMs: 2e4 });
|
|
8404
|
+
return sortedUnique(rulesetRequiredContexts(detail));
|
|
8405
|
+
} catch {
|
|
8406
|
+
return null;
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8151
8409
|
async function contentSha(deps, repo, branch, path2) {
|
|
8152
8410
|
try {
|
|
8153
8411
|
const encodedPath = path2.split("/").map(encodeURIComponent).join("/");
|
|
@@ -8173,48 +8431,101 @@ async function putSeedFile(deps, repo, path2, content, branch) {
|
|
|
8173
8431
|
if (sha) body.sha = sha;
|
|
8174
8432
|
await deps.client.rest("PUT", `repos/${repo}/contents/${encodedPath}`, { body });
|
|
8175
8433
|
}
|
|
8434
|
+
async function deliverSeedFile(deps, repo, path2, content, baseBranch) {
|
|
8435
|
+
let branchRules = [];
|
|
8436
|
+
try {
|
|
8437
|
+
branchRules = await deps.client.rest("GET", `repos/${repo}/rules/branches/${encodeURIComponent(baseBranch)}`);
|
|
8438
|
+
} catch {
|
|
8439
|
+
branchRules = [];
|
|
8440
|
+
}
|
|
8441
|
+
const plan = planSeedDelivery(branchRules, slugFromRepo(repo), baseBranch, RECONCILE_SEED_BRANCH_PREFIX);
|
|
8442
|
+
if (plan.mode === "direct" || !plan.branch) {
|
|
8443
|
+
await putSeedFile(deps, repo, path2, content, baseBranch);
|
|
8444
|
+
return { plan };
|
|
8445
|
+
}
|
|
8446
|
+
const head = await deps.client.rest("GET", `repos/${repo}/git/ref/heads/${encodeURIComponent(baseBranch)}`);
|
|
8447
|
+
const sha = head?.object?.sha;
|
|
8448
|
+
if (!sha) throw new Error(`cannot resolve ${baseBranch} head on ${repo} \u2014 seed branch not cut`);
|
|
8449
|
+
try {
|
|
8450
|
+
await deps.client.rest("POST", `repos/${repo}/git/refs`, { body: { ref: `refs/heads/${plan.branch}`, sha } });
|
|
8451
|
+
} catch (e) {
|
|
8452
|
+
if (!/Reference already exists|already exists/i.test(String(e.message ?? ""))) throw e;
|
|
8453
|
+
}
|
|
8454
|
+
await putSeedFile(deps, repo, path2, content, plan.branch);
|
|
8455
|
+
const owner = repo.includes("/") ? repo.split("/")[0] : "mutmutco";
|
|
8456
|
+
const open2 = await deps.client.rest(
|
|
8457
|
+
"GET",
|
|
8458
|
+
`repos/${repo}/pulls?state=open&base=${encodeURIComponent(baseBranch)}&head=${encodeURIComponent(`${owner}:${plan.branch}`)}`
|
|
8459
|
+
).catch(() => []);
|
|
8460
|
+
const decision = decideSeedPrAction((open2 ?? []).map((pr2) => ({ url: pr2?.html_url })));
|
|
8461
|
+
if (decision.action === "reuse") return { plan, prUrl: decision.url };
|
|
8462
|
+
const created = await deps.client.rest("POST", `repos/${repo}/pulls`, {
|
|
8463
|
+
body: {
|
|
8464
|
+
title: `chore: reconcile ${path2} with the live ${PRODUCT_RULESET_NAME} ruleset (#3816)`,
|
|
8465
|
+
head: plan.branch,
|
|
8466
|
+
base: baseBranch,
|
|
8467
|
+
body: `\`mmi-cli ci reconcile --apply\` brings this repo's committed ruleset reference into agreement with its LIVE \`${PRODUCT_RULESET_NAME}\` ruleset.
|
|
8468
|
+
|
|
8469
|
+
The base branch is protected, so the reference cannot be written directly \u2014 the same branch+PR seed delivery \`bootstrap apply\` uses (#2286.1). Refs mutmutco/MMI-Hub#3816.
|
|
8470
|
+
`
|
|
8471
|
+
}
|
|
8472
|
+
});
|
|
8473
|
+
return { plan, prUrl: created?.html_url };
|
|
8474
|
+
}
|
|
8176
8475
|
async function seedGateYml(repo, deps, meta, result) {
|
|
8177
8476
|
const baseBranch = "development";
|
|
8178
8477
|
const releaseTrack = isReleaseTrack(meta?.releaseTrack) ? meta?.releaseTrack : void 0;
|
|
8179
8478
|
const parsed = parseOwnerRepo(repo);
|
|
8479
|
+
const refOnlyVars = () => withDerivedRepoVars({ REPO_SLUG: parsed.slug }, parsed, "deployable", releaseTrack);
|
|
8180
8480
|
if (await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH)) {
|
|
8181
|
-
await seedRulesetRefIfMissing(repo, deps,
|
|
8182
|
-
return;
|
|
8481
|
+
return await seedRulesetRefIfMissing(repo, deps, refOnlyVars(), baseBranch, result);
|
|
8183
8482
|
}
|
|
8184
8483
|
if (!deps.readSeedFile && !deps.renderSeed) {
|
|
8185
8484
|
result.skipped.push(`gate.yml missing but no seed-template source wired \u2014 run bootstrap apply`);
|
|
8186
|
-
return;
|
|
8485
|
+
return "none";
|
|
8486
|
+
}
|
|
8487
|
+
const prTriggered = await prTriggeredWorkflowsOnRef(deps, repo, baseBranch, "deployable");
|
|
8488
|
+
if (prTriggered === void 0) {
|
|
8489
|
+
result.skipped.push("gate.yml missing and the workflows listing is unreadable \u2014 never seeding a gate on UNKNOWN");
|
|
8490
|
+
return "none";
|
|
8491
|
+
}
|
|
8492
|
+
if (prTriggered.length > 0) {
|
|
8493
|
+
return await seedRulesetRefIfMissing(repo, deps, refOnlyVars(), baseBranch, result);
|
|
8187
8494
|
}
|
|
8188
8495
|
const gate = meta?.gate;
|
|
8189
8496
|
if (!gate || typeof gate === "object" && Object.keys(gate).length === 0) {
|
|
8190
8497
|
result.skipped.push(`gate.yml missing \u2014 no registry gate config; set \`mmi-cli org project set ${repo} --var gate={...}\` first, then re-run reconcile`);
|
|
8191
|
-
return;
|
|
8498
|
+
return "none";
|
|
8192
8499
|
}
|
|
8193
8500
|
const derivedVars = withDerivedRepoVars({ ...gateConfigToVars(gate), REPO_SLUG: parsed.slug }, parsed, "deployable", releaseTrack);
|
|
8194
8501
|
const rendered = renderSeedBody(deps, GATE_TEMPLATE_SEED, PRODUCT_GATE_PATH, derivedVars);
|
|
8195
8502
|
if (rendered == null) {
|
|
8196
8503
|
result.errors.push(`gate.yml re-seed: could not render ${GATE_TEMPLATE_SEED} (template unreadable or unfilled)`);
|
|
8197
|
-
return;
|
|
8504
|
+
return "none";
|
|
8198
8505
|
}
|
|
8199
8506
|
try {
|
|
8200
8507
|
await putSeedFile(deps, repo, PRODUCT_GATE_PATH, rendered, baseBranch);
|
|
8201
8508
|
result.applied.push(`seeded ${PRODUCT_GATE_PATH}`);
|
|
8202
8509
|
} catch (e) {
|
|
8203
8510
|
result.errors.push(`gate.yml re-seed failed: ${e.message}`);
|
|
8204
|
-
return;
|
|
8511
|
+
return "none";
|
|
8205
8512
|
}
|
|
8206
|
-
await seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, result);
|
|
8513
|
+
return await seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, result);
|
|
8207
8514
|
}
|
|
8208
8515
|
async function seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, result) {
|
|
8209
|
-
if (await contentExists(deps, repo, baseBranch, PRODUCT_RULESET_REF)) return;
|
|
8210
|
-
if (!deps.readSeedFile && !deps.renderSeed) return;
|
|
8516
|
+
if (await contentExists(deps, repo, baseBranch, PRODUCT_RULESET_REF)) return "committed";
|
|
8517
|
+
if (!deps.readSeedFile && !deps.renderSeed) return "none";
|
|
8211
8518
|
const rulesetBody = renderSeedBody(deps, RULESET_TEMPLATE_SEED, PRODUCT_RULESET_REF, derivedVars);
|
|
8212
|
-
if (rulesetBody == null) return;
|
|
8519
|
+
if (rulesetBody == null) return "none";
|
|
8213
8520
|
try {
|
|
8214
|
-
await
|
|
8215
|
-
result.applied.push(
|
|
8521
|
+
const { plan, prUrl } = await deliverSeedFile(deps, repo, PRODUCT_RULESET_REF, rulesetBody, baseBranch);
|
|
8522
|
+
result.applied.push(
|
|
8523
|
+
`seeded ${PRODUCT_RULESET_REF}` + (plan.mode === "direct" ? ` (committed to ${baseBranch})` : ` (${plan.reason}${prUrl ? ` \u2014 ${prUrl}` : ""})`)
|
|
8524
|
+
);
|
|
8525
|
+
return plan.mode === "direct" ? "committed" : "pending";
|
|
8216
8526
|
} catch (e) {
|
|
8217
8527
|
result.errors.push(`ruleset reference seed failed: ${e.message}`);
|
|
8528
|
+
return "none";
|
|
8218
8529
|
}
|
|
8219
8530
|
}
|
|
8220
8531
|
async function parkProductRuleset(repo, deps) {
|
|
@@ -8248,19 +8559,47 @@ async function applyCiReconcileRepo(repo, deps) {
|
|
|
8248
8559
|
const meta = await deps.getProjectMeta(slugFromRepo(repo));
|
|
8249
8560
|
const report = await auditRepoCi(repo, deps);
|
|
8250
8561
|
if (report.class !== "deployable" || report.explicitNoCi) return merge;
|
|
8251
|
-
await seedGateYml(repo, deps, meta, merge);
|
|
8562
|
+
const refDelivery = await seedGateYml(repo, deps, meta, merge);
|
|
8563
|
+
const baseBranch = "development";
|
|
8252
8564
|
const driftCheck = report.checks.find((c) => c.label === "required check contexts match PR workflows");
|
|
8253
8565
|
const requiredCheck = report.checks.find((c) => c.label === "product required status checks active");
|
|
8254
|
-
|
|
8255
|
-
|
|
8566
|
+
const referenceCheck = report.checks.find((c) => c.label === RULESET_REFERENCE_MATCH_LABEL);
|
|
8567
|
+
if (requiredCheck?.ok && (driftCheck?.ok ?? true) && (referenceCheck?.ok ?? true)) {
|
|
8568
|
+
merge.skipped.push("product ruleset already active, aligned, and matching its committed reference");
|
|
8256
8569
|
return merge;
|
|
8257
8570
|
}
|
|
8258
8571
|
const raw = await fetchRulesetSeedBody(deps, repo);
|
|
8259
8572
|
if (!raw) {
|
|
8260
|
-
|
|
8573
|
+
if (refDelivery === "pending") {
|
|
8574
|
+
merge.skipped.push(
|
|
8575
|
+
`${PRODUCT_RULESET_REF} was delivered on a seed branch this run \u2014 the base branch is protected, so it lands when that PR merges; re-run the reconcile then to activate the ruleset`
|
|
8576
|
+
);
|
|
8577
|
+
} else {
|
|
8578
|
+
merge.errors.push(`missing ${PRODUCT_RULESET_REF} on development \u2014 run bootstrap apply first`);
|
|
8579
|
+
}
|
|
8261
8580
|
return merge;
|
|
8262
8581
|
}
|
|
8263
|
-
|
|
8582
|
+
const activationNeeded = !requiredCheck?.ok || !(driftCheck?.ok ?? true);
|
|
8583
|
+
const prWorkflows = await prTriggeredWorkflowsOnRef(deps, repo, baseBranch, "deployable") ?? [];
|
|
8584
|
+
const gateFiles = gateWorkflowFiles(prWorkflows);
|
|
8585
|
+
const allPrWorkflows = await listWorkflowPaths(deps, repo, baseBranch) ?? [];
|
|
8586
|
+
if (activationNeeded) {
|
|
8587
|
+
const bodies = [];
|
|
8588
|
+
for (const path2 of allPrWorkflows) {
|
|
8589
|
+
const body = await fetchFileContent(deps, repo, baseBranch, path2);
|
|
8590
|
+
if (body) bodies.push({ path: path2, body });
|
|
8591
|
+
}
|
|
8592
|
+
const filteredPaths = new Set(pathFilteredPullRequestWorkflows(bodies).filter((p) => !p.endsWith("/agent-pr.yml")));
|
|
8593
|
+
const safeContexts = new Set(collectPullRequestWorkflowContexts(bodies.filter((b) => !filteredPaths.has(b.path))));
|
|
8594
|
+
const unsafe = collectPullRequestWorkflowContexts(bodies.filter((b) => filteredPaths.has(b.path))).filter((c) => !safeContexts.has(c));
|
|
8595
|
+
if (unsafe.length) {
|
|
8596
|
+
merge.skipped.push(
|
|
8597
|
+
`product ruleset left non-enforcing \u2014 [${unsafe.join(", ")}] ${unsafe.length === 1 ? "is" : "are"} emitted ONLY by path-filtered workflow(s) (${[...filteredPaths].join(", ")}), so the context is not reported on a PR outside those paths and requiring it would block that PR forever (#3836). Give the gate a companion job that runs unconditionally, then re-run this command`
|
|
8598
|
+
);
|
|
8599
|
+
return merge;
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8602
|
+
if (activationNeeded && !await gateIsProvenGreen(repo, deps.client, baseBranch, gateFiles)) {
|
|
8264
8603
|
merge.skipped.push(
|
|
8265
8604
|
"product ruleset left non-enforcing \u2014 the gate is not green on development; activating it would block every PR (#3694)"
|
|
8266
8605
|
);
|
|
@@ -8268,29 +8607,55 @@ async function applyCiReconcileRepo(repo, deps) {
|
|
|
8268
8607
|
}
|
|
8269
8608
|
try {
|
|
8270
8609
|
let body = stripRulesetComment(raw);
|
|
8271
|
-
|
|
8272
|
-
|
|
8610
|
+
let targetContexts;
|
|
8611
|
+
if (!(driftCheck?.ok ?? true)) {
|
|
8273
8612
|
const emitted = registryRequiredContexts(meta) ?? await resolveEmittedPrContexts(deps, repo, baseBranch);
|
|
8274
8613
|
if (!emitted.length) {
|
|
8275
8614
|
merge.errors.push("cannot reconcile ruleset contexts \u2014 no PR workflow job ids found");
|
|
8276
8615
|
return merge;
|
|
8277
8616
|
}
|
|
8617
|
+
targetContexts = emitted;
|
|
8278
8618
|
body = patchRulesetRequiredContexts(body, emitted);
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8619
|
+
} else if (!(referenceCheck?.ok ?? true)) {
|
|
8620
|
+
const live = await readLiveProductContexts(deps, repo);
|
|
8621
|
+
if (live == null) {
|
|
8622
|
+
merge.errors.push(`cannot read the live ${PRODUCT_RULESET_NAME} ruleset \u2014 ${PRODUCT_RULESET_REF} left untouched`);
|
|
8623
|
+
return merge;
|
|
8624
|
+
}
|
|
8625
|
+
targetContexts = live;
|
|
8626
|
+
body = patchRulesetRequiredContexts(body, live);
|
|
8627
|
+
} else {
|
|
8628
|
+
targetContexts = rulesetRequiredContexts(body);
|
|
8629
|
+
}
|
|
8630
|
+
if (!(referenceCheck?.ok ?? true) || !(driftCheck?.ok ?? true)) {
|
|
8631
|
+
let comment;
|
|
8282
8632
|
try {
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8633
|
+
comment = JSON.parse(raw)._comment;
|
|
8634
|
+
} catch {
|
|
8635
|
+
comment = void 0;
|
|
8636
|
+
}
|
|
8637
|
+
const fileBody = comment === void 0 ? body : { _comment: comment, ...body };
|
|
8638
|
+
try {
|
|
8639
|
+
const { plan, prUrl } = await deliverSeedFile(
|
|
8640
|
+
deps,
|
|
8641
|
+
repo,
|
|
8642
|
+
PRODUCT_RULESET_REF,
|
|
8643
|
+
`${JSON.stringify(fileBody, null, 2)}
|
|
8644
|
+
`,
|
|
8645
|
+
baseBranch
|
|
8646
|
+
);
|
|
8647
|
+
merge.applied.push(
|
|
8648
|
+
plan.mode === "pr" ? `${PRODUCT_RULESET_REF} contexts \u2192 [${targetContexts.join(", ")}] delivered on ${plan.branch} (${plan.reason})${prUrl ? `: ${prUrl}` : ""}` : `reconciled ${PRODUCT_RULESET_REF} contexts \u2192 [${targetContexts.join(", ")}]`
|
|
8649
|
+
);
|
|
8286
8650
|
} catch (e) {
|
|
8287
|
-
merge.errors.push(`ruleset reference
|
|
8651
|
+
merge.errors.push(`ruleset reference delivery failed: ${e.message}`);
|
|
8288
8652
|
}
|
|
8289
|
-
return merge;
|
|
8290
8653
|
}
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8654
|
+
if (activationNeeded) {
|
|
8655
|
+
const activation = await activateProductRuleset(repo, body, deps.client);
|
|
8656
|
+
if (activation.action === "skipped") merge.skipped.push(activation.detail ?? "product ruleset");
|
|
8657
|
+
else merge.applied.push(`product ruleset ${activation.action}${activation.detail ? `: ${activation.detail}` : ""}`);
|
|
8658
|
+
}
|
|
8294
8659
|
} catch (e) {
|
|
8295
8660
|
merge.errors.push(e.message);
|
|
8296
8661
|
}
|
|
@@ -9147,7 +9512,7 @@ async function executeWaveLand(plan, deps, opts = { preserveWorktree: true }) {
|
|
|
9147
9512
|
}
|
|
9148
9513
|
|
|
9149
9514
|
// src/index.ts
|
|
9150
|
-
var
|
|
9515
|
+
var import_node_os13 = require("node:os");
|
|
9151
9516
|
|
|
9152
9517
|
// src/board.ts
|
|
9153
9518
|
var import_node_child_process8 = require("node:child_process");
|
|
@@ -11079,6 +11444,16 @@ async function fetchSecretForUse(deps, { repo, key, slug }) {
|
|
|
11079
11444
|
|
|
11080
11445
|
// src/report.ts
|
|
11081
11446
|
var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
11447
|
+
var REPORT_LABEL = "report";
|
|
11448
|
+
var REPORT_SURFACE_WAIVER_REASON = "Hub-only friction filing; attribution repo is footer-only; no honest single surface";
|
|
11449
|
+
function preflightReportSurface() {
|
|
11450
|
+
return checkSurfaceRequirement({
|
|
11451
|
+
repo: HUB_REPO2,
|
|
11452
|
+
labels: [REPORT_LABEL],
|
|
11453
|
+
command: "report",
|
|
11454
|
+
waiver: { reason: REPORT_SURFACE_WAIVER_REASON }
|
|
11455
|
+
});
|
|
11456
|
+
}
|
|
11082
11457
|
function findDuplicateReport(source, openReports, threshold = 0.6) {
|
|
11083
11458
|
const normalizedTitle = normalizeTitle(source.title);
|
|
11084
11459
|
let best;
|
|
@@ -11561,6 +11936,49 @@ function resolveParentField(payload) {
|
|
|
11561
11936
|
const parsed = typeof raw === "string" ? parseParentIssueUrl(raw) : null;
|
|
11562
11937
|
return parsed ? { parent: parsed } : { parentReadError: `unrecognised parent_issue_url: ${JSON.stringify(raw)}` };
|
|
11563
11938
|
}
|
|
11939
|
+
async function readIssueParent(runGh, repo, number) {
|
|
11940
|
+
try {
|
|
11941
|
+
const stdout = await runGh(["api", `repos/${repo}/issues/${number}`], RESOLVE_ID_TIMEOUT_MS);
|
|
11942
|
+
const resolved = resolveParentField(JSON.parse(stdout));
|
|
11943
|
+
return "parent" in resolved ? resolved.parent : void 0;
|
|
11944
|
+
} catch {
|
|
11945
|
+
return void 0;
|
|
11946
|
+
}
|
|
11947
|
+
}
|
|
11948
|
+
function isParentConflictMessage(text) {
|
|
11949
|
+
return typeof text === "string" && /sub[- ]?issue may only have one parent/i.test(text);
|
|
11950
|
+
}
|
|
11951
|
+
function buildReparentConflictPayload(input) {
|
|
11952
|
+
const { childRepo, childNumber, requestedParent, currentParent, offendingFlag } = input;
|
|
11953
|
+
const child2 = `${childRepo}#${childNumber}`;
|
|
11954
|
+
const payload = { code: ERROR_CODES.ERR_STATE_CONFLICT };
|
|
11955
|
+
if (offendingFlag) payload.offending_flag = offendingFlag;
|
|
11956
|
+
if (!currentParent) {
|
|
11957
|
+
return {
|
|
11958
|
+
message: currentParent === null ? `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub refused it under the one-parent rule, but reading the issue back showed no parent at all. Something changed it concurrently; re-read with \`mmi-cli issue view ${childNumber} --repo ${childRepo}\` and retry` : `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub allows a sub-issue exactly one parent and it already has one. Its current parent could not be read; check with \`mmi-cli issue view ${childNumber} --repo ${childRepo}\``,
|
|
11959
|
+
payload
|
|
11960
|
+
};
|
|
11961
|
+
}
|
|
11962
|
+
const current = `${currentParent.repo}#${currentParent.number}`;
|
|
11963
|
+
const sameRepo2 = currentParent.repo.toLowerCase() === childRepo.toLowerCase();
|
|
11964
|
+
const repoFlag = ` --repo ${childRepo}`;
|
|
11965
|
+
payload.current_parent = current;
|
|
11966
|
+
const requested = parseIssueRef(requestedParent);
|
|
11967
|
+
const alreadyThere = currentParent.number === requested.number && (requested.repo ?? childRepo).toLowerCase() === currentParent.repo.toLowerCase();
|
|
11968
|
+
if (!alreadyThere) {
|
|
11969
|
+
payload.corrected_command = `mmi-cli issue unlink-child ${sameRepo2 ? String(currentParent.number) : current} ${childNumber}${repoFlag} && mmi-cli issue edit ${childNumber}${repoFlag} --parent ${requestedParent}`;
|
|
11970
|
+
}
|
|
11971
|
+
return {
|
|
11972
|
+
message: alreadyThere ? `${child2} is already a sub-issue of ${current} \u2014 GitHub allows a sub-issue exactly one parent, so this reparent is a no-op it refuses rather than performs; nothing to do` : `${child2} already has a parent (${current}) \u2014 GitHub allows a sub-issue exactly one parent. Reparenting it under ${requestedParent} would REMOVE it from ${current}; unlink it there first if that is what you want`,
|
|
11973
|
+
payload
|
|
11974
|
+
};
|
|
11975
|
+
}
|
|
11976
|
+
async function classifyReparentFailure(e, runGh, childRepo, childNumber, requestedParent, offendingFlag) {
|
|
11977
|
+
const err = e;
|
|
11978
|
+
if (!isParentConflictMessage((err.stderr || err.message || String(e)).trim())) return void 0;
|
|
11979
|
+
const currentParent = await readIssueParent(runGh, childRepo, childNumber);
|
|
11980
|
+
return buildReparentConflictPayload({ childRepo, childNumber, requestedParent, currentParent, offendingFlag });
|
|
11981
|
+
}
|
|
11564
11982
|
function parentLinkFields(result, error) {
|
|
11565
11983
|
if (result) return { parent: result };
|
|
11566
11984
|
if (error) return { parentLinkError: error };
|
|
@@ -15164,17 +15582,23 @@ function partialTrainRecoveryError(cause, input) {
|
|
|
15164
15582
|
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
|
15165
15583
|
const branch = input.stage;
|
|
15166
15584
|
const releaseState = input.stage === "rc" ? "GitHub Release n/a" : "GitHub Release not created";
|
|
15167
|
-
const
|
|
15168
|
-
|
|
15169
|
-
const deployStep = input.stage === "main" ? "3" : "2";
|
|
15585
|
+
const tenant2 = input.deployModel === "tenant-container";
|
|
15586
|
+
const deployLine = tenant2 ? ` - deploy: dispatched by the train after the branch push; recover with \`mmi-cli runtime tenant redeploy ${input.repo} ${input.stage} --watch\` once the branch and release exist.` : ` - deploy: this repo is \`${input.deployModel ?? "not tenant-container"}\` \u2014 it has NO tenant runtime. Its own release/main-push workflows publish and deploy once the branch and Release exist. Do NOT run the tenant redeployer.`;
|
|
15170
15587
|
return new Error(
|
|
15171
15588
|
`${causeMessage}
|
|
15172
15589
|
|
|
15173
|
-
partial train state
|
|
15174
|
-
|
|
15175
|
-
|
|
15176
|
-
|
|
15177
|
-
|
|
15590
|
+
partial train state for ${input.tag}:
|
|
15591
|
+
- tag ${input.tag}: PUSHED to origin
|
|
15592
|
+
- origin/${branch}: NOT pushed
|
|
15593
|
+
- ${releaseState}
|
|
15594
|
+
` + deployLine + `
|
|
15595
|
+
|
|
15596
|
+
The tag is public and correct \u2014 do not delete or force-move it.
|
|
15597
|
+
|
|
15598
|
+
Resume it: mmi-cli release --resume${input.stage === "main" ? "" : " (main-stage partials only)"}
|
|
15599
|
+
|
|
15600
|
+
That completes THIS release at ${input.tag} \u2014 branch push, GitHub Release, the deploy path named above, then the development roll-forward. It preserves the version and refuses unless the partial state is proven (tag on origin, not on origin/main, no Release).
|
|
15601
|
+
Do NOT rerun the train instead: with ${input.tag} on origin the cycle resolver returns the NEXT version, so a rerun cuts a second release rather than finishing this one \u2014 and MMI_RELEASE_VERSION cannot pin it back, because it refuses a version that is not ahead of the latest tag, which ${input.tag} now is.`
|
|
15178
15602
|
);
|
|
15179
15603
|
}
|
|
15180
15604
|
async function probeRemoteTag(deps, tag) {
|
|
@@ -15349,6 +15773,10 @@ async function watchOwnWorkflowRuns(deps, repo, targets, since, headSha, enumera
|
|
|
15349
15773
|
workflowRuns.push(...await discoverShaWorkflowRuns(deps, repo, headSha, seen));
|
|
15350
15774
|
return workflowRuns;
|
|
15351
15775
|
}
|
|
15776
|
+
async function enumerateOwnWorkflowRuns(deps, repo, headSha) {
|
|
15777
|
+
const runs = await discoverShaWorkflowRuns(deps, repo, headSha, /* @__PURE__ */ new Set());
|
|
15778
|
+
return runs.length ? runs : [{ workflow: `sha-enumeration(${headSha.slice(0, 7)}) no runs yet`, conclusion: "pending" }];
|
|
15779
|
+
}
|
|
15352
15780
|
var NON_DEPLOY_EVENTS = /* @__PURE__ */ new Set([
|
|
15353
15781
|
"pull_request",
|
|
15354
15782
|
"pull_request_target",
|
|
@@ -15425,7 +15853,11 @@ async function dispatchDeploy(deps, ctx, stage, ref, model, watch, autoRunSince,
|
|
|
15425
15853
|
}
|
|
15426
15854
|
if (model === "registry-publish") {
|
|
15427
15855
|
const note = ref === "rc" ? "no dispatch on rc: registry-publish repos have no rc-stage Release to publish from" : "no central dispatch: this repo's own publish.yml auto-fires on the published Release (#2428)";
|
|
15428
|
-
if (ref === "rc" || !
|
|
15856
|
+
if (ref === "rc" || !autoRunHeadSha) return { note, deployStatus: "pending" };
|
|
15857
|
+
if (!watch) {
|
|
15858
|
+
const listed = await enumerateOwnWorkflowRuns(deps, ctx.repo, autoRunHeadSha);
|
|
15859
|
+
return { note, workflowRuns: listed, deployStatus: aggregateWorkflowRuns(listed) };
|
|
15860
|
+
}
|
|
15429
15861
|
const since = autoRunSince ?? (deps.now ?? Date.now)();
|
|
15430
15862
|
const workflowRuns = await watchOwnWorkflowRuns(
|
|
15431
15863
|
deps,
|
|
@@ -15440,8 +15872,12 @@ async function dispatchDeploy(deps, ctx, stage, ref, model, watch, autoRunSince,
|
|
|
15440
15872
|
}
|
|
15441
15873
|
if (model === "hub-serverless") {
|
|
15442
15874
|
const note = ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)";
|
|
15443
|
-
if (!watch) return { note, deployStatus: "pending" };
|
|
15444
15875
|
if (!autoRunHeadSha) return { note, deployStatus: "pending" };
|
|
15876
|
+
if (!watch) {
|
|
15877
|
+
if (ref === "rc") return { note, deployStatus: "pending" };
|
|
15878
|
+
const listed = await enumerateOwnWorkflowRuns(deps, HUB_REPO3, autoRunHeadSha);
|
|
15879
|
+
return { note, workflowRuns: listed, deployStatus: aggregateWorkflowRuns(listed) };
|
|
15880
|
+
}
|
|
15445
15881
|
const since = autoRunSince ?? (deps.now ?? Date.now)();
|
|
15446
15882
|
const targets = ref === "rc" ? [{ workflow: "deploy.yml", event: "push", branch: "rc" }] : [
|
|
15447
15883
|
{ workflow: "deploy.yml", event: "release" },
|
|
@@ -15747,7 +16183,7 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
|
|
|
15747
16183
|
try {
|
|
15748
16184
|
checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks, tagPush.pushed ? tagPushSince : void 0);
|
|
15749
16185
|
} catch (e) {
|
|
15750
|
-
throw partialTrainRecoveryError(e, { repo: ctx.repo, tag, stage: "main" });
|
|
16186
|
+
throw partialTrainRecoveryError(e, { repo: ctx.repo, tag, stage: "main", deployModel });
|
|
15751
16187
|
}
|
|
15752
16188
|
if (trueMergeGateNote) checks = `${trueMergeGateNote}; ${checks}`;
|
|
15753
16189
|
await runGitPush(deps, ["push", "origin", "main"]);
|
|
@@ -15767,6 +16203,61 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
|
|
|
15767
16203
|
}
|
|
15768
16204
|
return { checks, releaseUrl, announceNote, dispatch };
|
|
15769
16205
|
}
|
|
16206
|
+
async function runReleaseResume(deps, options = {}) {
|
|
16207
|
+
const watch = options.watch ?? false;
|
|
16208
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
16209
|
+
await requireCleanTree(deps);
|
|
16210
|
+
await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
|
|
16211
|
+
const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
|
|
16212
|
+
const deployModel = await preflight(deps, ctx, "main", meta);
|
|
16213
|
+
const tags = clean2(await deps.run("git", ["tag", "--list", "v*", "--sort=-v:refname"])).split("\n").map((t) => t.trim()).filter(Boolean);
|
|
16214
|
+
const tag = tags[0];
|
|
16215
|
+
if (!tag) throw new Error("release --resume: no v* tag found \u2014 there is no partial release to resume");
|
|
16216
|
+
const tagSha = await probeRemoteTag(deps, tag);
|
|
16217
|
+
if (!tagSha) {
|
|
16218
|
+
throw new Error(
|
|
16219
|
+
`release --resume: ${tag} is not on origin \u2014 nothing was partially released. A local-only tag is not a partial release; run the train normally.`
|
|
16220
|
+
);
|
|
16221
|
+
}
|
|
16222
|
+
const base = { command: "release-resume", repo: ctx.repo, tag, tagSha, deployModel };
|
|
16223
|
+
if (!await isStrayUnreleasedTag(deps, tag, tagSha, ctx.repo)) {
|
|
16224
|
+
throw new Error(
|
|
16225
|
+
`release --resume: ${tag} is NOT a resumable partial \u2014 it is either already reachable from origin/main or it already has a GitHub Release, which means that release completed. (This probe also answers "not resumable" when it cannot prove otherwise \u2014 an unreadable Release or a failed ancestry check is never treated as safe to resume.) Nothing was written.`
|
|
16226
|
+
);
|
|
16227
|
+
}
|
|
16228
|
+
try {
|
|
16229
|
+
await deps.run("git", ["merge-base", "--is-ancestor", "origin/main", tagSha]);
|
|
16230
|
+
} catch {
|
|
16231
|
+
throw new Error(
|
|
16232
|
+
`release --resume: origin/main is not an ancestor of ${tag} (${tagSha.slice(0, 12)}) \u2014 main has moved on since the partial release, so finishing it here would not be a fast-forward. Resolve by hand; nothing was written.`
|
|
16233
|
+
);
|
|
16234
|
+
}
|
|
16235
|
+
const steps = [];
|
|
16236
|
+
await runGitPush(deps, ["push", "origin", `${tagSha}:refs/heads/main`]);
|
|
16237
|
+
steps.push(`pushed ${tagSha.slice(0, 12)} to origin/main`);
|
|
16238
|
+
const releaseUrl = clean2(await deps.run("gh", ["release", "create", tag, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
|
|
16239
|
+
steps.push(`created the GitHub Release for ${tag}`);
|
|
16240
|
+
await verifyPublishedRelease(deps, ctx.repo, tag, "main", tagSha);
|
|
16241
|
+
steps.push("verified the Release published against the tagged commit");
|
|
16242
|
+
const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
|
|
16243
|
+
const autoRunSince = (deps.now ?? Date.now)();
|
|
16244
|
+
const deployDispatch = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch, autoRunSince, tagSha, "report", meta.publishDir);
|
|
16245
|
+
const publishDispatch = deployDispatch.deployStatus === "success" ? await dispatchPublishIfRequired(deps, ctx, meta, deployModel, "main", tag, watch, "report") : null;
|
|
16246
|
+
const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
|
|
16247
|
+
steps.push(`dispatched the ${deployModel} deploy path`);
|
|
16248
|
+
const devRollForward = await rollDevelopmentForward(deps, ctx, tag);
|
|
16249
|
+
steps.push(`development roll-forward: ${devRollForward.status}`);
|
|
16250
|
+
return {
|
|
16251
|
+
...base,
|
|
16252
|
+
resumed: true,
|
|
16253
|
+
steps,
|
|
16254
|
+
releaseUrl,
|
|
16255
|
+
announceNote,
|
|
16256
|
+
dispatch,
|
|
16257
|
+
devRollForward,
|
|
16258
|
+
note: `resumed and completed ${tag} \u2014 the original version was preserved, not re-cut`
|
|
16259
|
+
};
|
|
16260
|
+
}
|
|
15770
16261
|
async function pushRcAlignment(deps) {
|
|
15771
16262
|
try {
|
|
15772
16263
|
await deps.run("git", ["push", "origin", "main:rc"]);
|
|
@@ -15815,7 +16306,7 @@ async function runTrainApplyPipeline(mode, input) {
|
|
|
15815
16306
|
try {
|
|
15816
16307
|
checks2 = await waitForRequiredTrainChecks(deps, ctx, rcSha, requiredChecks, tagPush.pushed ? tagPushSince : void 0);
|
|
15817
16308
|
} catch (e) {
|
|
15818
|
-
throw partialTrainRecoveryError(e, { repo: ctx.repo, tag: tag2, stage: "rc" });
|
|
16309
|
+
throw partialTrainRecoveryError(e, { repo: ctx.repo, tag: tag2, stage: "rc", deployModel: deployModel2 });
|
|
15819
16310
|
}
|
|
15820
16311
|
const autoRunSince = (deps.now ?? Date.now)();
|
|
15821
16312
|
await runGitPush(deps, ["push", "origin", "rc"]);
|
|
@@ -16132,14 +16623,14 @@ async function runTenantReconcile(deps, options) {
|
|
|
16132
16623
|
};
|
|
16133
16624
|
}
|
|
16134
16625
|
function tenantControlWatches(action) {
|
|
16135
|
-
return action === "status" || action === "retire" || action === "verify-secrets" || action === "verify-broker";
|
|
16626
|
+
return action === "status" || action === "retire" || action === "verify-secrets" || action === "verify-broker" || action === "logs";
|
|
16136
16627
|
}
|
|
16137
16628
|
async function runTenantControl(deps, options) {
|
|
16138
16629
|
const { repo, stage, action } = options;
|
|
16139
16630
|
const watch = options.watch ?? tenantControlWatches(action);
|
|
16140
16631
|
const base = { command: "tenant-control", repo, stage, action };
|
|
16141
16632
|
const since = (deps.now ?? Date.now)();
|
|
16142
|
-
const d = await deps.dispatchTenantControl({ repo, stage, action });
|
|
16633
|
+
const d = await deps.dispatchTenantControl({ repo, stage, action, lines: options.lines });
|
|
16143
16634
|
if (!d.ok) {
|
|
16144
16635
|
const transport = d.category === "transport-failed";
|
|
16145
16636
|
return {
|
|
@@ -16156,10 +16647,12 @@ async function runTenantControl(deps, options) {
|
|
|
16156
16647
|
if (action === "retire") {
|
|
16157
16648
|
result.category = conclusion === "success" ? "retired" : conclusion === "failure" ? "control-run-failed" : "wait-timeout";
|
|
16158
16649
|
}
|
|
16159
|
-
if (watch && runId != null && conclusion === "success" && (action === "status" || action === "verify-secrets" || action === "verify-broker")) {
|
|
16650
|
+
if (watch && runId != null && conclusion === "success" && (action === "status" || action === "verify-secrets" || action === "verify-broker" || action === "logs")) {
|
|
16160
16651
|
const output = extractControlOutputFromLog(await fetchControlRunLog(deps, runId));
|
|
16161
16652
|
if (action === "status") {
|
|
16162
16653
|
result.serviceState = parseStatusSnippet(output).serviceState;
|
|
16654
|
+
} else if (action === "logs") {
|
|
16655
|
+
result.logs = output;
|
|
16163
16656
|
} else if (action === "verify-secrets") {
|
|
16164
16657
|
result.secrets = parseVerifySecrets(output);
|
|
16165
16658
|
result.secretsRaw = output;
|
|
@@ -21157,6 +21650,62 @@ function registerQueryCommands(program3) {
|
|
|
21157
21650
|
|
|
21158
21651
|
// src/bootstrap-commands.ts
|
|
21159
21652
|
var import_node_fs24 = require("node:fs");
|
|
21653
|
+
var import_node_os9 = require("node:os");
|
|
21654
|
+
var import_node_path23 = require("node:path");
|
|
21655
|
+
|
|
21656
|
+
// src/bootstrap-drift.ts
|
|
21657
|
+
function byteComparableSeeds(manifest, cls) {
|
|
21658
|
+
return manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self" && s.classes.includes(cls));
|
|
21659
|
+
}
|
|
21660
|
+
function compareSeedBytes(hubContent, repoContent) {
|
|
21661
|
+
if (repoContent === null) return "absent";
|
|
21662
|
+
const normalize = (s) => s.replace(/\r\n/g, "\n");
|
|
21663
|
+
return normalize(hubContent) === normalize(repoContent) ? "match" : "drift";
|
|
21664
|
+
}
|
|
21665
|
+
function auditRepoSeedDrift(repo, seeds, hubContents, repoReads) {
|
|
21666
|
+
const byTarget = new Map(repoReads.map((r) => [r.target, r.content]));
|
|
21667
|
+
const slug = repo.includes("/") ? repo.slice(repo.indexOf("/") + 1).toLowerCase() : repo.toLowerCase();
|
|
21668
|
+
const findings = [];
|
|
21669
|
+
for (const seed of seeds) {
|
|
21670
|
+
const hub = hubContents.get(seed.target);
|
|
21671
|
+
if (hub == null) {
|
|
21672
|
+
findings.push({
|
|
21673
|
+
repo,
|
|
21674
|
+
target: seed.target,
|
|
21675
|
+
state: "drift",
|
|
21676
|
+
detail: `the Hub's own copy could not be read \u2014 the manifest declares a file MMI-Hub does not have`
|
|
21677
|
+
});
|
|
21678
|
+
continue;
|
|
21679
|
+
}
|
|
21680
|
+
const state = compareSeedBytes(hub, byTarget.get(seed.target) ?? null);
|
|
21681
|
+
if (state === "match") continue;
|
|
21682
|
+
const why = seed.waivers?.[slug];
|
|
21683
|
+
if (why) {
|
|
21684
|
+
findings.push({ repo, target: seed.target, state: "waived", detail: `${state} \u2014 waived: ${why}` });
|
|
21685
|
+
continue;
|
|
21686
|
+
}
|
|
21687
|
+
findings.push({
|
|
21688
|
+
repo,
|
|
21689
|
+
target: seed.target,
|
|
21690
|
+
state,
|
|
21691
|
+
detail: state === "absent" ? "declared org-owned in the manifest but not present on the base branch" : "differs from MMI-Hub's copy \u2014 propagate with `mmi-cli bootstrap apply <repo> --only <target> --execute`, or, if this repo is RIGHT to differ, declare a waiver for it on the seed in the manifest (#3842)"
|
|
21692
|
+
});
|
|
21693
|
+
}
|
|
21694
|
+
return findings;
|
|
21695
|
+
}
|
|
21696
|
+
function renderSeedDriftReport(findings, reposAudited, seedsPerRepo) {
|
|
21697
|
+
const lines = [`org-seed drift: ${reposAudited} repo(s) audited, ${seedsPerRepo} byte-comparable seed(s) each`];
|
|
21698
|
+
const waived = findings.filter((f) => f.state === "waived");
|
|
21699
|
+
const real = findings.filter((f) => f.state !== "waived");
|
|
21700
|
+
for (const f of waived) lines.push(` WAIVED ${f.repo} ${f.target} \u2014 ${f.detail}`);
|
|
21701
|
+
if (!real.length) {
|
|
21702
|
+
lines.push(" clean \u2014 every repo carries the Hub's bytes for every org-owned whole-file seed" + (waived.length ? ` (${waived.length} declared waiver(s) above)` : ""));
|
|
21703
|
+
return lines.join("\n");
|
|
21704
|
+
}
|
|
21705
|
+
for (const f of real) lines.push(` ${f.state.toUpperCase().padEnd(6)} ${f.repo} ${f.target} \u2014 ${f.detail}`);
|
|
21706
|
+
lines.push(` \u2014 ${real.length} finding(s)${waived.length ? `, ${waived.length} waived` : ""}`);
|
|
21707
|
+
return lines.join("\n");
|
|
21708
|
+
}
|
|
21160
21709
|
|
|
21161
21710
|
// src/bootstrap-verify.ts
|
|
21162
21711
|
var TRAIN_BRANCHES2 = ["development", "rc", "main"];
|
|
@@ -21757,12 +22306,76 @@ function registerBootstrapCommands(program3) {
|
|
|
21757
22306
|
else console.log(renderOrgRulesetDriftReport(plan));
|
|
21758
22307
|
if (plan.action !== "noop") process.exitCode = 1;
|
|
21759
22308
|
});
|
|
21760
|
-
bootstrap.command("
|
|
22309
|
+
bootstrap.command("drift").description("#3818: compare every org-owned whole-file seed against MMI-Hub's copy across the registry roster; read-only").option("--repo <owner/repo>", "audit one repo instead of the roster (never a fleet verdict)").option("--json", "machine-readable output").action(async () => {
|
|
22310
|
+
const o = { repo: rawValue("--repo", ""), json: rawFlag("--json") };
|
|
22311
|
+
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
22312
|
+
if (!(0, import_node_fs24.existsSync)(manifestPath)) return fail(`bootstrap drift: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the reference this compares against`);
|
|
22313
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs24.readFileSync)(manifestPath, "utf8"));
|
|
22314
|
+
const hubContents = /* @__PURE__ */ new Map();
|
|
22315
|
+
for (const s of manifest.seeds) {
|
|
22316
|
+
if (s.ownership !== "org" || s.source !== "self") continue;
|
|
22317
|
+
hubContents.set(s.target, (0, import_node_fs24.existsSync)(s.target) ? (0, import_node_fs24.readFileSync)(s.target, "utf8") : null);
|
|
22318
|
+
}
|
|
22319
|
+
let targets;
|
|
22320
|
+
let classOf = (_repo) => "deployable";
|
|
22321
|
+
if (o.repo) {
|
|
22322
|
+
targets = [o.repo];
|
|
22323
|
+
} else {
|
|
22324
|
+
const projects = await fetchProjectsList(registryClientDeps(await loadConfig()));
|
|
22325
|
+
if (!projects || projects.length === 0) {
|
|
22326
|
+
return failGraceful("bootstrap drift: the registry roster is unreadable or empty \u2014 refusing to report a fleet verdict from a scope this command could not establish (#3808)");
|
|
22327
|
+
}
|
|
22328
|
+
targets = collectRegistryRepos(projects);
|
|
22329
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
22330
|
+
for (const p of projects) for (const r of p.repos ?? []) byRepo.set((r.includes("/") ? r : `mutmutco/${r}`).toLowerCase(), p.class ?? "deployable");
|
|
22331
|
+
classOf = (repo) => byRepo.get(repo.toLowerCase()) ?? "deployable";
|
|
22332
|
+
}
|
|
22333
|
+
const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
|
|
22334
|
+
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
22335
|
+
const findings = [];
|
|
22336
|
+
let seedsPerRepo = 0;
|
|
22337
|
+
for (const repo of targets) {
|
|
22338
|
+
const cls = classOf(repo);
|
|
22339
|
+
const seeds = byteComparableSeeds(manifest, cls);
|
|
22340
|
+
seedsPerRepo = Math.max(seedsPerRepo, seeds.length);
|
|
22341
|
+
const baseBranch = cls === "content" ? "main" : "development";
|
|
22342
|
+
const reads = [];
|
|
22343
|
+
for (const seed of seeds) {
|
|
22344
|
+
let content = null;
|
|
22345
|
+
try {
|
|
22346
|
+
const r = await gh(["api", `repos/${repo}/contents/${enc(seed.target)}?ref=${baseBranch}`]);
|
|
22347
|
+
const parsed = JSON.parse(r.stdout);
|
|
22348
|
+
content = parsed.encoding === "base64" && typeof parsed.content === "string" ? Buffer.from(parsed.content, "base64").toString("utf8") : null;
|
|
22349
|
+
} catch {
|
|
22350
|
+
content = null;
|
|
22351
|
+
}
|
|
22352
|
+
reads.push({ target: seed.target, content });
|
|
22353
|
+
}
|
|
22354
|
+
findings.push(...auditRepoSeedDrift(repo, seeds, hubContents, reads));
|
|
22355
|
+
}
|
|
22356
|
+
if (o.json) {
|
|
22357
|
+
console.log(JSON.stringify({
|
|
22358
|
+
// #3842: `ok` reflects real findings; waivers ride the payload so a consumer can see every
|
|
22359
|
+
// standing exception without them counting as drift.
|
|
22360
|
+
ok: findings.every((f) => f.state === "waived"),
|
|
22361
|
+
scope: o.repo ? "single-repo" : "fleet",
|
|
22362
|
+
reposAudited: targets.length,
|
|
22363
|
+
seedsPerRepo,
|
|
22364
|
+
waived: findings.filter((f) => f.state === "waived").length,
|
|
22365
|
+
findings
|
|
22366
|
+
}, null, 2));
|
|
22367
|
+
} else {
|
|
22368
|
+
console.log(renderSeedDriftReport(findings, targets.length, seedsPerRepo));
|
|
22369
|
+
}
|
|
22370
|
+
if (findings.some((f) => f.state !== "waived")) process.exitCode = 1;
|
|
22371
|
+
});
|
|
22372
|
+
bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--only <target>", "deliver ONLY this manifest target (#3818) \u2014 one file, no labels/ruleset/registry writes").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo, cmdOpts) => {
|
|
21761
22373
|
const o = {
|
|
21762
22374
|
class: rawValue("--class", "deployable"),
|
|
21763
22375
|
projectType: rawValue("--project-type", ""),
|
|
21764
22376
|
deployModel: rawValue("--deploy-model", ""),
|
|
21765
22377
|
releaseTrack: rawValue("--release-track", ""),
|
|
22378
|
+
only: rawValue("--only", ""),
|
|
21766
22379
|
execute: rawFlag("--execute"),
|
|
21767
22380
|
json: rawFlag("--json")
|
|
21768
22381
|
};
|
|
@@ -21782,9 +22395,28 @@ function registerBootstrapCommands(program3) {
|
|
|
21782
22395
|
const manifest = loadBootstrapSeeds((0, import_node_fs24.readFileSync)(manifestPath, "utf8"));
|
|
21783
22396
|
const baseBranch = o.class === "content" ? "main" : "development";
|
|
21784
22397
|
const slug = parsedRepo.slug;
|
|
22398
|
+
const onlyTarget = o.only.trim();
|
|
22399
|
+
const seedsToApply = onlyTarget ? manifest.seeds.filter((s) => s.target === onlyTarget || s.target.replace("{{REPO_SLUG}}", slug) === onlyTarget) : manifest.seeds;
|
|
22400
|
+
if (onlyTarget && !seedsToApply.length) {
|
|
22401
|
+
const known = manifest.seeds.map((s) => s.target).join("\n ");
|
|
22402
|
+
return fail(`bootstrap apply: --only '${onlyTarget}' names no seed in ${manifestPath}. Declared targets:
|
|
22403
|
+
${known}`);
|
|
22404
|
+
}
|
|
21785
22405
|
const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
|
|
21786
22406
|
const readFile9 = (p) => (0, import_node_fs24.existsSync)(p) ? (0, import_node_fs24.readFileSync)(p, "utf8") : null;
|
|
21787
22407
|
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
22408
|
+
const putSeed = async (target, content, ref, sha) => {
|
|
22409
|
+
const tmp = (0, import_node_path23.join)((0, import_node_os9.tmpdir)(), `mmi-seed-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.json`);
|
|
22410
|
+
(0, import_node_fs24.writeFileSync)(tmp, JSON.stringify(contentPutBody(target, content, ref, sha)), "utf8");
|
|
22411
|
+
try {
|
|
22412
|
+
await gh(contentPutInputArgs(repo, target, tmp));
|
|
22413
|
+
} finally {
|
|
22414
|
+
try {
|
|
22415
|
+
(0, import_node_fs24.unlinkSync)(tmp);
|
|
22416
|
+
} catch {
|
|
22417
|
+
}
|
|
22418
|
+
}
|
|
22419
|
+
};
|
|
21788
22420
|
const rawVars = {};
|
|
21789
22421
|
for (const value of cmdOpts.var ?? []) {
|
|
21790
22422
|
const eq = value.indexOf("=");
|
|
@@ -21849,7 +22481,7 @@ function registerBootstrapCommands(program3) {
|
|
|
21849
22481
|
}
|
|
21850
22482
|
}
|
|
21851
22483
|
const docsForIndex = [];
|
|
21852
|
-
for (const seed of
|
|
22484
|
+
for (const seed of seedsToApply) {
|
|
21853
22485
|
if (!seed.classes.includes(o.class)) continue;
|
|
21854
22486
|
if (!seedMatchesDeployModel(seed, applyDeployModel)) continue;
|
|
21855
22487
|
if (!seedMatchesProjectType(seed, applyProjectType)) continue;
|
|
@@ -21881,12 +22513,12 @@ function registerBootstrapCommands(program3) {
|
|
|
21881
22513
|
docsForIndex.push({ path: resolved.target, content: docBody });
|
|
21882
22514
|
}
|
|
21883
22515
|
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
21884
|
-
await
|
|
22516
|
+
await putSeed(resolved.target, content, seedPlan.ref, action.action === "update" ? sha : void 0);
|
|
21885
22517
|
applied.push(`${action.action} ${resolved.target}`);
|
|
21886
22518
|
if (seedPlan.mode === "pr") seededToBranch++;
|
|
21887
22519
|
}
|
|
21888
22520
|
}
|
|
21889
|
-
const indexContent = seededDocsIndex(docsForIndex);
|
|
22521
|
+
const indexContent = onlyTarget ? null : seededDocsIndex(docsForIndex);
|
|
21890
22522
|
if (indexContent) {
|
|
21891
22523
|
let indexCurrent = null;
|
|
21892
22524
|
try {
|
|
@@ -21903,7 +22535,7 @@ function registerBootstrapCommands(program3) {
|
|
|
21903
22535
|
reason: indexCurrent === null ? "generated routing index (#3545)" : "routing index present \u2014 regenerate with `mmi-cli docs index --write`, which sees the whole tree"
|
|
21904
22536
|
});
|
|
21905
22537
|
if (o.execute && indexAction === "create") {
|
|
21906
|
-
await
|
|
22538
|
+
await putSeed(DOCS_INDEX_PATH, indexContent, seedPlan.ref, void 0);
|
|
21907
22539
|
applied.push(`${indexAction} ${DOCS_INDEX_PATH}`);
|
|
21908
22540
|
if (seedPlan.mode === "pr") seededToBranch++;
|
|
21909
22541
|
}
|
|
@@ -21927,9 +22559,13 @@ function registerBootstrapCommands(program3) {
|
|
|
21927
22559
|
"--head",
|
|
21928
22560
|
seedPlan.branch,
|
|
21929
22561
|
"--title",
|
|
21930
|
-
`bootstrap: seed ${parsedRepo.name} (${baseBranch} is protected)`,
|
|
22562
|
+
onlyTarget ? `chore: propagate org-owned ${onlyTarget} from MMI-Hub` : `bootstrap: seed ${parsedRepo.name} (${baseBranch} is protected)`,
|
|
21931
22563
|
"--body",
|
|
21932
|
-
`Auto-opened by \`mmi-cli bootstrap apply
|
|
22564
|
+
onlyTarget ? `Auto-opened by \`mmi-cli bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
|
|
22565
|
+
|
|
22566
|
+
\`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\`; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
|
|
22567
|
+
|
|
22568
|
+
\`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected").`
|
|
21933
22569
|
]);
|
|
21934
22570
|
seedPrUrl = created.url;
|
|
21935
22571
|
}
|
|
@@ -21945,7 +22581,7 @@ function registerBootstrapCommands(program3) {
|
|
|
21945
22581
|
});
|
|
21946
22582
|
applied.push(autoMergeEnabled ? `seed: PR ${seedPrUrl} (base ${baseBranch} protected; auto-merge enabled)` : `seed: PR ${seedPrUrl} (base ${baseBranch} protected; auto-merge refused \u2014 PR is already clean. Land it: mmi-cli pr land <n> --repo ${repo})`);
|
|
21947
22583
|
}
|
|
21948
|
-
if (o.execute && o.class === "deployable") {
|
|
22584
|
+
if (o.execute && !onlyTarget && o.class === "deployable") {
|
|
21949
22585
|
try {
|
|
21950
22586
|
await gh(["api", "-X", "PATCH", `repos/${repo}`, "-f", "allow_auto_merge=true", "-f", "allow_squash_merge=true", "-f", "delete_branch_on_merge=true"]);
|
|
21951
22587
|
applied.push("merge settings: allow_auto_merge, squash, delete-branch-on-merge");
|
|
@@ -21974,7 +22610,7 @@ function registerBootstrapCommands(program3) {
|
|
|
21974
22610
|
}
|
|
21975
22611
|
}
|
|
21976
22612
|
}
|
|
21977
|
-
if (o.execute) {
|
|
22613
|
+
if (o.execute && !onlyTarget) {
|
|
21978
22614
|
for (const l of manifest.labels) {
|
|
21979
22615
|
try {
|
|
21980
22616
|
await gh(["label", "create", l.name, "--color", l.color, "--description", l.description, "--force", "-R", repo]);
|
|
@@ -21994,17 +22630,19 @@ function registerBootstrapCommands(program3) {
|
|
|
21994
22630
|
}
|
|
21995
22631
|
}
|
|
21996
22632
|
const ddbWrites = [];
|
|
21997
|
-
let registerPayload;
|
|
21998
|
-
|
|
21999
|
-
|
|
22000
|
-
|
|
22001
|
-
|
|
22002
|
-
|
|
22003
|
-
|
|
22004
|
-
|
|
22005
|
-
|
|
22633
|
+
let registerPayload = {};
|
|
22634
|
+
if (!onlyTarget) {
|
|
22635
|
+
try {
|
|
22636
|
+
registerPayload = buildRegisterPayload(repo, o.class, vars, {
|
|
22637
|
+
projectType: o.projectType || void 0,
|
|
22638
|
+
deployModel: o.deployModel || void 0,
|
|
22639
|
+
releaseTrack: bootstrapReleaseTrack
|
|
22640
|
+
});
|
|
22641
|
+
} catch (e) {
|
|
22642
|
+
return fail(`bootstrap apply: ${e.message}`);
|
|
22643
|
+
}
|
|
22006
22644
|
}
|
|
22007
|
-
if (o.execute) {
|
|
22645
|
+
if (o.execute && !onlyTarget) {
|
|
22008
22646
|
const cfg = await loadConfig();
|
|
22009
22647
|
const res = await registerProject(registerPayload, registryClientDeps(cfg));
|
|
22010
22648
|
if (res.ok) {
|
|
@@ -22015,7 +22653,7 @@ function registerBootstrapCommands(program3) {
|
|
|
22015
22653
|
applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
|
|
22016
22654
|
}
|
|
22017
22655
|
}
|
|
22018
|
-
if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
|
|
22656
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
|
|
22019
22657
|
else {
|
|
22020
22658
|
console.log(renderSeedPlan(actions));
|
|
22021
22659
|
if (o.execute) console.log(`
|
|
@@ -22027,11 +22665,11 @@ LIVE apply to ${repo}:
|
|
|
22027
22665
|
|
|
22028
22666
|
// src/stage-commands.ts
|
|
22029
22667
|
var import_node_fs26 = require("node:fs");
|
|
22030
|
-
var
|
|
22668
|
+
var import_node_path25 = require("node:path");
|
|
22031
22669
|
|
|
22032
22670
|
// src/port-registry.ts
|
|
22033
22671
|
var import_node_fs25 = require("node:fs");
|
|
22034
|
-
var
|
|
22672
|
+
var import_node_path24 = require("node:path");
|
|
22035
22673
|
|
|
22036
22674
|
// ../infra/port-geometry.mjs
|
|
22037
22675
|
var PORT_BLOCK = 100;
|
|
@@ -22084,22 +22722,22 @@ function existingPortRange(repo, registry2) {
|
|
|
22084
22722
|
return registry2[repo] ?? null;
|
|
22085
22723
|
}
|
|
22086
22724
|
function portRangeInfraAt(root, source) {
|
|
22087
|
-
const registryPath = (0,
|
|
22088
|
-
const ddbScriptPath = (0,
|
|
22725
|
+
const registryPath = (0, import_node_path24.join)(root, "infra", "port-ranges.json");
|
|
22726
|
+
const ddbScriptPath = (0, import_node_path24.join)(root, "infra", "port-ddb.mjs");
|
|
22089
22727
|
if (!(0, import_node_fs25.existsSync)(registryPath) || !(0, import_node_fs25.existsSync)(ddbScriptPath)) return null;
|
|
22090
22728
|
return { root, source, registryPath, ddbScriptPath };
|
|
22091
22729
|
}
|
|
22092
22730
|
function resolvePortRangeInfra(cwd, packageDir) {
|
|
22093
22731
|
const direct = portRangeInfraAt(cwd, "cwd");
|
|
22094
22732
|
if (direct) return direct;
|
|
22095
|
-
for (let dir = cwd; ; dir = (0,
|
|
22096
|
-
const sibling = portRangeInfraAt((0,
|
|
22733
|
+
for (let dir = cwd; ; dir = (0, import_node_path24.dirname)(dir)) {
|
|
22734
|
+
const sibling = portRangeInfraAt((0, import_node_path24.join)(dir, "MMI-Hub"), "sibling-hub");
|
|
22097
22735
|
if (sibling) return sibling;
|
|
22098
|
-
const parent = (0,
|
|
22736
|
+
const parent = (0, import_node_path24.dirname)(dir);
|
|
22099
22737
|
if (parent === dir) break;
|
|
22100
22738
|
}
|
|
22101
22739
|
if (packageDir) {
|
|
22102
|
-
const pkgRoot = (0,
|
|
22740
|
+
const pkgRoot = (0, import_node_path24.join)(packageDir, "..", "..");
|
|
22103
22741
|
const pkgFrom = portRangeInfraAt(pkgRoot, "pkg-root");
|
|
22104
22742
|
if (pkgFrom) return pkgFrom;
|
|
22105
22743
|
}
|
|
@@ -22275,8 +22913,8 @@ function registerStageCommands(program3) {
|
|
|
22275
22913
|
const portRange = portRangeMeta && typeof portRangeMeta.start === "number" && typeof portRangeMeta.end === "number" ? [portRangeMeta.start, portRangeMeta.end] : void 0;
|
|
22276
22914
|
return decideStage({
|
|
22277
22915
|
registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
|
|
22278
|
-
hasCompose: (0, import_node_fs26.existsSync)((0,
|
|
22279
|
-
hasEnvExample: (0, import_node_fs26.existsSync)((0,
|
|
22916
|
+
hasCompose: (0, import_node_fs26.existsSync)((0, import_node_path25.join)(process.cwd(), "docker-compose.yml")),
|
|
22917
|
+
hasEnvExample: (0, import_node_fs26.existsSync)((0, import_node_path25.join)(process.cwd(), ".env.example"))
|
|
22280
22918
|
});
|
|
22281
22919
|
}
|
|
22282
22920
|
async function fetchStageVaultEnvMerge() {
|
|
@@ -22716,8 +23354,8 @@ function registerBoardCommands(program3) {
|
|
|
22716
23354
|
// src/merge-cleanup.ts
|
|
22717
23355
|
var import_node_fs27 = require("node:fs");
|
|
22718
23356
|
var import_promises7 = require("node:fs/promises");
|
|
22719
|
-
var
|
|
22720
|
-
var
|
|
23357
|
+
var import_node_path27 = require("node:path");
|
|
23358
|
+
var import_node_os10 = require("node:os");
|
|
22721
23359
|
var import_node_child_process13 = require("node:child_process");
|
|
22722
23360
|
|
|
22723
23361
|
// src/board-advance.ts
|
|
@@ -22804,7 +23442,7 @@ function boardAdvanceFailureMessage(result) {
|
|
|
22804
23442
|
|
|
22805
23443
|
// src/deferred-registry-store.ts
|
|
22806
23444
|
var import_promises6 = require("node:fs/promises");
|
|
22807
|
-
var
|
|
23445
|
+
var import_node_path26 = require("node:path");
|
|
22808
23446
|
var sleep2 = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
22809
23447
|
async function atomicWrite(target, contents) {
|
|
22810
23448
|
const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
@@ -22855,12 +23493,12 @@ function makeDeferredWorktreeStore(registryPath, lockOpts = {}) {
|
|
|
22855
23493
|
},
|
|
22856
23494
|
// Standalone atomic write — THROWS on failure (no best-effort swallow, #2846).
|
|
22857
23495
|
write: async (entries) => {
|
|
22858
|
-
await (0, import_promises6.mkdir)((0,
|
|
23496
|
+
await (0, import_promises6.mkdir)((0, import_node_path26.dirname)(registryPath), { recursive: true });
|
|
22859
23497
|
await atomicWrite(registryPath, serializeDeferredWorktrees(entries));
|
|
22860
23498
|
},
|
|
22861
23499
|
// Serialized read-modify-write under the repo-wide lock (#2846).
|
|
22862
23500
|
update: async (mutate) => {
|
|
22863
|
-
await (0, import_promises6.mkdir)((0,
|
|
23501
|
+
await (0, import_promises6.mkdir)((0, import_node_path26.dirname)(registryPath), { recursive: true });
|
|
22864
23502
|
const deadline = Date.now() + opts.maxWaitMs;
|
|
22865
23503
|
for (; ; ) {
|
|
22866
23504
|
const guard = await acquireLock(lockPath, opts, deadline);
|
|
@@ -23021,7 +23659,7 @@ async function applyGcPlan(plan, remote, opts = {}) {
|
|
|
23021
23659
|
);
|
|
23022
23660
|
const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
23023
23661
|
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
23024
|
-
const primaryRepoRoot = worktreeGitRoot ? (0,
|
|
23662
|
+
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path27.dirname)((0, import_node_path27.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
23025
23663
|
const gcActor = describeActor({ env: process.env, surface: detectSurface(process.env), cwd: process.cwd() });
|
|
23026
23664
|
const owners = readWorktreeOwners(primaryRepoRoot);
|
|
23027
23665
|
const removalNow = Date.now();
|
|
@@ -23145,8 +23783,8 @@ async function composeOverrideBodyFile(prNumber, repoArgs, gh) {
|
|
|
23145
23783
|
const commits = JSON.parse(raw).commits ?? [];
|
|
23146
23784
|
const body = squashBodyWithOverride(commits.map((c) => ({ headline: c.messageHeadline ?? "", body: c.messageBody ?? "" })), process.cwd());
|
|
23147
23785
|
if (!body) return void 0;
|
|
23148
|
-
const dir = (0, import_node_fs27.mkdtempSync)((0,
|
|
23149
|
-
const path2 = (0,
|
|
23786
|
+
const dir = (0, import_node_fs27.mkdtempSync)((0, import_node_path27.join)((0, import_node_os10.tmpdir)(), "mmi-squash-body-"));
|
|
23787
|
+
const path2 = (0, import_node_path27.join)(dir, "body.txt");
|
|
23150
23788
|
(0, import_node_fs27.writeFileSync)(path2, `${body}
|
|
23151
23789
|
`, "utf8");
|
|
23152
23790
|
return { path: path2, cleanup: () => {
|
|
@@ -23542,7 +24180,7 @@ async function fetchRestCorePool(gh = defaultGhApi) {
|
|
|
23542
24180
|
// src/worktree-lifecycle-commands.ts
|
|
23543
24181
|
var import_node_fs28 = require("node:fs");
|
|
23544
24182
|
var import_promises8 = require("node:fs/promises");
|
|
23545
|
-
var
|
|
24183
|
+
var import_node_path28 = require("node:path");
|
|
23546
24184
|
var GH_TIMEOUT_MS = 2e4;
|
|
23547
24185
|
var DEFAULT_BASE = "origin/development";
|
|
23548
24186
|
var DEFAULT_REMOTE = "origin";
|
|
@@ -23678,7 +24316,7 @@ function classifyStaleLeaks(input) {
|
|
|
23678
24316
|
var defaultOrphanDirScanDeps = {
|
|
23679
24317
|
listDirs: (root) => {
|
|
23680
24318
|
try {
|
|
23681
|
-
return (0, import_node_fs28.readdirSync)(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => (0,
|
|
24319
|
+
return (0, import_node_fs28.readdirSync)(root, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => (0, import_node_path28.join)(root, e.name));
|
|
23682
24320
|
} catch {
|
|
23683
24321
|
return [];
|
|
23684
24322
|
}
|
|
@@ -23825,13 +24463,13 @@ function registerWorktreeCommands(program3) {
|
|
|
23825
24463
|
const headBorn = await execFileP2("git", ["-C", wtPath || ".", "rev-parse", "--verify", "--quiet", "HEAD"], { timeout: GIT_TIMEOUT_MS }).then(() => true).catch(() => false);
|
|
23826
24464
|
const branch = headBorn ? (await execFileP2("git", ["rev-parse", "--abbrev-ref", "HEAD"], { timeout: GIT_TIMEOUT_MS })).stdout.trim() : (await execFileP2("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
23827
24465
|
if (!wtPath || !branch) return fail("worktree land: not inside a git worktree");
|
|
23828
|
-
const gitFile = (0,
|
|
24466
|
+
const gitFile = (0, import_node_path28.join)(wtPath, ".git");
|
|
23829
24467
|
const isLinked = (0, import_node_fs28.existsSync)(gitFile) && (0, import_node_fs28.statSync)(gitFile).isFile();
|
|
23830
24468
|
if (apply && !isLinked) {
|
|
23831
24469
|
return fail("worktree land: run from inside the linked worktree you want to land (this is the primary checkout)");
|
|
23832
24470
|
}
|
|
23833
24471
|
const commonDir = (await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
23834
|
-
const primaryCheckout = commonDir ? (0,
|
|
24472
|
+
const primaryCheckout = commonDir ? (0, import_node_path28.dirname)(commonDir) : wtPath;
|
|
23835
24473
|
const localBranchNames = await execFileP2("git", ["-C", primaryCheckout, "for-each-ref", "--format=%(refname:short)", "refs/heads"], { timeout: GIT_TIMEOUT_MS }).then(({ stdout }) => new Set((stdout || "").split("\n").map((l) => l.trim()).filter(Boolean))).catch(() => void 0);
|
|
23836
24474
|
const orphan = classifyOrphanedWorktree({
|
|
23837
24475
|
branch,
|
|
@@ -24018,7 +24656,7 @@ async function gatherWorktreeContext() {
|
|
|
24018
24656
|
if (s) stages.push({ path: wt.path, port: s.port });
|
|
24019
24657
|
}
|
|
24020
24658
|
const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
|
|
24021
|
-
const primaryRepoRoot = worktreeGitRoot ? (0,
|
|
24659
|
+
const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path28.dirname)((0, import_node_path28.dirname)(worktreeGitRoot)) : repoRoot2;
|
|
24022
24660
|
const wtRoot = siblingMmiWorktreesRoot(primaryRepoRoot);
|
|
24023
24661
|
let orphanDirs = [];
|
|
24024
24662
|
if ((0, import_node_fs28.existsSync)(wtRoot)) {
|
|
@@ -24047,6 +24685,14 @@ async function bestEffortGit(args, cwd, step, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
24047
24685
|
var import_node_fs29 = require("node:fs");
|
|
24048
24686
|
var import_node_crypto5 = require("node:crypto");
|
|
24049
24687
|
var ghRunner = async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout;
|
|
24688
|
+
var ReparentConflictError = class extends Error {
|
|
24689
|
+
constructor(message, payload) {
|
|
24690
|
+
super(message);
|
|
24691
|
+
this.payload = payload;
|
|
24692
|
+
this.name = "ReparentConflictError";
|
|
24693
|
+
}
|
|
24694
|
+
payload;
|
|
24695
|
+
};
|
|
24050
24696
|
async function editIssue(client, options, deps = {}) {
|
|
24051
24697
|
const parsed = parseIssueRef(options.ref);
|
|
24052
24698
|
const repo = parsed.repo ?? options.defaultRepo;
|
|
@@ -24095,7 +24741,12 @@ async function editIssue(client, options, deps = {}) {
|
|
|
24095
24741
|
let parentResult;
|
|
24096
24742
|
if (options.parent) {
|
|
24097
24743
|
const run = deps.runGh ?? ghRunner;
|
|
24098
|
-
|
|
24744
|
+
try {
|
|
24745
|
+
parentResult = await linkSubIssue(run, options.parent, options.ref, repo);
|
|
24746
|
+
} catch (e) {
|
|
24747
|
+
const conflict = await classifyReparentFailure(e, run, repo, parsed.number, options.parent, "--parent");
|
|
24748
|
+
throw conflict ? new ReparentConflictError(conflict.message, conflict.payload) : e;
|
|
24749
|
+
}
|
|
24099
24750
|
}
|
|
24100
24751
|
return {
|
|
24101
24752
|
number: parsed.number,
|
|
@@ -24285,7 +24936,7 @@ ${spec.body ?? ""}`;
|
|
|
24285
24936
|
const hash = (0, import_node_crypto5.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
|
|
24286
24937
|
return `${batchKey}:${hash}`;
|
|
24287
24938
|
}
|
|
24288
|
-
var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "label", "parent", "repo"]);
|
|
24939
|
+
var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "label", "parent", "repo", "surface"]);
|
|
24289
24940
|
function validateBatchSpecs(specs) {
|
|
24290
24941
|
const errors = [];
|
|
24291
24942
|
const validated = [];
|
|
@@ -24326,10 +24977,47 @@ function validateBatchSpecs(specs) {
|
|
|
24326
24977
|
errors.push({ row, error: e.message });
|
|
24327
24978
|
continue;
|
|
24328
24979
|
}
|
|
24980
|
+
if (spec.surface !== void 0) {
|
|
24981
|
+
if (typeof spec.surface !== "string" || !spec.surface.trim()) {
|
|
24982
|
+
errors.push({ row, error: "surface must be a non-empty string" });
|
|
24983
|
+
continue;
|
|
24984
|
+
}
|
|
24985
|
+
if (!labelsCarrySurface(spec.labels)) spec.labels = [...spec.labels ?? [], surfaceLabel(spec.surface)];
|
|
24986
|
+
delete spec.surface;
|
|
24987
|
+
}
|
|
24329
24988
|
validated.push({ row, spec, priority, type: spec.type });
|
|
24330
24989
|
}
|
|
24331
24990
|
return { ok: errors.length === 0, errors, validated };
|
|
24332
24991
|
}
|
|
24992
|
+
async function preflightBatchSurfaces(validated, rowRepo, options) {
|
|
24993
|
+
const applies = /* @__PURE__ */ new Map();
|
|
24994
|
+
const appliesTo = async (repo) => {
|
|
24995
|
+
if (!applies.has(repo)) applies.set(repo, await surfaceLabelApplies(repo));
|
|
24996
|
+
return applies.get(repo);
|
|
24997
|
+
};
|
|
24998
|
+
if (options.surface) {
|
|
24999
|
+
for (const { spec } of validated) {
|
|
25000
|
+
if (labelsCarrySurface(spec.labels)) continue;
|
|
25001
|
+
if (await appliesTo(rowRepo(spec))) spec.labels = [...spec.labels ?? [], surfaceLabel(options.surface)];
|
|
25002
|
+
}
|
|
25003
|
+
}
|
|
25004
|
+
if (options.noSurface) return [];
|
|
25005
|
+
const errors = [];
|
|
25006
|
+
const cache = /* @__PURE__ */ new Map();
|
|
25007
|
+
for (const { row, spec } of validated) {
|
|
25008
|
+
const repo = rowRepo(spec);
|
|
25009
|
+
const key = [repo, labelsCarrySurface(spec.labels) ? "has" : "none"].join("::");
|
|
25010
|
+
let verdict = cache.get(key);
|
|
25011
|
+
if (!verdict) {
|
|
25012
|
+
verdict = await checkSurfaceRequirement({ repo, labels: spec.labels, command: "issue create --batch" });
|
|
25013
|
+
cache.set(key, verdict);
|
|
25014
|
+
if (verdict.warn) process.stderr.write(`${verdict.warn}
|
|
25015
|
+
`);
|
|
25016
|
+
}
|
|
25017
|
+
if (verdict.refusal) errors.push({ row, error: verdict.refusal.message });
|
|
25018
|
+
}
|
|
25019
|
+
return errors;
|
|
25020
|
+
}
|
|
24333
25021
|
async function createIssuesBatch(specs, options, deps = {}) {
|
|
24334
25022
|
const ensureLabels = deps.ensureLabels ?? ensureLabelsExist;
|
|
24335
25023
|
const client = deps.client ?? defaultGitHubClient();
|
|
@@ -24345,6 +25033,12 @@ ${lines}`);
|
|
|
24345
25033
|
throw new Error("could not resolve repo \u2014 pass --repo owner/repo or set repo per row");
|
|
24346
25034
|
}
|
|
24347
25035
|
const rowRepo = (spec) => spec.repo ?? defaultRepo;
|
|
25036
|
+
const surfaceErrors = await preflightBatchSurfaces(validation.validated, rowRepo, options);
|
|
25037
|
+
if (surfaceErrors.length) {
|
|
25038
|
+
const lines = surfaceErrors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
25039
|
+
throw new Error(`batch validation failed (${surfaceErrors.length} error(s)):
|
|
25040
|
+
${lines}`);
|
|
25041
|
+
}
|
|
24348
25042
|
const labelsByRepo = /* @__PURE__ */ new Map();
|
|
24349
25043
|
for (const { spec } of validation.validated) {
|
|
24350
25044
|
if (!spec.labels?.length) continue;
|
|
@@ -24451,7 +25145,10 @@ function registerIssueLifecycleCommands(program3, deps = {}) {
|
|
|
24451
25145
|
});
|
|
24452
25146
|
console.log(JSON.stringify(result));
|
|
24453
25147
|
} catch (e) {
|
|
24454
|
-
return
|
|
25148
|
+
return failGracefulEnvelope(
|
|
25149
|
+
`issue edit failed: ${e.message}`,
|
|
25150
|
+
e instanceof ReparentConflictError ? e.payload : void 0
|
|
25151
|
+
);
|
|
24455
25152
|
}
|
|
24456
25153
|
});
|
|
24457
25154
|
mutating(
|
|
@@ -24580,14 +25277,28 @@ ${lines}`);
|
|
|
24580
25277
|
}
|
|
24581
25278
|
const batchParent = opts.parent;
|
|
24582
25279
|
const batchPriority = opts.priority ? normalizePriority(opts.priority) : void 0;
|
|
25280
|
+
const batchSurface = typeof opts.surface === "string" && opts.surface.trim() ? opts.surface : void 0;
|
|
25281
|
+
const batchNoSurface = rawFlag("--no-surface");
|
|
24583
25282
|
if (opts.dryRun || opts.validateOnly) {
|
|
25283
|
+
const defaultRepo = await resolveRepo(opts.repo);
|
|
25284
|
+
const planRowRepo = (spec) => spec.repo ?? defaultRepo;
|
|
25285
|
+
const surfaceErrors = defaultRepo || validation.validated.every((v) => v.spec.repo) ? await preflightBatchSurfaces(validation.validated, planRowRepo, { surface: batchSurface, noSurface: batchNoSurface }) : [];
|
|
25286
|
+
if (surfaceErrors.length) {
|
|
25287
|
+
const lines = surfaceErrors.map((e) => ` row ${e.row}: ${e.error}`).join("\n");
|
|
25288
|
+
return fail(`issue create --batch: validation failed (${surfaceErrors.length} error(s)):
|
|
25289
|
+
${lines}`, {
|
|
25290
|
+
code: ERROR_CODES.ERR_MISSING_FLAG,
|
|
25291
|
+
offending_flag: "--surface"
|
|
25292
|
+
});
|
|
25293
|
+
}
|
|
24584
25294
|
const planned = validation.validated.map((v) => ({
|
|
24585
25295
|
row: v.row,
|
|
24586
25296
|
type: v.type,
|
|
24587
25297
|
title: v.spec.title,
|
|
24588
25298
|
priority: v.spec.priority ? v.priority : batchPriority ?? v.priority,
|
|
24589
25299
|
repo: v.spec.repo,
|
|
24590
|
-
...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {}
|
|
25300
|
+
...v.spec.parent ?? batchParent ? { parent: v.spec.parent ?? batchParent } : {},
|
|
25301
|
+
...v.spec.labels?.length ? { labels: v.spec.labels } : {}
|
|
24591
25302
|
}));
|
|
24592
25303
|
console.log(JSON.stringify(opts.validateOnly ? { ok: true, planned } : { dry_run: true, planned }));
|
|
24593
25304
|
return;
|
|
@@ -24597,7 +25308,9 @@ ${lines}`);
|
|
|
24597
25308
|
repo: opts.repo,
|
|
24598
25309
|
idempotencyKey: opts.idempotencyKey,
|
|
24599
25310
|
parent: batchParent,
|
|
24600
|
-
priority: batchPriority
|
|
25311
|
+
priority: batchPriority,
|
|
25312
|
+
surface: batchSurface,
|
|
25313
|
+
noSurface: batchNoSurface
|
|
24601
25314
|
}, { attach: batchAttach });
|
|
24602
25315
|
console.log(JSON.stringify(result));
|
|
24603
25316
|
if (result.failures.length) process.exitCode = 1;
|
|
@@ -24625,7 +25338,7 @@ ${lines}`);
|
|
|
24625
25338
|
|
|
24626
25339
|
// src/train-commands.ts
|
|
24627
25340
|
var import_node_fs30 = require("node:fs");
|
|
24628
|
-
var
|
|
25341
|
+
var import_node_path29 = require("node:path");
|
|
24629
25342
|
|
|
24630
25343
|
// src/train-status.ts
|
|
24631
25344
|
function buildTrainStatusReport(input) {
|
|
@@ -24665,7 +25378,7 @@ function formatTrainStatus(r) {
|
|
|
24665
25378
|
// src/train-commands.ts
|
|
24666
25379
|
function readRepoVersion() {
|
|
24667
25380
|
try {
|
|
24668
|
-
return JSON.parse((0, import_node_fs30.readFileSync)((0,
|
|
25381
|
+
return JSON.parse((0, import_node_fs30.readFileSync)((0, import_node_path29.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
24669
25382
|
} catch {
|
|
24670
25383
|
return void 0;
|
|
24671
25384
|
}
|
|
@@ -24812,8 +25525,8 @@ function registerDeployCommands(program3) {
|
|
|
24812
25525
|
|
|
24813
25526
|
// src/discovery-commands.ts
|
|
24814
25527
|
var import_node_fs31 = require("node:fs");
|
|
24815
|
-
var
|
|
24816
|
-
var
|
|
25528
|
+
var import_node_os11 = require("node:os");
|
|
25529
|
+
var import_node_path30 = require("node:path");
|
|
24817
25530
|
var GC_GH_TIMEOUT_MS3 = 2e4;
|
|
24818
25531
|
async function collectStatus() {
|
|
24819
25532
|
let branch = "";
|
|
@@ -24988,10 +25701,10 @@ async function collectOnboardStatus() {
|
|
|
24988
25701
|
else if (top) nextCommand = `mmi-cli board claim ${top.number} # ${top.title}`;
|
|
24989
25702
|
else nextCommand = "mmi-cli board read \u2014 no claimable items found";
|
|
24990
25703
|
}
|
|
24991
|
-
const home = (0,
|
|
25704
|
+
const home = (0, import_node_os11.homedir)();
|
|
24992
25705
|
const plugin = onboardPluginGate({
|
|
24993
|
-
readKnown: () => readFileSyncSafe((0,
|
|
24994
|
-
readSettings: () => readFileSyncSafe((0,
|
|
25706
|
+
readKnown: () => readFileSyncSafe((0, import_node_path30.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs31.readFileSync),
|
|
25707
|
+
readSettings: () => readFileSyncSafe((0, import_node_path30.join)(home, ".claude", "settings.json"), import_node_fs31.readFileSync)
|
|
24995
25708
|
});
|
|
24996
25709
|
return { track, board, registry: registry2, secrets, plugin, nextCommand };
|
|
24997
25710
|
}
|
|
@@ -26564,8 +27277,8 @@ function ghAccountCaveat(announcedLogin, accounts) {
|
|
|
26564
27277
|
|
|
26565
27278
|
// src/doctor-io.ts
|
|
26566
27279
|
var import_node_fs32 = require("node:fs");
|
|
26567
|
-
var
|
|
26568
|
-
var
|
|
27280
|
+
var import_node_os12 = require("node:os");
|
|
27281
|
+
var import_node_path31 = require("node:path");
|
|
26569
27282
|
var import_node_child_process14 = require("node:child_process");
|
|
26570
27283
|
var import_node_util8 = require("node:util");
|
|
26571
27284
|
var execFileP6 = (0, import_node_util8.promisify)(import_node_child_process14.execFile);
|
|
@@ -26573,7 +27286,7 @@ var MMI_PLUGIN_ID2 = "mmi@mutmutco";
|
|
|
26573
27286
|
function installedClaudePluginVersion() {
|
|
26574
27287
|
try {
|
|
26575
27288
|
const file = JSON.parse(
|
|
26576
|
-
(0, import_node_fs32.readFileSync)((0,
|
|
27289
|
+
(0, import_node_fs32.readFileSync)((0, import_node_path31.join)((0, import_node_os12.homedir)(), ".claude", "plugins", "installed_plugins.json"), "utf8")
|
|
26577
27290
|
);
|
|
26578
27291
|
const versions = (file.plugins?.[MMI_PLUGIN_ID2] ?? []).map((r) => r.version).filter((v) => Boolean(v));
|
|
26579
27292
|
if (versions.length === 0) return void 0;
|
|
@@ -26613,7 +27326,7 @@ function worktreeRootSync() {
|
|
|
26613
27326
|
}
|
|
26614
27327
|
var gitignorePath = () => {
|
|
26615
27328
|
const root = worktreeRootSync();
|
|
26616
|
-
return root === null ? null : (0,
|
|
27329
|
+
return root === null ? null : (0, import_node_path31.join)(root, ".gitignore");
|
|
26617
27330
|
};
|
|
26618
27331
|
function readGitignore() {
|
|
26619
27332
|
const path2 = gitignorePath();
|
|
@@ -26652,7 +27365,7 @@ async function repoRoot() {
|
|
|
26652
27365
|
}
|
|
26653
27366
|
function hasRepoLocalWorktrees() {
|
|
26654
27367
|
const root = worktreeRootSync();
|
|
26655
|
-
return root !== null && (0, import_node_fs32.existsSync)((0,
|
|
27368
|
+
return root !== null && (0, import_node_fs32.existsSync)((0, import_node_path31.join)(root, ".worktrees"));
|
|
26656
27369
|
}
|
|
26657
27370
|
|
|
26658
27371
|
// src/index.ts
|
|
@@ -26667,7 +27380,8 @@ async function readDocsAuditFetch(repo) {
|
|
|
26667
27380
|
const list = await fetchDocsAuditList(registryClientDeps(await loadConfig()));
|
|
26668
27381
|
if ("notArmed" in list) return { notArmed: true };
|
|
26669
27382
|
if (!list.ok) return { ok: false, error: list.error };
|
|
26670
|
-
const
|
|
27383
|
+
const wanted = repo.toLowerCase();
|
|
27384
|
+
const row = list.rows.find((r) => String(r.repo ?? "").toLowerCase() === wanted);
|
|
26671
27385
|
return {
|
|
26672
27386
|
ok: true,
|
|
26673
27387
|
verdict: row ? { repo: row.repo, date: row.date, shaRange: row.shaRange, outcome: row.outcome, checkerVendor: row.checkerVendor } : null
|
|
@@ -26696,12 +27410,12 @@ function ghMultiAccountCaveat(announcedLogin) {
|
|
|
26696
27410
|
var ENV_HEAL_LOCK_STALE_MS = 10 * 6e4;
|
|
26697
27411
|
var ENV_HEAL_LOCK_MAX_WAIT_MS = 2 * 6e4;
|
|
26698
27412
|
function envHealLockPath(home) {
|
|
26699
|
-
return (0,
|
|
27413
|
+
return (0, import_node_path32.join)(home, ".claude", "plugins", ".mmi-env-heal.lock");
|
|
26700
27414
|
}
|
|
26701
27415
|
async function withEnvHealLock(what, run) {
|
|
26702
27416
|
try {
|
|
26703
27417
|
return await withFileLock(
|
|
26704
|
-
envHealLockPath((0,
|
|
27418
|
+
envHealLockPath((0, import_node_os13.homedir)()),
|
|
26705
27419
|
{ staleMs: ENV_HEAL_LOCK_STALE_MS, maxWaitMs: ENV_HEAL_LOCK_MAX_WAIT_MS, label: "mmi env-heal lock" },
|
|
26706
27420
|
run
|
|
26707
27421
|
);
|
|
@@ -26804,7 +27518,7 @@ function mmiDoctorDeps(opts = {}) {
|
|
|
26804
27518
|
const configRoot = surfaceConfigRoot(surface);
|
|
26805
27519
|
const running = surface === "codex" ? installedActivePluginVersion(surface) : runningPluginVersion(process.env, resolveClientVersion());
|
|
26806
27520
|
const plan = buildPluginCachePlan(
|
|
26807
|
-
(0,
|
|
27521
|
+
(0, import_node_os13.homedir)(),
|
|
26808
27522
|
running,
|
|
26809
27523
|
pluginCacheFsDeps(configRoot, () => 0),
|
|
26810
27524
|
{ configRoot, includeStaging: surface !== "codex" }
|
|
@@ -26857,11 +27571,11 @@ function mmiDoctorDeps(opts = {}) {
|
|
|
26857
27571
|
marketplaceRows: () => {
|
|
26858
27572
|
try {
|
|
26859
27573
|
if (detectSurface(process.env) === "codex") return [];
|
|
26860
|
-
const home = (0,
|
|
27574
|
+
const home = (0, import_node_os13.homedir)();
|
|
26861
27575
|
return marketplaceRows(
|
|
26862
27576
|
MMI_MARKETPLACE_NAME,
|
|
26863
|
-
readFileSyncSafe((0,
|
|
26864
|
-
readFileSyncSafe((0,
|
|
27577
|
+
readFileSyncSafe((0, import_node_path32.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), import_node_fs33.readFileSync),
|
|
27578
|
+
readFileSyncSafe((0, import_node_path32.join)(home, ".claude", "settings.json"), import_node_fs33.readFileSync)
|
|
26865
27579
|
);
|
|
26866
27580
|
} catch {
|
|
26867
27581
|
return [];
|
|
@@ -27067,7 +27781,7 @@ program2.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
|
27067
27781
|
});
|
|
27068
27782
|
var rules = program2.command("rules").description("org-managed .gitignore delivery");
|
|
27069
27783
|
rules.command("gitignore").option("--write", "upsert the managed block into .gitignore (default: check only, non-zero exit on drift)").option("--json", "machine-readable output").description("verify (or --write) this repo's org-managed .gitignore block matches the SSOT").action((opts) => {
|
|
27070
|
-
const path2 = (0,
|
|
27784
|
+
const path2 = (0, import_node_path32.join)(process.cwd(), ".gitignore");
|
|
27071
27785
|
const current = (0, import_node_fs33.existsSync)(path2) ? (0, import_node_fs33.readFileSync)(path2, "utf8") : null;
|
|
27072
27786
|
const plan = planManagedGitignore(current);
|
|
27073
27787
|
const drift = [...plan.added.map((l) => `+${l}`), ...plan.removed.map((l) => `-${l}`)].join(", ") || "block normalize";
|
|
@@ -27234,7 +27948,7 @@ gcCmd.option("--dry-run", "show what would be deleted (default)").option("--appl
|
|
|
27234
27948
|
if (!Number.isFinite(limit) || limit < 1) return fail("worktree gc: --limit must be a positive integer");
|
|
27235
27949
|
let root;
|
|
27236
27950
|
if (o.root !== void 0) {
|
|
27237
|
-
root = (0,
|
|
27951
|
+
root = (0, import_node_path32.resolve)(o.root);
|
|
27238
27952
|
if (!(0, import_node_fs33.existsSync)(root) || !(0, import_node_fs33.statSync)(root).isDirectory()) return fail(`worktree gc: --root ${o.root} is not a directory`);
|
|
27239
27953
|
const gcRepoRoot = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
|
|
27240
27954
|
if (isPathUnderDirectory(gcRepoRoot, root)) {
|
|
@@ -27335,7 +28049,7 @@ function acquireWorktreeSetupLock(worktreeRoot) {
|
|
|
27335
28049
|
};
|
|
27336
28050
|
};
|
|
27337
28051
|
try {
|
|
27338
|
-
(0, import_node_fs33.mkdirSync)((0,
|
|
28052
|
+
(0, import_node_fs33.mkdirSync)((0, import_node_path32.dirname)(lockPath), { recursive: true });
|
|
27339
28053
|
return take();
|
|
27340
28054
|
} catch {
|
|
27341
28055
|
try {
|
|
@@ -27376,11 +28090,25 @@ withExamples(mutating(
|
|
|
27376
28090
|
}
|
|
27377
28091
|
const repoRoot2 = await primaryCheckoutRoot(process.cwd()) ?? process.cwd();
|
|
27378
28092
|
const wtPath = o.path ?? defaultWorktreePath(repoRoot2, branch);
|
|
27379
|
-
const { base, fetchBranch } = resolveWorktreeBase(fromRef, o.remote);
|
|
28093
|
+
const { base: fallbackBase, fetchBranch, preferRemote } = resolveWorktreeBase(fromRef, o.remote);
|
|
28094
|
+
let base = fallbackBase;
|
|
27380
28095
|
step = `fetch the base ref ${fromRef}`;
|
|
27381
28096
|
if (fetchBranch) {
|
|
27382
28097
|
const fetchErr = await execFileP2("git", ["fetch", o.remote, fetchBranch], { timeout: GH_MUTATION_TIMEOUT_MS }).then(() => void 0).catch((e) => (e instanceof Error ? e.message : String(e)).split("\n")[0]);
|
|
27383
|
-
if (fetchErr) console.error(` warning: could not fetch ${o.remote}/${fetchBranch} (${fetchErr}); base ${base} may be stale`);
|
|
28098
|
+
if (fetchErr && !preferRemote) console.error(` warning: could not fetch ${o.remote}/${fetchBranch} (${fetchErr}); base ${base} may be stale`);
|
|
28099
|
+
}
|
|
28100
|
+
const revParseRef = async (ref) => {
|
|
28101
|
+
try {
|
|
28102
|
+
return (await execFileP2("git", ["rev-parse", "--verify", ref], { timeout: GIT_TIMEOUT_MS })).stdout.trim() || void 0;
|
|
28103
|
+
} catch {
|
|
28104
|
+
return void 0;
|
|
28105
|
+
}
|
|
28106
|
+
};
|
|
28107
|
+
if (preferRemote && await revParseRef(preferRemote)) base = preferRemote;
|
|
28108
|
+
if (!o.json) {
|
|
28109
|
+
const baseSha = (await revParseRef(base))?.slice(0, 12) ?? "unresolved";
|
|
28110
|
+
const localOnly = preferRemote && base !== preferRemote ? ` (no ${preferRemote} \u2014 local ref)` : "";
|
|
28111
|
+
console.error(` base ${base} ${baseSha}${localOnly}`);
|
|
27384
28112
|
}
|
|
27385
28113
|
step = `git worktree add ${wtPath}`;
|
|
27386
28114
|
await addWorktreeRobust(wtPath, branch, base, {
|
|
@@ -27642,12 +28370,27 @@ docsAudit.command("record").description("write a dated janitor verdict for one r
|
|
|
27642
28370
|
await failGraceful(e.message);
|
|
27643
28371
|
}
|
|
27644
28372
|
});
|
|
27645
|
-
docsAudit.command("status").description("read the janitor dead-man verdict back for one repo \u2014 missing/stale/failed is RED; a not-yet-armed registry route is an informational exit 0").option("--repo <owner/name>", "the repo to check (default: the current repo)").action(async (o) => {
|
|
28373
|
+
docsAudit.command("status").description("read the janitor dead-man verdict back for one repo \u2014 missing/stale/failed is RED; a not-yet-armed registry route is an informational exit 0").option("--repo <owner/name>", "the repo to check (default: the current repo)").option("--json", "machine-readable output \u2014 the discrete state rather than the sentence").action(async (o) => {
|
|
27646
28374
|
try {
|
|
27647
28375
|
const repo = o.repo ?? await currentRepoFullName();
|
|
27648
28376
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
27649
|
-
const
|
|
27650
|
-
|
|
28377
|
+
const fetched = await readDocsAuditFetch(repo);
|
|
28378
|
+
const result = docsAuditStatus(fetched, { repo, today });
|
|
28379
|
+
if (o.json) {
|
|
28380
|
+
const verdict = "ok" in fetched && fetched.ok ? fetched.verdict : null;
|
|
28381
|
+
console.log(JSON.stringify({
|
|
28382
|
+
repo,
|
|
28383
|
+
armed: !("notArmed" in fetched),
|
|
28384
|
+
ok: result.ok,
|
|
28385
|
+
state: result.state,
|
|
28386
|
+
date: verdict?.date ?? null,
|
|
28387
|
+
outcome: verdict?.outcome ?? null,
|
|
28388
|
+
checkerVendor: verdict?.checkerVendor ?? null,
|
|
28389
|
+
line: result.line
|
|
28390
|
+
}, null, 2));
|
|
28391
|
+
} else {
|
|
28392
|
+
console.log(result.line);
|
|
28393
|
+
}
|
|
27651
28394
|
if (!result.ok) process.exitCode = 1;
|
|
27652
28395
|
} catch (e) {
|
|
27653
28396
|
await failGraceful(e.message);
|
|
@@ -27663,17 +28406,25 @@ async function reportWrite(label, res) {
|
|
|
27663
28406
|
return failGraceful(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
27664
28407
|
}
|
|
27665
28408
|
var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
|
|
27666
|
-
tenant.command("control <owner/repo> <stage> <action>").description("bounded tenant control plus value-free vault/broker verification; project-admin own dev/rc, master main").option("--watch", "block on the dispatched run and report its conclusion (status/retire/verify-secrets/verify-broker watch by default)").option("--json", "machine-readable output").action(async (repo, stage, action, o) => {
|
|
28409
|
+
tenant.command("control <owner/repo> <stage> <action>").description("bounded tenant control plus value-free vault/broker verification; project-admin own dev/rc, master main").option("--watch", "block on the dispatched run and report its conclusion (status/retire/verify-secrets/verify-broker/logs watch by default)").option("--lines <n>", "logs only: trailing lines of the tenant service to return (1-2000, default 200)").option("--json", "machine-readable output").action(async (repo, stage, action, o) => {
|
|
27667
28410
|
try {
|
|
27668
|
-
|
|
28411
|
+
let lines;
|
|
28412
|
+
if (o.lines !== void 0) {
|
|
28413
|
+
if (action !== "logs") return fail("runtime tenant control: --lines is valid only for the logs action");
|
|
28414
|
+
lines = Number(o.lines);
|
|
28415
|
+
if (!Number.isInteger(lines) || lines < 1 || lines > 2e3) return fail("runtime tenant control: --lines must be an integer between 1 and 2000");
|
|
28416
|
+
}
|
|
28417
|
+
const result = await runTenantControl(trainApplyDeps(), { repo, stage, action, watch: o.watch, lines });
|
|
27669
28418
|
if (!o.json && action === "verify-secrets" && result.secrets) {
|
|
27670
28419
|
const body = { ok: result.conclusion === "success", secrets: result.secrets, ssmStatus: result.conclusion === "success" ? "Success" : "Failed", raw: result.secretsRaw };
|
|
27671
|
-
const { lines, failure } = renderVerifySecrets(body);
|
|
27672
|
-
for (const line of
|
|
28420
|
+
const { lines: lines2, failure } = renderVerifySecrets(body);
|
|
28421
|
+
for (const line of lines2) printLine(line);
|
|
27673
28422
|
if (failure) return failGraceful(`runtime tenant control ${stage} verify-secrets: ${failure}`);
|
|
28423
|
+
} else if (!o.json && action === "logs" && result.logs) {
|
|
28424
|
+
printLine(result.logs);
|
|
27674
28425
|
} else if (!o.json && action === "verify-broker" && result.broker) {
|
|
27675
|
-
const { lines, failure } = renderVerifyBroker({ broker: result.broker, raw: result.brokerRaw });
|
|
27676
|
-
for (const line of
|
|
28426
|
+
const { lines: lines2, failure } = renderVerifyBroker({ broker: result.broker, raw: result.brokerRaw });
|
|
28427
|
+
for (const line of lines2) printLine(line);
|
|
27677
28428
|
if (failure) return failGraceful(`runtime tenant control ${stage} verify-broker: ${failure}`);
|
|
27678
28429
|
} else {
|
|
27679
28430
|
printLine(o.json ? JSON.stringify(result, null, 2) : renderTenantControl(result));
|
|
@@ -28138,16 +28889,43 @@ function resolveCreateType(raw, command, labels) {
|
|
|
28138
28889
|
}
|
|
28139
28890
|
return raw;
|
|
28140
28891
|
}
|
|
28892
|
+
function resolveCreateSurface(opts) {
|
|
28893
|
+
return typeof opts.surface === "string" && opts.surface.trim() ? surfaceLabel(opts.surface) : void 0;
|
|
28894
|
+
}
|
|
28895
|
+
function surfaceWaived() {
|
|
28896
|
+
return rawFlag("--no-surface");
|
|
28897
|
+
}
|
|
28141
28898
|
var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
|
|
28142
28899
|
withExamples(mutating(
|
|
28143
|
-
issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").choices([...ISSUE_TYPES])).option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks or newlines needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment"),
|
|
28900
|
+
issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").choices([...ISSUE_TYPES])).option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks or newlines needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--surface <surface>", "issue surface, with or without the surface: prefix (#3789). Required when the target repo runs the one-surface-label board rule; any value satisfies it, so this is not a closed enum").option("--no-surface", "file without a surface label on a repo that requires one \u2014 for a genuinely exempt filing (e.g. a coop proof issue that spans every surface)").option("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment"),
|
|
28144
28901
|
// --dry-run/--validate-only plan: validate --type + --priority (mirrors the action — a bad enum fails
|
|
28145
28902
|
// ERR_BAD_ENUM, a missing priority defaults to medium) then echo the resolved create intent. Refs
|
|
28146
28903
|
// (`--parent`) and title-source are validated by the action on a real run.
|
|
28147
|
-
(opts) => {
|
|
28904
|
+
async (opts) => {
|
|
28148
28905
|
const type = resolveCreateType(opts.type, "issue create", opts.label);
|
|
28149
28906
|
const priority = resolveCreatePriority(opts.priority, "issue create");
|
|
28150
|
-
|
|
28907
|
+
const planLabels = opts.label ?? [];
|
|
28908
|
+
const clash = conflictingSurfaceInputs(opts.surface, planLabels);
|
|
28909
|
+
if (clash) fail(clash.message, clash.payload);
|
|
28910
|
+
const planRepo = opts.batch || surfaceWaived() ? void 0 : await resolveRepo(opts.repo);
|
|
28911
|
+
const surface = resolveCreateSurface(opts);
|
|
28912
|
+
if (planRepo) {
|
|
28913
|
+
const { refusal, warn } = await checkSurfaceRequirement({
|
|
28914
|
+
repo: planRepo,
|
|
28915
|
+
labels: [...planLabels, ...surface ? [surface] : []]
|
|
28916
|
+
});
|
|
28917
|
+
if (warn) process.stderr.write(`${warn}
|
|
28918
|
+
`);
|
|
28919
|
+
if (refusal) fail(refusal.message, refusal.payload);
|
|
28920
|
+
}
|
|
28921
|
+
return {
|
|
28922
|
+
command: "issue create",
|
|
28923
|
+
type,
|
|
28924
|
+
title: opts.title ?? opts.titleFile,
|
|
28925
|
+
priority,
|
|
28926
|
+
repo: opts.repo,
|
|
28927
|
+
...surface ? { surface } : {}
|
|
28928
|
+
};
|
|
28151
28929
|
}
|
|
28152
28930
|
).action(async (o) => {
|
|
28153
28931
|
let args;
|
|
@@ -28157,6 +28935,7 @@ withExamples(mutating(
|
|
|
28157
28935
|
let issueType;
|
|
28158
28936
|
let extraLabels = [];
|
|
28159
28937
|
let targetRepo2;
|
|
28938
|
+
let surfaceFlagLabel;
|
|
28160
28939
|
try {
|
|
28161
28940
|
issueType = resolveCreateType(o.type, "issue create", o.label);
|
|
28162
28941
|
title = await resolveIssueTitle({ title: o.title, titleFile: o.titleFile }, { readFile: import_promises10.readFile, readStdin });
|
|
@@ -28164,6 +28943,13 @@ withExamples(mutating(
|
|
|
28164
28943
|
if (o.idempotencyKey) body = appendIdempotencyMarker(body, o.idempotencyKey);
|
|
28165
28944
|
priority = resolveCreatePriority(o.priority, "issue create");
|
|
28166
28945
|
extraLabels = [...o.label ?? []];
|
|
28946
|
+
const clash = conflictingSurfaceInputs(typeof o.surface === "string" ? o.surface : void 0, extraLabels);
|
|
28947
|
+
if (clash) return fail(clash.message, clash.payload);
|
|
28948
|
+
const surfaceFromFlag = resolveCreateSurface(o);
|
|
28949
|
+
if (surfaceFromFlag && !labelsCarrySurface(extraLabels)) {
|
|
28950
|
+
extraLabels.push(surfaceFromFlag);
|
|
28951
|
+
surfaceFlagLabel = surfaceFromFlag;
|
|
28952
|
+
}
|
|
28167
28953
|
targetRepo2 = await resolveRepo(o.repo);
|
|
28168
28954
|
if (!targetRepo2) {
|
|
28169
28955
|
return fail("issue create: could not resolve the target repo \u2014 run inside a git checkout or pass --repo <owner/repo>");
|
|
@@ -28180,6 +28966,27 @@ withExamples(mutating(
|
|
|
28180
28966
|
} catch (e) {
|
|
28181
28967
|
return fail(`issue create: ${e.message}`, e instanceof TextArgError ? { code: e.code, offending_flag: e.offendingFlag } : void 0);
|
|
28182
28968
|
}
|
|
28969
|
+
{
|
|
28970
|
+
const { refusal, warn, enforcing } = await checkSurfaceRequirement({ repo: targetRepo2, labels: extraLabels });
|
|
28971
|
+
if (warn) process.stderr.write(`${warn}
|
|
28972
|
+
`);
|
|
28973
|
+
if (!enforcing && surfaceFlagLabel) {
|
|
28974
|
+
extraLabels = extraLabels.filter((l) => l !== surfaceFlagLabel);
|
|
28975
|
+
args = buildIssueArgs({
|
|
28976
|
+
type: issueType,
|
|
28977
|
+
title,
|
|
28978
|
+
body,
|
|
28979
|
+
priority,
|
|
28980
|
+
repo: targetRepo2,
|
|
28981
|
+
labels: extraLabels.length ? extraLabels : void 0
|
|
28982
|
+
});
|
|
28983
|
+
process.stderr.write(
|
|
28984
|
+
`warning: --surface ${surfaceFlagLabel} was dropped \u2014 ${targetRepo2} defines no surface:* labels, and creating one here would switch the one-surface-label rule on for every later filing in it. Use --label ${surfaceFlagLabel} if you really mean to start that taxonomy.
|
|
28985
|
+
`
|
|
28986
|
+
);
|
|
28987
|
+
}
|
|
28988
|
+
if (refusal && !surfaceWaived()) return fail(refusal.message, refusal.payload);
|
|
28989
|
+
}
|
|
28183
28990
|
await ensureLabelsExist(extraLabels, targetRepo2);
|
|
28184
28991
|
const created = await ghCreate(args);
|
|
28185
28992
|
const { projectItemId, onBoard } = await attachToProject(created.number, targetRepo2, priority);
|
|
@@ -28289,6 +29096,15 @@ jsonParity(issue.command("link-child <parent> <child>").description("link an exi
|
|
|
28289
29096
|
const result = await linkSubIssue(ghRunner2, parentRef, childRef, defaultRepo);
|
|
28290
29097
|
console.log(JSON.stringify(result));
|
|
28291
29098
|
} catch (e) {
|
|
29099
|
+
let conflict;
|
|
29100
|
+
try {
|
|
29101
|
+
const child2 = parseIssueRef(childRef);
|
|
29102
|
+
const childRepo = child2.repo ?? defaultRepo;
|
|
29103
|
+
if (childRepo) conflict = await classifyReparentFailure(e, ghRunner2, childRepo, child2.number, parentRef);
|
|
29104
|
+
} catch {
|
|
29105
|
+
conflict = void 0;
|
|
29106
|
+
}
|
|
29107
|
+
if (conflict) return fail(`issue link-child: ${conflict.message}`, conflict.payload);
|
|
28292
29108
|
const err = e;
|
|
28293
29109
|
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
28294
29110
|
return fail(`issue link-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
|
|
@@ -28380,6 +29196,9 @@ program2.command("report").description("file a friction report on the Hub board
|
|
|
28380
29196
|
}
|
|
28381
29197
|
const cfg = await loadConfig();
|
|
28382
29198
|
if (!cfg.sagaApiUrl) return fail("report: Hub API URL not configured");
|
|
29199
|
+
const { warn: surfaceWarn } = await preflightReportSurface();
|
|
29200
|
+
if (surfaceWarn) process.stderr.write(`${surfaceWarn}
|
|
29201
|
+
`);
|
|
28383
29202
|
const result = await fileReport(
|
|
28384
29203
|
{
|
|
28385
29204
|
apiUrl: cfg.sagaApiUrl,
|
|
@@ -28452,6 +29271,14 @@ program2.command("skill-lesson").description("file a skill-lesson on the Hub boa
|
|
|
28452
29271
|
return console.log(JSON.stringify({ deduped: true, number: dup.number, url: dup.url, score: dup.score }));
|
|
28453
29272
|
}
|
|
28454
29273
|
}
|
|
29274
|
+
const { warn: surfaceWarn } = await checkSurfaceRequirement({
|
|
29275
|
+
repo: targetRepo2,
|
|
29276
|
+
labels: [SKILL_LESSON_LABEL],
|
|
29277
|
+
command: "skill-lesson",
|
|
29278
|
+
waiver: { reason: "tooling lesson spans product surfaces \u2014 coop-proof class (#3789)" }
|
|
29279
|
+
});
|
|
29280
|
+
if (surfaceWarn) process.stderr.write(`${surfaceWarn}
|
|
29281
|
+
`);
|
|
28455
29282
|
try {
|
|
28456
29283
|
await execFileP2("gh", ["label", "create", SKILL_LESSON_LABEL, "--color", "c2e0c6", "--repo", targetRepo2], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
28457
29284
|
} catch {
|
|
@@ -28501,11 +29328,11 @@ pr.command("view <number>").description("read a PR as structured JSON (merged st
|
|
|
28501
29328
|
}
|
|
28502
29329
|
});
|
|
28503
29330
|
async function listCiWorkflowPaths(cwd = process.cwd()) {
|
|
28504
|
-
const wfDir = (0,
|
|
29331
|
+
const wfDir = (0, import_node_path32.join)(cwd, ".github", "workflows");
|
|
28505
29332
|
if (!(0, import_node_fs33.existsSync)(wfDir)) return [];
|
|
28506
29333
|
return (0, import_node_fs33.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).filter((name) => {
|
|
28507
29334
|
try {
|
|
28508
|
-
return workflowReportsPrChecks((0, import_node_fs33.readFileSync)((0,
|
|
29335
|
+
return workflowReportsPrChecks((0, import_node_fs33.readFileSync)((0, import_node_path32.join)(wfDir, name), "utf8"));
|
|
28509
29336
|
} catch {
|
|
28510
29337
|
return true;
|
|
28511
29338
|
}
|
|
@@ -28537,16 +29364,16 @@ function ciAuditDeps() {
|
|
|
28537
29364
|
// gate re-seed step is skipped gracefully rather than failing mid-run.
|
|
28538
29365
|
readSeedFile: (path2) => {
|
|
28539
29366
|
if (!root) return null;
|
|
28540
|
-
const fullPath = (0,
|
|
29367
|
+
const fullPath = (0, import_node_path32.join)(root, path2);
|
|
28541
29368
|
return (0, import_node_fs33.existsSync)(fullPath) ? (0, import_node_fs33.readFileSync)(fullPath, "utf8") : null;
|
|
28542
29369
|
}
|
|
28543
29370
|
};
|
|
28544
29371
|
}
|
|
28545
29372
|
function hubRoot() {
|
|
28546
|
-
const fromPkg = (0,
|
|
29373
|
+
const fromPkg = (0, import_node_path32.join)(__dirname, "..", "..");
|
|
28547
29374
|
const marker = "skills/bootstrap/seeds/manifest.json";
|
|
28548
|
-
if ((0, import_node_fs33.existsSync)((0,
|
|
28549
|
-
if ((0, import_node_fs33.existsSync)((0,
|
|
29375
|
+
if ((0, import_node_fs33.existsSync)((0, import_node_path32.join)(fromPkg, marker))) return fromPkg;
|
|
29376
|
+
if ((0, import_node_fs33.existsSync)((0, import_node_path32.join)(process.cwd(), marker))) return process.cwd();
|
|
28550
29377
|
return null;
|
|
28551
29378
|
}
|
|
28552
29379
|
pr.command("ci-policy").description("report merge CI policy: wait-for-checks vs no-ci (for grind/build agents)").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current checkout)").action(async (o) => {
|
|
@@ -28934,8 +29761,8 @@ function trainApplyDeps() {
|
|
|
28934
29761
|
// Hub-App-authority dispatch of the central tenant-control.yml (#1717) — the Hub fires the
|
|
28935
29762
|
// workflow_dispatch with its App token. Never throws for an expected rejection: it returns the dispatch
|
|
28936
29763
|
// outcome so runTenantControl can map a 5xx (transport-failed, retryable) vs a 4xx (rejected) vs ok.
|
|
28937
|
-
dispatchTenantControl: async ({ repo, stage, action }) => {
|
|
28938
|
-
const res = await tenantControl({ repo, stage, action }, registryClientDeps(await loadConfig()));
|
|
29764
|
+
dispatchTenantControl: async ({ repo, stage, action, lines }) => {
|
|
29765
|
+
const res = await tenantControl({ repo, stage, action, ...lines != null ? { lines } : {} }, registryClientDeps(await loadConfig()));
|
|
28939
29766
|
if (res.ok) return { ok: true };
|
|
28940
29767
|
const body = res.body;
|
|
28941
29768
|
return { ok: false, category: body?.category, error: body?.error ?? res.error };
|
|
@@ -28983,9 +29810,19 @@ function renderDeployLine(d) {
|
|
|
28983
29810
|
if (d.deployStatus === "success") parts.push("deploy: SUCCEEDED");
|
|
28984
29811
|
else if (d.deployStatus === "failure") parts.push("deploy: FAILED (promotion stands; retry the deploy, do not re-tag)");
|
|
28985
29812
|
else if (d.runId != null) parts.push(`deploy: UNVERIFIED \u2014 dispatched, not resolved (watch: gh run watch ${d.runId} --repo mutmutco/MMI-Hub --exit-status)`);
|
|
29813
|
+
else if (d.workflowRuns?.length) parts.push("deploy: UNVERIFIED \u2014 the runs above are enumerated, not resolved; watch each to conclusion before calling this release healthy (#3322)");
|
|
28986
29814
|
else parts.push("deploy: UNVERIFIED \u2014 no run correlated or watched; resolve every workflow run on the release SHA before calling this release healthy (#3322)");
|
|
28987
29815
|
return parts.join("; ");
|
|
28988
29816
|
}
|
|
29817
|
+
function renderReleaseResume(r) {
|
|
29818
|
+
const lines = [`mmi-cli release --resume: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) [${r.deployModel}] \u2014 ${r.note}`];
|
|
29819
|
+
for (const step of r.steps) lines.push(` - ${step}`);
|
|
29820
|
+
if (r.releaseUrl) lines.push(` release: ${r.releaseUrl}`);
|
|
29821
|
+
if (r.announceNote) lines.push(` announce: ${r.announceNote}`);
|
|
29822
|
+
if (r.dispatch) lines.push(` deploy: ${r.dispatch.note}`);
|
|
29823
|
+
if (r.devRollForward) lines.push(` development: ${r.devRollForward.note}`);
|
|
29824
|
+
return lines.join("\n");
|
|
29825
|
+
}
|
|
28989
29826
|
function renderTrainApply(commandName, r) {
|
|
28990
29827
|
let base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
28991
29828
|
if (r.versionFold) base = `${base}; ${r.versionFold}`;
|
|
@@ -29023,7 +29860,12 @@ for (const commandName of ["rcand", "release"]) {
|
|
|
29023
29860
|
const RELEASE_ONLY_FLAGS = [
|
|
29024
29861
|
{ flags: "--announce-summary-file <path>", description: "agent-curated summary lines for the Hub Slack announcement (#883)" },
|
|
29025
29862
|
{ flags: "--ack <shas>", description: "comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)" },
|
|
29026
|
-
{ flags: "--dev", description: "full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)" }
|
|
29863
|
+
{ flags: "--dev", description: "full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)" },
|
|
29864
|
+
// #3851: finish a release that pushed its tag and then stopped. NOT a rerun — a rerun would cut the
|
|
29865
|
+
// NEXT version, because the cycle resolver advances past the pushed tag and MMI_RELEASE_VERSION
|
|
29866
|
+
// refuses a version that is not ahead of the latest. This is the only path allowed to target an
|
|
29867
|
+
// existing tag, and it proves the release is genuinely partial before writing anything.
|
|
29868
|
+
{ flags: "--resume", description: "finish a release whose tag was pushed but whose main push / Release / deploy never happened \u2014 preserves the original version, refuses unless the partial state is proven (#3851)" }
|
|
29027
29869
|
];
|
|
29028
29870
|
for (const f of RELEASE_ONLY_FLAGS) {
|
|
29029
29871
|
if (commandName === "release") {
|
|
@@ -29047,6 +29889,18 @@ for (const commandName of ["rcand", "release"]) {
|
|
|
29047
29889
|
if (o.announceSummaryFile && commandName !== "release") {
|
|
29048
29890
|
return fail(`${commandName}: --announce-summary-file applies only to release \u2014 rcand posts no Hub Slack announcement. Run: mmi-cli release --announce-summary-file <path>`);
|
|
29049
29891
|
}
|
|
29892
|
+
if (o.resume && commandName !== "release") {
|
|
29893
|
+
return fail(`${commandName}: --resume applies only to release \u2014 it finishes a partially-released main. Run: mmi-cli release --resume`);
|
|
29894
|
+
}
|
|
29895
|
+
if (o.resume) {
|
|
29896
|
+
if (o.apply) return fail("release: --resume and --apply are mutually exclusive \u2014 --apply cuts the NEXT version, --resume finishes the one whose tag is already pushed");
|
|
29897
|
+
try {
|
|
29898
|
+
const result = await runReleaseResume(trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile });
|
|
29899
|
+
return printLine(o.json ? JSON.stringify(result, null, 2) : renderReleaseResume(result));
|
|
29900
|
+
} catch (e) {
|
|
29901
|
+
return failGraceful(`release --resume: ${e.message}`);
|
|
29902
|
+
}
|
|
29903
|
+
}
|
|
29050
29904
|
if (o.apply && o.repo) {
|
|
29051
29905
|
const rerun = `mmi-cli ${commandName} --apply${o.watch ? " --watch" : ""}${o.dev ? " --dev" : ""}${o.json ? " --json" : ""}`;
|
|
29052
29906
|
const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), rerun);
|
|
@@ -29252,7 +30106,7 @@ function directoryBytes(path2) {
|
|
|
29252
30106
|
return 0;
|
|
29253
30107
|
}
|
|
29254
30108
|
for (const entry of entries) {
|
|
29255
|
-
const child2 = (0,
|
|
30109
|
+
const child2 = (0, import_node_path32.join)(path2, entry.name);
|
|
29256
30110
|
if (entry.isDirectory()) total += directoryBytes(child2);
|
|
29257
30111
|
else {
|
|
29258
30112
|
try {
|
|
@@ -29282,7 +30136,7 @@ function pluginCacheFsDeps(configRoot, dirBytes) {
|
|
|
29282
30136
|
dirBytes,
|
|
29283
30137
|
listStagingDirs: (root) => (0, import_node_fs33.readdirSync)(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
29284
30138
|
try {
|
|
29285
|
-
return { name: d.name, mtimeMs: newestMtimeMs((0,
|
|
30139
|
+
return { name: d.name, mtimeMs: newestMtimeMs((0, import_node_path32.join)(root, d.name), listDirEntries, (p) => (0, import_node_fs33.statSync)(p).mtimeMs) };
|
|
29286
30140
|
} catch {
|
|
29287
30141
|
return { name: d.name, mtimeMs: Date.now() };
|
|
29288
30142
|
}
|
|
@@ -29296,7 +30150,7 @@ function stagingApplyFsGuard(configRoot) {
|
|
|
29296
30150
|
return {
|
|
29297
30151
|
referencedPaths: () => readInstalledPluginRefs(configRoot),
|
|
29298
30152
|
mtimeMs: (name) => {
|
|
29299
|
-
const p = (0,
|
|
30153
|
+
const p = (0, import_node_path32.join)(stagingRoot, name);
|
|
29300
30154
|
if (!(0, import_node_fs33.existsSync)(p)) return null;
|
|
29301
30155
|
try {
|
|
29302
30156
|
return newestMtimeMs(p, listDirEntries, (q) => (0, import_node_fs33.statSync)(q).mtimeMs);
|
|
@@ -29319,7 +30173,7 @@ program2.command("plugin-prune").description(`prune stale cached MMI plugin vers
|
|
|
29319
30173
|
return;
|
|
29320
30174
|
}
|
|
29321
30175
|
const plan = buildPluginCachePlan(
|
|
29322
|
-
(0,
|
|
30176
|
+
(0, import_node_os13.homedir)(),
|
|
29323
30177
|
running,
|
|
29324
30178
|
pluginCacheFsDeps(configRoot, directoryBytes),
|
|
29325
30179
|
{ withBytes: true, configRoot, includeStaging: surface !== "codex" }
|