@rmartz/merge-safety 0.12.0 → 0.13.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/bin/merge-safety.js +89 -122
- package/package.json +1 -1
package/dist/bin/merge-safety.js
CHANGED
|
@@ -230,8 +230,6 @@ var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
|
|
|
230
230
|
var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
|
|
231
231
|
var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
|
|
232
232
|
var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
|
|
233
|
-
var FUNCTIONAL_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "perf", "revert"]);
|
|
234
|
-
var CONVENTIONAL_TYPE_RE = /^([a-z]+)(?:!|\([^)]*\))*:/;
|
|
235
233
|
function firstLine(message) {
|
|
236
234
|
return message.split("\n", 1)[0] ?? "";
|
|
237
235
|
}
|
|
@@ -253,10 +251,6 @@ function isCiTitle(title) {
|
|
|
253
251
|
function isDocsTitle(title) {
|
|
254
252
|
return DOCS_SUBJECT_RE.test(title.trim());
|
|
255
253
|
}
|
|
256
|
-
function mayCarryBreakingMarker(title) {
|
|
257
|
-
const type = CONVENTIONAL_TYPE_RE.exec(title.trim())?.[1];
|
|
258
|
-
return type !== void 0 && FUNCTIONAL_TYPES.has(type);
|
|
259
|
-
}
|
|
260
254
|
|
|
261
255
|
// src/reasons.ts
|
|
262
256
|
var BREAKING_DIFF_REASONS = {
|
|
@@ -326,6 +320,89 @@ function evaluateStaleness(facts) {
|
|
|
326
320
|
return { needsUpdate, reasons };
|
|
327
321
|
}
|
|
328
322
|
|
|
323
|
+
// src/base-health.ts
|
|
324
|
+
var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
|
|
325
|
+
var GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
326
|
+
var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
|
|
327
|
+
function isGitHubActionsCheck(check) {
|
|
328
|
+
return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
|
|
329
|
+
}
|
|
330
|
+
function isNonBuildPlatformCheck(name) {
|
|
331
|
+
return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
|
|
332
|
+
}
|
|
333
|
+
function isFailingCiConclusion(conclusion) {
|
|
334
|
+
return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
|
|
335
|
+
}
|
|
336
|
+
function failingRequiredBaseChecks(checks, requiredContexts) {
|
|
337
|
+
if (!requiredContexts || requiredContexts.length === 0) return [];
|
|
338
|
+
const required = new Set(requiredContexts);
|
|
339
|
+
return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
|
|
340
|
+
}
|
|
341
|
+
function failingFallbackBaseChecks(checks) {
|
|
342
|
+
return checks.filter(
|
|
343
|
+
(c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
|
|
344
|
+
).map((c) => c.name);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/merge-safety.ts
|
|
348
|
+
var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
|
|
349
|
+
function isEvaluablePrState(state) {
|
|
350
|
+
return state === "OPEN";
|
|
351
|
+
}
|
|
352
|
+
var HOTFIX_LABEL = "hotfix";
|
|
353
|
+
function overlappingFiles(prFiles, baseFiles) {
|
|
354
|
+
if (!prFiles.length || !baseFiles.length) return [];
|
|
355
|
+
const base = new Set(baseFiles);
|
|
356
|
+
return prFiles.filter((f) => base.has(f));
|
|
357
|
+
}
|
|
358
|
+
function evaluateMergeSafety(facts) {
|
|
359
|
+
const reasons = [];
|
|
360
|
+
if (facts.hasConflict) {
|
|
361
|
+
reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
|
|
362
|
+
}
|
|
363
|
+
const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
|
|
364
|
+
if (baseUnhealthy) {
|
|
365
|
+
reasons.push(
|
|
366
|
+
withDetail(
|
|
367
|
+
"The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
|
|
368
|
+
facts.failingBaseChecks
|
|
369
|
+
)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
|
|
373
|
+
reasons.push(...stalenessReasons);
|
|
374
|
+
const needsAction = facts.hasConflict || baseUnhealthy;
|
|
375
|
+
const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
|
|
376
|
+
const summary = conclusion === "success" ? "No update required: current, or no breaking/overlapping changes on the base, no conflict, and the base CI is green." : firstLine(reasons[0] ?? "Not safe to merge as-is.");
|
|
377
|
+
const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : "No update required";
|
|
378
|
+
const add = [];
|
|
379
|
+
if (needsUpdate) add.push("update required");
|
|
380
|
+
if (facts.hasConflict) add.push("merge conflict");
|
|
381
|
+
const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
|
|
382
|
+
return {
|
|
383
|
+
conclusion,
|
|
384
|
+
needsUpdate,
|
|
385
|
+
hasConflict: facts.hasConflict,
|
|
386
|
+
baseUnhealthy,
|
|
387
|
+
title,
|
|
388
|
+
reasons,
|
|
389
|
+
summary,
|
|
390
|
+
labels: { add, remove }
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function errorMergeSafetyDecision(message) {
|
|
394
|
+
return {
|
|
395
|
+
conclusion: "failure",
|
|
396
|
+
needsUpdate: false,
|
|
397
|
+
hasConflict: false,
|
|
398
|
+
baseUnhealthy: false,
|
|
399
|
+
title: "Could not evaluate",
|
|
400
|
+
reasons: [message],
|
|
401
|
+
summary: `Could not evaluate merge safety: ${message}`,
|
|
402
|
+
labels: { add: [], remove: [] }
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
329
406
|
// src/breaking-diff.ts
|
|
330
407
|
var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
|
|
331
408
|
var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
|
|
@@ -432,110 +509,6 @@ function breakingDiffSignals(diffText) {
|
|
|
432
509
|
if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
|
|
433
510
|
return signals;
|
|
434
511
|
}
|
|
435
|
-
function hasSignal(signals, kind) {
|
|
436
|
-
return signals.some((s) => s.kind === kind);
|
|
437
|
-
}
|
|
438
|
-
function signalDetail(signals, kind) {
|
|
439
|
-
return signals.find((s) => s.kind === kind)?.detail ?? [];
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// src/base-health.ts
|
|
443
|
-
var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
|
|
444
|
-
var GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
445
|
-
var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
|
|
446
|
-
function isGitHubActionsCheck(check) {
|
|
447
|
-
return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
|
|
448
|
-
}
|
|
449
|
-
function isNonBuildPlatformCheck(name) {
|
|
450
|
-
return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
|
|
451
|
-
}
|
|
452
|
-
function isFailingCiConclusion(conclusion) {
|
|
453
|
-
return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
|
|
454
|
-
}
|
|
455
|
-
function failingRequiredBaseChecks(checks, requiredContexts) {
|
|
456
|
-
if (!requiredContexts || requiredContexts.length === 0) return [];
|
|
457
|
-
const required = new Set(requiredContexts);
|
|
458
|
-
return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
|
|
459
|
-
}
|
|
460
|
-
function failingFallbackBaseChecks(checks) {
|
|
461
|
-
return checks.filter(
|
|
462
|
-
(c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
|
|
463
|
-
).map((c) => c.name);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// src/merge-safety.ts
|
|
467
|
-
var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
|
|
468
|
-
function isEvaluablePrState(state) {
|
|
469
|
-
return state === "OPEN";
|
|
470
|
-
}
|
|
471
|
-
var HOTFIX_LABEL = "hotfix";
|
|
472
|
-
var BREAKING_LABEL = "breaking change";
|
|
473
|
-
function overlappingFiles(prFiles, baseFiles) {
|
|
474
|
-
if (!prFiles.length || !baseFiles.length) return [];
|
|
475
|
-
const base = new Set(baseFiles);
|
|
476
|
-
return prFiles.filter((f) => base.has(f));
|
|
477
|
-
}
|
|
478
|
-
function evaluateMergeSafety(facts) {
|
|
479
|
-
const reasons = [];
|
|
480
|
-
if (facts.hasConflict) {
|
|
481
|
-
reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
|
|
482
|
-
}
|
|
483
|
-
const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
|
|
484
|
-
if (baseUnhealthy) {
|
|
485
|
-
reasons.push(
|
|
486
|
-
withDetail(
|
|
487
|
-
"The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
|
|
488
|
-
facts.failingBaseChecks
|
|
489
|
-
)
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
|
|
493
|
-
reasons.push(...stalenessReasons);
|
|
494
|
-
const needsCiRetitle = hasSignal(facts.prBreakingDiffSignals, "sensitive-package-bump") ? !facts.prIsCi : false;
|
|
495
|
-
if (needsCiRetitle) {
|
|
496
|
-
reasons.push(
|
|
497
|
-
withDetail(
|
|
498
|
-
"This PR changes a CI-sensitive package version, which can redden the format/lint gate on PRs that never touched the bumped file. Retitle it with the `ci` type (e.g. `ci(deps): \u2026`) so merged siblings are forced to re-test under it \u2014 a `breaking change` label cannot carry this signal on a non-functional type:",
|
|
499
|
-
signalDetail(facts.prBreakingDiffSignals, "sensitive-package-bump")
|
|
500
|
-
)
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle;
|
|
504
|
-
const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
|
|
505
|
-
const summary = conclusion === "success" ? "No update required: current, or no breaking/overlapping changes on the base, no conflict, and the base CI is green." : firstLine(reasons[0] ?? "Not safe to merge as-is.");
|
|
506
|
-
const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
|
|
507
|
-
const add = [];
|
|
508
|
-
if (needsUpdate) add.push("update required");
|
|
509
|
-
if (facts.hasConflict) add.push("merge conflict");
|
|
510
|
-
const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
|
|
511
|
-
const addOnly = [];
|
|
512
|
-
if (hasSignal(facts.prBreakingDiffSignals, "major-version-bump") && facts.prMayCarryBreakingMarker)
|
|
513
|
-
addOnly.push(BREAKING_LABEL);
|
|
514
|
-
return {
|
|
515
|
-
conclusion,
|
|
516
|
-
needsUpdate,
|
|
517
|
-
hasConflict: facts.hasConflict,
|
|
518
|
-
baseUnhealthy,
|
|
519
|
-
needsCiRetitle,
|
|
520
|
-
title,
|
|
521
|
-
reasons,
|
|
522
|
-
summary,
|
|
523
|
-
labels: { add, remove, addOnly }
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
function errorMergeSafetyDecision(message) {
|
|
527
|
-
return {
|
|
528
|
-
conclusion: "failure",
|
|
529
|
-
needsUpdate: false,
|
|
530
|
-
hasConflict: false,
|
|
531
|
-
baseUnhealthy: false,
|
|
532
|
-
needsCiRetitle: false,
|
|
533
|
-
title: "Could not evaluate",
|
|
534
|
-
reasons: [message],
|
|
535
|
-
summary: `Could not evaluate merge safety: ${message}`,
|
|
536
|
-
labels: { add: [], remove: [], addOnly: [] }
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
512
|
|
|
540
513
|
// src/merge-safety-facts.ts
|
|
541
514
|
var GIT_TIMEOUT_MS = 3e4;
|
|
@@ -617,9 +590,10 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
|
|
|
617
590
|
// Each boolean is derived from its detail list — one computation, two views.
|
|
618
591
|
baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
|
|
619
592
|
baseCiSinceMergeBase: baseCiCommits.length > 0,
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
593
|
+
// The title's `!` is the only declared input (#82) — pr-policy's title check
|
|
594
|
+
// keeps it in step with the `breaking change` label. The diff can only add a
|
|
595
|
+
// reason to re-test the PR against a moved base (#53).
|
|
596
|
+
prIsBreaking: isBreakingTitle(meta.title) || diffSignals.length > 0,
|
|
623
597
|
prIsDocs: isDocsTitle(meta.title),
|
|
624
598
|
baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
|
|
625
599
|
// Title or path (#67): a release-typed PR that changes CI is still re-tested.
|
|
@@ -632,8 +606,7 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
|
|
|
632
606
|
baseCiCommits,
|
|
633
607
|
overlappingFiles: overlaps,
|
|
634
608
|
failingBaseChecks,
|
|
635
|
-
prBreakingDiffSignals: diffSignals
|
|
636
|
-
prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title)
|
|
609
|
+
prBreakingDiffSignals: diffSignals
|
|
637
610
|
};
|
|
638
611
|
}
|
|
639
612
|
|
|
@@ -807,13 +780,7 @@ ${detail}` },
|
|
|
807
780
|
decision.conclusion,
|
|
808
781
|
args.cwd
|
|
809
782
|
);
|
|
810
|
-
await reconcileLabels(
|
|
811
|
-
repo,
|
|
812
|
-
pr,
|
|
813
|
-
[...decision.labels.add, ...decision.labels.addOnly],
|
|
814
|
-
decision.labels.remove,
|
|
815
|
-
args.cwd
|
|
816
|
-
);
|
|
783
|
+
await reconcileLabels(repo, pr, decision.labels.add, decision.labels.remove, args.cwd);
|
|
817
784
|
console.log(`#${pr}: ${decision.conclusion} \u2014 ${decision.summary}`);
|
|
818
785
|
}
|
|
819
786
|
function emitDecisionJson(decision, isError) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/merge-safety",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The pre-auto-merge safety verdict for a PR (base-currency, breaking-change, and conflict facts), posted as the `merge-safety` check-run and distributed via a reusable workflow kept current by Dependabot.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ci",
|