@mutmutco/cli 3.101.0 → 3.102.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 +119 -27
- package/package.json +1 -1
package/dist/main.cjs
CHANGED
|
@@ -7536,6 +7536,39 @@ function missingPlaceholders(rendered) {
|
|
|
7536
7536
|
for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
|
|
7537
7537
|
return [...out];
|
|
7538
7538
|
}
|
|
7539
|
+
async function resolveHubSeedSource(execGit, defaultBranch = "development") {
|
|
7540
|
+
let status;
|
|
7541
|
+
try {
|
|
7542
|
+
status = (await execGit(["status", "--porcelain"])).trim();
|
|
7543
|
+
} catch (e) {
|
|
7544
|
+
return { ok: false, reason: `could not read git status of this checkout (${e.message}) \u2014 refusing to treat an unknown working tree as the fleet's desired state` };
|
|
7545
|
+
}
|
|
7546
|
+
if (status) {
|
|
7547
|
+
return {
|
|
7548
|
+
ok: false,
|
|
7549
|
+
reason: `this checkout has uncommitted changes \u2014 refusing to read seeds from a working tree that has not been reviewed or merged:
|
|
7550
|
+
${status}`
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
let head;
|
|
7554
|
+
let remoteSha;
|
|
7555
|
+
try {
|
|
7556
|
+
head = (await execGit(["rev-parse", "HEAD"])).trim();
|
|
7557
|
+
remoteSha = (await execGit(["ls-remote", "origin", `refs/heads/${defaultBranch}`])).trim().split(/\s+/)[0] ?? "";
|
|
7558
|
+
} catch (e) {
|
|
7559
|
+
return { ok: false, reason: `could not resolve this checkout's HEAD against origin/${defaultBranch} (${e.message}) \u2014 refusing to read seeds without a known upstream revision` };
|
|
7560
|
+
}
|
|
7561
|
+
if (!remoteSha) {
|
|
7562
|
+
return { ok: false, reason: `\`git ls-remote origin refs/heads/${defaultBranch}\` returned nothing \u2014 refusing to read seeds without a known upstream revision` };
|
|
7563
|
+
}
|
|
7564
|
+
if (head !== remoteSha) {
|
|
7565
|
+
return {
|
|
7566
|
+
ok: false,
|
|
7567
|
+
reason: `this checkout (${head}) is not origin/${defaultBranch} (${remoteSha}) \u2014 fast-forward to origin/${defaultBranch} first; a stale or ahead checkout must not seed the fleet`
|
|
7568
|
+
};
|
|
7569
|
+
}
|
|
7570
|
+
return { ok: true, sha: head };
|
|
7571
|
+
}
|
|
7539
7572
|
var GITIGNORE_MANAGED_BEGIN = "# >>> mmi-managed >>>";
|
|
7540
7573
|
var GITIGNORE_MANAGED_END = "# <<< mmi-managed <<<";
|
|
7541
7574
|
var MANAGED_GITIGNORE_LINES = [
|
|
@@ -23943,6 +23976,16 @@ var import_node_crypto6 = require("node:crypto");
|
|
|
23943
23976
|
function byteComparableSeeds(manifest, cls) {
|
|
23944
23977
|
return manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self" && s.classes.includes(cls));
|
|
23945
23978
|
}
|
|
23979
|
+
function declaredRenderWaivers(manifest, repo) {
|
|
23980
|
+
const slug = repo.includes("/") ? repo.slice(repo.indexOf("/") + 1).toLowerCase() : repo.toLowerCase();
|
|
23981
|
+
const findings = [];
|
|
23982
|
+
for (const seed of manifest.seeds) {
|
|
23983
|
+
if (seed.ownership !== "org" || seed.source === "self") continue;
|
|
23984
|
+
const why = seed.waivers?.[slug];
|
|
23985
|
+
if (why) findings.push({ repo, target: seed.target, state: "waived", detail: `hand-authored, not rendered \u2014 waived: ${why}` });
|
|
23986
|
+
}
|
|
23987
|
+
return findings;
|
|
23988
|
+
}
|
|
23946
23989
|
function compareSeedBytes(hubContent, repoContent) {
|
|
23947
23990
|
if (repoContent === null) return "absent";
|
|
23948
23991
|
const normalize = (s) => s.replace(/\r\n/g, "\n");
|
|
@@ -24020,18 +24063,21 @@ function assignWaves(repos, canarySlug) {
|
|
|
24020
24063
|
rest.forEach((r, i) => waves.set(r.repo, i < wave1Count ? 1 : 2));
|
|
24021
24064
|
return waves;
|
|
24022
24065
|
}
|
|
24023
|
-
function statusFor(read) {
|
|
24066
|
+
function statusFor(read, currentSha) {
|
|
24024
24067
|
if (!read) return { status: "pending", record: {} };
|
|
24025
24068
|
if (read.drift === "match") return { status: "match", record: { mergeSha: read.pr?.mergeSha } };
|
|
24026
24069
|
const pr2 = read.pr;
|
|
24027
24070
|
if (!pr2) return { status: "pending", record: {} };
|
|
24028
24071
|
if (pr2.state === "closed") return { status: "closed-unmerged", record: { prNumber: pr2.number, prUrl: pr2.url } };
|
|
24072
|
+
if (pr2.state === "merged" && pr2.sourceSha && pr2.sourceSha !== currentSha) {
|
|
24073
|
+
return { status: "pending", record: {} };
|
|
24074
|
+
}
|
|
24029
24075
|
if (pr2.checks === "red") return { status: "red", record: { prNumber: pr2.number, prUrl: pr2.url } };
|
|
24030
24076
|
return { status: "open-pending", record: { prNumber: pr2.number, prUrl: pr2.url, mergeSha: pr2.mergeSha } };
|
|
24031
24077
|
}
|
|
24032
24078
|
var HALTING_STATUSES = /* @__PURE__ */ new Set(["red", "closed-unmerged"]);
|
|
24033
24079
|
function planPropagationTick(input) {
|
|
24034
|
-
const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied } = input;
|
|
24080
|
+
const { target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha } = input;
|
|
24035
24081
|
const readByRepo = new Map(reads.map((r) => [r.repo, r]));
|
|
24036
24082
|
const records = [];
|
|
24037
24083
|
const opened = [];
|
|
@@ -24065,7 +24111,7 @@ function planPropagationTick(input) {
|
|
|
24065
24111
|
let waveAllMatch = true;
|
|
24066
24112
|
let waveHasRed = false;
|
|
24067
24113
|
for (const r of waveRepos) {
|
|
24068
|
-
const { status, record } = statusFor(readByRepo.get(r.repo));
|
|
24114
|
+
const { status, record } = statusFor(readByRepo.get(r.repo), currentSha);
|
|
24069
24115
|
const shouldOpen = status === "pending";
|
|
24070
24116
|
if (shouldOpen) opened.push(r.repo);
|
|
24071
24117
|
records.push({
|
|
@@ -24807,6 +24853,7 @@ async function reconcileOrgNoAgentFilesRuleset(plan, client, org = ORG_LOGIN) {
|
|
|
24807
24853
|
}
|
|
24808
24854
|
|
|
24809
24855
|
// src/bootstrap-commands.ts
|
|
24856
|
+
var execGitForSeedSource = async (args) => (await execFileP2("git", args, { timeout: GIT_TIMEOUT_MS })).stdout;
|
|
24810
24857
|
function registerBootstrapCommands(program3) {
|
|
24811
24858
|
const bootstrap = program3.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--json", "machine-readable output").action((o) => {
|
|
24812
24859
|
if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
|
|
@@ -24886,6 +24933,8 @@ function registerBootstrapCommands(program3) {
|
|
|
24886
24933
|
const o = { repo: rawValue("--repo", ""), json: rawFlag("--json") };
|
|
24887
24934
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
24888
24935
|
if (!(0, import_node_fs31.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`);
|
|
24936
|
+
const seedSource = await resolveHubSeedSource(execGitForSeedSource);
|
|
24937
|
+
if (!seedSource.ok) return fail(`bootstrap drift: ${seedSource.reason}`);
|
|
24889
24938
|
const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
|
|
24890
24939
|
const hubContents = /* @__PURE__ */ new Map();
|
|
24891
24940
|
for (const s of manifest.seeds) {
|
|
@@ -24928,12 +24977,14 @@ function registerBootstrapCommands(program3) {
|
|
|
24928
24977
|
reads.push({ target: seed.target, content });
|
|
24929
24978
|
}
|
|
24930
24979
|
findings.push(...auditRepoSeedDrift(repo, seeds, hubContents, reads));
|
|
24980
|
+
findings.push(...declaredRenderWaivers(manifest, repo));
|
|
24931
24981
|
}
|
|
24932
24982
|
if (o.json) {
|
|
24933
24983
|
console.log(JSON.stringify({
|
|
24934
24984
|
// #3842: `ok` reflects real findings; waivers ride the payload so a consumer can see every
|
|
24935
24985
|
// standing exception without them counting as drift.
|
|
24936
24986
|
ok: findings.every((f) => f.state === "waived"),
|
|
24987
|
+
sourceSha: seedSource.sha,
|
|
24937
24988
|
scope: o.repo ? "single-repo" : "fleet",
|
|
24938
24989
|
reposAudited: targets.length,
|
|
24939
24990
|
seedsPerRepo,
|
|
@@ -24968,6 +25019,8 @@ function registerBootstrapCommands(program3) {
|
|
|
24968
25019
|
}
|
|
24969
25020
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
24970
25021
|
if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; bootstrap runs from the MMI-Hub repo root by design \u2014 it stamps org-level resources (Project, Ruleset, secrets, access) through the GitHub App, which is only authorized from the Hub checkout`);
|
|
25022
|
+
const seedSource = await resolveHubSeedSource(execGitForSeedSource);
|
|
25023
|
+
if (!seedSource.ok) return fail(`bootstrap apply: ${seedSource.reason}`);
|
|
24971
25024
|
const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
|
|
24972
25025
|
const baseBranch = o.class === "content" ? "main" : "development";
|
|
24973
25026
|
const slug = parsedRepo.slug;
|
|
@@ -25144,9 +25197,9 @@ function registerBootstrapCommands(program3) {
|
|
|
25144
25197
|
"--body",
|
|
25145
25198
|
onlyTarget ? `Auto-opened by \`mmi-cli bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
|
|
25146
25199
|
|
|
25147
|
-
\`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json
|
|
25200
|
+
\`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\` at MMI-Hub@${seedSource.sha}; 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.
|
|
25148
25201
|
|
|
25149
|
-
\`${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").`
|
|
25202
|
+
\`${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"). Seeds are from MMI-Hub@${seedSource.sha}.`
|
|
25150
25203
|
]);
|
|
25151
25204
|
seedPrUrl = created.url;
|
|
25152
25205
|
}
|
|
@@ -25242,7 +25295,7 @@ function registerBootstrapCommands(program3) {
|
|
|
25242
25295
|
applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
|
|
25243
25296
|
}
|
|
25244
25297
|
}
|
|
25245
|
-
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));
|
|
25298
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, sourceSha: seedSource.sha, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedPrUrl, actions, applied, ddbWrites }, null, 2));
|
|
25246
25299
|
else {
|
|
25247
25300
|
console.log(renderSeedPlan(actions));
|
|
25248
25301
|
if (o.execute) console.log(`
|
|
@@ -25254,6 +25307,8 @@ LIVE apply to ${repo}:
|
|
|
25254
25307
|
const o = { target: rawValue("--target", ""), execute: rawFlag("--execute"), json: rawFlag("--json") };
|
|
25255
25308
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
25256
25309
|
if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap propagate: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the desired state this tick propagates`);
|
|
25310
|
+
const seedSource = await resolveHubSeedSource(execGitForSeedSource);
|
|
25311
|
+
if (!seedSource.ok) return fail(`bootstrap propagate: ${seedSource.reason}`);
|
|
25257
25312
|
const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
|
|
25258
25313
|
const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
|
|
25259
25314
|
if (!o.target) {
|
|
@@ -25337,13 +25392,14 @@ LIVE apply to ${repo}:
|
|
|
25337
25392
|
let pr2;
|
|
25338
25393
|
try {
|
|
25339
25394
|
const branch = `${branchPrefix}-${r.slug}`;
|
|
25340
|
-
const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit", "--limit", "1"]);
|
|
25395
|
+
const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit,body", "--limit", "1"]);
|
|
25341
25396
|
const arr = JSON.parse(listed.stdout || "[]");
|
|
25342
25397
|
const p = arr[0];
|
|
25343
25398
|
if (p) {
|
|
25344
25399
|
const rollup = p.statusCheckRollup ?? [];
|
|
25345
25400
|
const checks = rollup.length === 0 ? "none" : rollup.some((c) => c.conclusion === "FAILURE" || c.state === "FAILURE") ? "red" : rollup.every((c) => c.conclusion === "SUCCESS" || c.state === "SUCCESS") ? "success" : "pending";
|
|
25346
|
-
|
|
25401
|
+
const sourceSha = p.body?.match(/Propagates MMI-Hub@([0-9a-fA-F]{7,40})/)?.[1];
|
|
25402
|
+
pr2 = { number: p.number, url: p.url, state: p.state === "MERGED" ? "merged" : p.state === "CLOSED" ? "closed" : "open", checks, mergeSha: p.mergeCommit?.oid, sourceSha };
|
|
25347
25403
|
}
|
|
25348
25404
|
} catch {
|
|
25349
25405
|
pr2 = void 0;
|
|
@@ -25365,10 +25421,10 @@ LIVE apply to ${repo}:
|
|
|
25365
25421
|
}
|
|
25366
25422
|
}
|
|
25367
25423
|
}
|
|
25368
|
-
const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied });
|
|
25424
|
+
const plan = planPropagationTick({ target: seed.target, repos, canarySlug, reads, isWorkflowSeed, functionGateSatisfied, currentSha: seedSource.sha });
|
|
25369
25425
|
if (o.execute) {
|
|
25370
25426
|
if (plan.refusedNoCanary) return fail("bootstrap propagate --execute: no canary declared for this target \u2014 refusing to write (set seedCanary:true on exactly one registry repo)");
|
|
25371
|
-
const headSha =
|
|
25427
|
+
const headSha = seedSource.sha;
|
|
25372
25428
|
for (const rec of plan.records) {
|
|
25373
25429
|
if (rec.action !== "open-pr") continue;
|
|
25374
25430
|
const repoEntry = repos.find((r) => r.repo === rec.repo);
|
|
@@ -25433,8 +25489,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
|
|
|
25433
25489
|
rec.prUrl = prUrl;
|
|
25434
25490
|
}
|
|
25435
25491
|
}
|
|
25436
|
-
if (o.json) console.log(JSON.stringify(plan, null, 2));
|
|
25437
|
-
else console.log(renderPropagationReport(plan)
|
|
25492
|
+
if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
|
|
25493
|
+
else console.log(`${renderPropagationReport(plan)}
|
|
25494
|
+
source: MMI-Hub@${seedSource.sha}`);
|
|
25438
25495
|
if (plan.halted) process.exitCode = 1;
|
|
25439
25496
|
});
|
|
25440
25497
|
bootstrap.command("rollback <repo>").description("#4240: open a per-repo revert PR of the recorded seed-propagate merge \u2014 never a fleet-wide overwrite; dry-run unless --execute").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--target <path>", "the manifest target to roll back (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--record <path>", "read propagation candidates from a persisted `bootstrap propagate --json` report instead of the live seed-propagate PR history").option("--execute", "LIVE revert via gh (master-gated) \u2014 opens/reuses the seed-rollback PR; dry-run prints the plan only").option("--json", "machine-readable output").action(async (repo) => {
|
|
@@ -25454,6 +25511,8 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
|
|
|
25454
25511
|
}
|
|
25455
25512
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
25456
25513
|
if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap rollback: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the manifest names which targets are org-owned and therefore propagated (and rollback-able)`);
|
|
25514
|
+
const seedSource = await resolveHubSeedSource(execGitForSeedSource);
|
|
25515
|
+
if (!seedSource.ok) return fail(`bootstrap rollback: ${seedSource.reason}`);
|
|
25457
25516
|
const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
|
|
25458
25517
|
const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
|
|
25459
25518
|
if (!o.target) {
|
|
@@ -25509,7 +25568,7 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
|
|
|
25509
25568
|
}
|
|
25510
25569
|
const plan = planRollback(repo, seed.target, slug, candidates);
|
|
25511
25570
|
if (!plan.resolution.found) {
|
|
25512
|
-
if (o.json) console.log(JSON.stringify(plan, null, 2));
|
|
25571
|
+
if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
|
|
25513
25572
|
else console.log(renderRollbackReport(plan));
|
|
25514
25573
|
return fail(`bootstrap rollback: ${plan.resolution.reason}`);
|
|
25515
25574
|
}
|
|
@@ -25564,8 +25623,9 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
|
|
|
25564
25623
|
}
|
|
25565
25624
|
plan.prUrl = prUrl;
|
|
25566
25625
|
}
|
|
25567
|
-
if (o.json) console.log(JSON.stringify(plan, null, 2));
|
|
25568
|
-
else console.log(renderRollbackReport(plan)
|
|
25626
|
+
if (o.json) console.log(JSON.stringify({ ...plan, sourceSha: seedSource.sha }, null, 2));
|
|
25627
|
+
else console.log(`${renderRollbackReport(plan)}
|
|
25628
|
+
manifest verified against: MMI-Hub@${seedSource.sha}`);
|
|
25569
25629
|
});
|
|
25570
25630
|
}
|
|
25571
25631
|
|
|
@@ -29582,6 +29642,25 @@ function findNegatedClosings(text, closingNumbers) {
|
|
|
29582
29642
|
return forIssue.length > 0 && forIssue.every((m) => m.negated);
|
|
29583
29643
|
});
|
|
29584
29644
|
}
|
|
29645
|
+
var CROSS_REPO_TOKEN_RE = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g;
|
|
29646
|
+
function findCrossRepoAmbiguousClosings(text, closingNumbers, repo) {
|
|
29647
|
+
if (!closingNumbers.length) return [];
|
|
29648
|
+
const closing = new Set(closingNumbers);
|
|
29649
|
+
const flagged = /* @__PURE__ */ new Set();
|
|
29650
|
+
const repoLower = repo.toLowerCase();
|
|
29651
|
+
for (const match of text.matchAll(CROSS_REPO_TOKEN_RE)) {
|
|
29652
|
+
const n = Number(match[2]);
|
|
29653
|
+
if (!closing.has(n)) continue;
|
|
29654
|
+
if ((match[1] ?? "").toLowerCase() === repoLower) continue;
|
|
29655
|
+
flagged.add(n);
|
|
29656
|
+
}
|
|
29657
|
+
return [...flagged];
|
|
29658
|
+
}
|
|
29659
|
+
function crossRepoAmbiguousRefusalMessage(ambiguous, repo, context = "pr merge") {
|
|
29660
|
+
const named = ambiguous.map((n) => `${repo}#${n}`).join(", ");
|
|
29661
|
+
const first = `#${ambiguous[0] ?? "N"}`;
|
|
29662
|
+
return `${context}: REFUSED \u2014 GitHub will close ${named} on merge (a bare \`#N\` after a closing keyword always means the repo the PR lives in), but this body also mentions ${first} qualified against a DIFFERENT repo elsewhere \u2014 check whether that closing keyword was meant to carry an explicit owner/repo#N qualifier instead. Reword the closing reference with the full owner/repo#N form, or re-run with --force to merge anyway and let ${named} close.`;
|
|
29663
|
+
}
|
|
29585
29664
|
function commitMessagesText(commits) {
|
|
29586
29665
|
if (!Array.isArray(commits)) return "";
|
|
29587
29666
|
return commits.map((commit) => {
|
|
@@ -29590,7 +29669,7 @@ function commitMessagesText(commits) {
|
|
|
29590
29669
|
${typeof messageBody === "string" ? messageBody : ""}`;
|
|
29591
29670
|
}).join("\n");
|
|
29592
29671
|
}
|
|
29593
|
-
function parseClosingGuardInput(raw) {
|
|
29672
|
+
function parseClosingGuardInput(raw, repo) {
|
|
29594
29673
|
if (!raw || typeof raw !== "object") return void 0;
|
|
29595
29674
|
const { title, body, state, closingIssuesReferences, commits } = raw;
|
|
29596
29675
|
if (typeof state !== "string" || !Array.isArray(closingIssuesReferences)) return void 0;
|
|
@@ -29603,7 +29682,7 @@ function parseClosingGuardInput(raw) {
|
|
|
29603
29682
|
const commitClosing = [...new Set(findClosingMentions(commitMessagesText(commits)).map((m) => m.issue))];
|
|
29604
29683
|
const text = `${typeof title === "string" ? title : ""}
|
|
29605
29684
|
${typeof body === "string" ? body : ""}`;
|
|
29606
|
-
return { state, text, closing, commitClosing };
|
|
29685
|
+
return { state, text, closing, commitClosing, repo };
|
|
29607
29686
|
}
|
|
29608
29687
|
function negatedClosingRefusalMessage(negated, context = "pr merge") {
|
|
29609
29688
|
const named = negated.map((n) => `#${n}`).join(", ");
|
|
@@ -29632,12 +29711,24 @@ function evaluateClosingGuard(input, opts) {
|
|
|
29632
29711
|
}
|
|
29633
29712
|
if (input.closing.length === 0) return { blocked: false };
|
|
29634
29713
|
const negated = findNegatedClosings(input.text, input.closing);
|
|
29635
|
-
if (
|
|
29636
|
-
|
|
29637
|
-
|
|
29638
|
-
|
|
29639
|
-
|
|
29640
|
-
|
|
29714
|
+
if (negated.length) {
|
|
29715
|
+
if (!opts.force) return { blocked: true, message: negatedClosingRefusalMessage(negated, opts.context) };
|
|
29716
|
+
return {
|
|
29717
|
+
blocked: false,
|
|
29718
|
+
message: `${opts.context}: --force past the negated-closing guard \u2014 GitHub will still close ${negated.map((n) => `#${n}`).join(", ")} on merge although the PR body says it does not.`
|
|
29719
|
+
};
|
|
29720
|
+
}
|
|
29721
|
+
if (input.repo) {
|
|
29722
|
+
const ambiguous = findCrossRepoAmbiguousClosings(input.text, input.closing, input.repo);
|
|
29723
|
+
if (ambiguous.length) {
|
|
29724
|
+
if (!opts.force) return { blocked: true, message: crossRepoAmbiguousRefusalMessage(ambiguous, input.repo, opts.context) };
|
|
29725
|
+
return {
|
|
29726
|
+
blocked: false,
|
|
29727
|
+
message: `${opts.context}: --force past the cross-repo-ambiguous closing guard \u2014 GitHub will still close ${ambiguous.map((n) => `${input.repo}#${n}`).join(", ")} on merge.`
|
|
29728
|
+
};
|
|
29729
|
+
}
|
|
29730
|
+
}
|
|
29731
|
+
return { blocked: false };
|
|
29641
29732
|
}
|
|
29642
29733
|
|
|
29643
29734
|
// src/session-report.ts
|
|
@@ -34556,12 +34647,13 @@ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skip
|
|
|
34556
34647
|
if (result.status === "failure" || result.status === "conflicting") process.exitCode = 1;
|
|
34557
34648
|
if (result.status === "timeout" || result.status === "rate-limited") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
|
|
34558
34649
|
});
|
|
34559
|
-
pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword
|
|
34650
|
+
pr.command("land <number>").description("agent merge path (#1440): train probe ? checks-wait ? merge --auto ? poll enqueued ? development PRs only").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--preserve-worktree", "after merge, keep the local PR worktree/stage/branch for an active batch (#1888)").option("--force", "acknowledge and land past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal").action(async (number, o) => {
|
|
34560
34651
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
34561
34652
|
const startingPath = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
34562
34653
|
assertPrMergeHousekeepingClean(startingPath || process.cwd(), "pr land", { force: o.force });
|
|
34563
34654
|
const landClosingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
|
|
34564
|
-
const
|
|
34655
|
+
const landRepoForGuard = await resolveRepo(o.repo).catch(() => void 0) ?? o.repo;
|
|
34656
|
+
const landClosingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(landClosingGuardRaw, landRepoForGuard), { force: o.force, context: "pr land" });
|
|
34565
34657
|
if (landClosingGuardVerdict.blocked) {
|
|
34566
34658
|
console.error(landClosingGuardVerdict.message);
|
|
34567
34659
|
process.exitCode = 1;
|
|
@@ -34672,14 +34764,14 @@ pr.command("land <number>").description("agent merge path (#1440): train probe ?
|
|
|
34672
34764
|
}
|
|
34673
34765
|
if (result.status === "failed" || result.cleanupError) process.exitCode = 1;
|
|
34674
34766
|
});
|
|
34675
|
-
jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword
|
|
34767
|
+
jsonParity(pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree ? no leftover local branch; on no-ci repos run pr ci-policy / checks-wait first (#1432)").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--auto", "enable auto-merge ? merge once the base-branch policy is satisfied (use for policy-gated repos)").option("--wait", `wait for checks to reach a terminal passing verdict before merging (default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m)`).option("--preserve-worktree", "keep the local PR worktree/stage/branch for an active multi-issue batch (#1888)").option("--force", "acknowledge and merge past a remaining prunable/severe scratch housekeeping block (advisory kept plans/ never blocks, #3012) or a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal")).action(async (number, o) => {
|
|
34676
34768
|
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
34677
34769
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
34678
34770
|
const repoForPostCleanup = await resolveRepo(o.repo) ?? o.repo;
|
|
34679
34771
|
const [headRef, baseRef, headRefOid] = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName,baseRefName,headRefOid", "--jq", '.headRefName + " " + .baseRefName + " " + (.headRefOid // "")'], { timeout: GC_GH_TIMEOUT_MS2 })).stdout.trim().split(/\s+/);
|
|
34680
34772
|
const headIsProtected = isProtectedBranch(headRef);
|
|
34681
34773
|
const closingGuardRaw = await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "title,body,state,closingIssuesReferences,commits"], { timeout: GC_GH_TIMEOUT_MS2 }).then((r) => JSON.parse(r.stdout)).catch(() => void 0);
|
|
34682
|
-
const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw), { force: o.force, context: "pr merge" });
|
|
34774
|
+
const closingGuardVerdict = evaluateClosingGuard(parseClosingGuardInput(closingGuardRaw, repoForPostCleanup), { force: o.force, context: "pr merge" });
|
|
34683
34775
|
if (closingGuardVerdict.blocked) {
|
|
34684
34776
|
console.error(closingGuardVerdict.message);
|
|
34685
34777
|
process.exitCode = 1;
|
package/package.json
CHANGED