@rmartz/merge-safety 0.15.0 → 0.16.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/README.md +9 -2
- package/dist/bin/merge-safety.d.ts +1 -43
- package/dist/bin/merge-safety.js +8 -426
- package/dist/chunk-2QWRQ4Q5.js +614 -0
- package/dist/index.d.ts +360 -3
- package/dist/index.js +19 -3
- package/package.json +1 -1
- package/dist/chunk-ITPKHZGC.js +0 -12
package/README.md
CHANGED
|
@@ -32,6 +32,13 @@ the verdict expects of a repo and how it behaves.
|
|
|
32
32
|
> `rmartz/merge-safety/.github/workflows/merge-safety.yml` keep working and see a
|
|
33
33
|
> deprecation warning on each run; migrate them to the action.
|
|
34
34
|
|
|
35
|
+
## Using it as a library
|
|
36
|
+
|
|
37
|
+
The package also exports the verdict for in-process use, e.g. from a hosted
|
|
38
|
+
GitHub App with no checkout: `gatherMergeSafetyFacts` with the API-backed
|
|
39
|
+
`makeApiGitRunner` fact source, then `evaluateMergeSafety`. See
|
|
40
|
+
[docs/fact-sources.md](docs/fact-sources.md).
|
|
41
|
+
|
|
35
42
|
## Requirements
|
|
36
43
|
|
|
37
44
|
- Node.js >= 20.11
|
|
@@ -74,8 +81,8 @@ split-prefix convention (rmartz/ai#82): a **production** bump arrives as
|
|
|
74
81
|
**github-actions** (`chore(github-actions):`) bumps are release-less — as a
|
|
75
82
|
zero-runtime-dependency package, all Dependabot bumps are dev-only in practice.
|
|
76
83
|
|
|
77
|
-
Three guards back the automatic flow:
|
|
78
|
-
(pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
|
|
84
|
+
Three guards back the automatic flow: pr-policy's `title` check, part of the
|
|
85
|
+
`pr-policy` check run by [`pr-policy.yml`](.github/workflows/pr-policy.yml) (pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
|
|
79
86
|
(post-merge tripwire for a non-conventional subject that would make semantic-release
|
|
80
87
|
silently skip), the shared [`release-check.yml`](.github/workflows/release-check.yml)
|
|
81
88
|
(renders the notes through the shared
|
|
@@ -1,47 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { MergeSafetyCommand } from '../index.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Base-health classification — "is the base branch's own CI failing?", the
|
|
6
|
-
* PR-independent axis of the merge-safety verdict.
|
|
7
|
-
*
|
|
8
|
-
* Extracted from `merge-safety.ts` when the self-derived breaking-change signal
|
|
9
|
-
* (#53) pushed that file past its 480-line cap. It is a genuinely separate
|
|
10
|
-
* concern: every other clause of the verdict reads *this PR* against *its base*,
|
|
11
|
-
* while these functions read only the base tip's check-runs and know nothing
|
|
12
|
-
* about any PR. Pure — the network boundary stays in the bin's probes.
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* A base-tip check-run reduced to what base-health classification needs: its name
|
|
16
|
-
* (matched against the base branch's required status checks), its conclusion, and
|
|
17
|
-
* the slug of the producing GitHub App (used by the fallback heuristic to tell a
|
|
18
|
-
* GitHub Actions CI run from an external deploy integration).
|
|
19
|
-
*/
|
|
20
|
-
interface BaseCheckRun {
|
|
21
|
-
/** The check-run name (e.g. the workflow / job name), matched to a required context. */
|
|
22
|
-
name: string;
|
|
23
|
-
/** The check-run conclusion, or `null` while still in progress. */
|
|
24
|
-
conclusion: string | null;
|
|
25
|
-
/** The producing GitHub App's slug (e.g. `github-actions`, `vercel`), or `null`. */
|
|
26
|
-
appSlug: string | null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Fetches the base branch tip's check-runs (deduped to the latest per name) so
|
|
31
|
-
* base-health can be judged. Injected like {@link GitRunner} so the assembly logic
|
|
32
|
-
* stays testable with a fake and the network boundary lives in the bin. Soft-fails
|
|
33
|
-
* to `[]` (an unreadable base is treated as *not* failing) so a transient `gh`
|
|
34
|
-
* error never wedges the whole merge queue on a base-health false positive.
|
|
35
|
-
*/
|
|
36
|
-
type BaseChecksProbe = (baseSha: string) => Promise<readonly BaseCheckRun[]>;
|
|
37
|
-
/**
|
|
38
|
-
* Fetches the base branch's **required status check** contexts — the set base-health
|
|
39
|
-
* scopes failures to (#40). Returns `null` when protection can't be read (transient
|
|
40
|
-
* `gh` error, no ruleset, missing scope); a `null` or empty set means base-health
|
|
41
|
-
* reports no failure, so a probe hiccup never wedges the merge queue. Injected like
|
|
42
|
-
* {@link BaseChecksProbe}, with the network boundary in the bin.
|
|
43
|
-
*/
|
|
44
|
-
type RequiredChecksProbe = (baseBranch: string) => Promise<readonly string[] | null>;
|
|
2
|
+
import { MergeSafetyCommand, BaseChecksProbe, RequiredChecksProbe } from '../index.js';
|
|
45
3
|
|
|
46
4
|
interface Args {
|
|
47
5
|
mode: MergeSafetyCommand;
|
package/dist/bin/merge-safety.js
CHANGED
|
@@ -1,52 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
MERGE_SAFETY_CHECK_NAME,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
boundedRun,
|
|
5
|
+
errorMergeSafetyDecision,
|
|
6
|
+
evaluateMergeSafety,
|
|
7
|
+
gatherMergeSafetyFacts,
|
|
8
|
+
isEvaluablePrState,
|
|
9
|
+
isMergeSafetyCommand,
|
|
10
|
+
makeGitRunner
|
|
11
|
+
} from "../chunk-2QWRQ4Q5.js";
|
|
6
12
|
|
|
7
13
|
// src/bin/merge-safety.ts
|
|
8
14
|
import { realpathSync } from "fs";
|
|
9
15
|
import { fileURLToPath } from "url";
|
|
10
16
|
|
|
11
|
-
// src/lib/bounded-subprocess.ts
|
|
12
|
-
import { spawn } from "child_process";
|
|
13
|
-
function boundedRun(command, args, options) {
|
|
14
|
-
return new Promise((resolve, reject) => {
|
|
15
|
-
const child = spawn(command, args, {
|
|
16
|
-
cwd: options.cwd,
|
|
17
|
-
env: options.env,
|
|
18
|
-
detached: true
|
|
19
|
-
// new process group, so we can kill the whole tree
|
|
20
|
-
});
|
|
21
|
-
let stdout = "";
|
|
22
|
-
let stderr = "";
|
|
23
|
-
let timedOut = false;
|
|
24
|
-
const timer = setTimeout(() => {
|
|
25
|
-
timedOut = true;
|
|
26
|
-
try {
|
|
27
|
-
process.kill(-child.pid, "SIGKILL");
|
|
28
|
-
} catch {
|
|
29
|
-
}
|
|
30
|
-
}, options.timeoutMs);
|
|
31
|
-
if (options.input !== void 0 && child.stdin) {
|
|
32
|
-
child.stdin.on("error", () => {
|
|
33
|
-
});
|
|
34
|
-
child.stdin.write(options.input);
|
|
35
|
-
child.stdin.end();
|
|
36
|
-
}
|
|
37
|
-
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
38
|
-
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
39
|
-
child.on("error", (err) => {
|
|
40
|
-
clearTimeout(timer);
|
|
41
|
-
reject(err);
|
|
42
|
-
});
|
|
43
|
-
child.on("close", (code) => {
|
|
44
|
-
clearTimeout(timer);
|
|
45
|
-
resolve({ stdout, stderr, code, timedOut });
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
17
|
// src/lib/github.ts
|
|
51
18
|
var GH_API_TIMEOUT_MS = 3e4;
|
|
52
19
|
var MAX_RETRIES = 2;
|
|
@@ -283,391 +250,6 @@ async function abandonRunAsCancelled(err, opts = {}) {
|
|
|
283
250
|
await wait(CANCEL_GRACE_MS);
|
|
284
251
|
}
|
|
285
252
|
|
|
286
|
-
// src/conventional-commits.ts
|
|
287
|
-
var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
|
|
288
|
-
var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
|
|
289
|
-
var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
|
|
290
|
-
var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
|
|
291
|
-
function firstLine(message) {
|
|
292
|
-
return message.split("\n", 1)[0] ?? "";
|
|
293
|
-
}
|
|
294
|
-
function isBreakingCommitMessage(message) {
|
|
295
|
-
return BREAKING_SUBJECT_RE.test(firstLine(message)) || BREAKING_FOOTER_RE.test(message);
|
|
296
|
-
}
|
|
297
|
-
function isCiCommitMessage(message) {
|
|
298
|
-
return CI_SUBJECT_RE.test(firstLine(message));
|
|
299
|
-
}
|
|
300
|
-
function isDocsCommitMessage(message) {
|
|
301
|
-
return DOCS_SUBJECT_RE.test(firstLine(message));
|
|
302
|
-
}
|
|
303
|
-
function isBreakingTitle(title) {
|
|
304
|
-
return BREAKING_SUBJECT_RE.test(title.trim());
|
|
305
|
-
}
|
|
306
|
-
function isCiTitle(title) {
|
|
307
|
-
return CI_SUBJECT_RE.test(title.trim());
|
|
308
|
-
}
|
|
309
|
-
function isDocsTitle(title) {
|
|
310
|
-
return DOCS_SUBJECT_RE.test(title.trim());
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// src/reasons.ts
|
|
314
|
-
var BREAKING_DIFF_REASONS = {
|
|
315
|
-
"major-version-bump": "dependency major bump",
|
|
316
|
-
"sensitive-package-bump": "CI-sensitive package version change",
|
|
317
|
-
"material-test-changes": "existing test file modified"
|
|
318
|
-
};
|
|
319
|
-
function nestedBullets(items) {
|
|
320
|
-
return items.map((item) => ` - ${item}`).join("\n");
|
|
321
|
-
}
|
|
322
|
-
function formatBaseCommit(commit) {
|
|
323
|
-
return `${commit.sha.slice(0, 7)} ${commit.subject}`;
|
|
324
|
-
}
|
|
325
|
-
function withDetail(sentence, detail) {
|
|
326
|
-
return detail.length ? `${sentence}
|
|
327
|
-
${nestedBullets(detail)}` : sentence;
|
|
328
|
-
}
|
|
329
|
-
function breakingSignalDetail(signals) {
|
|
330
|
-
return signals.flatMap((s) => s.detail.map((d) => `${BREAKING_DIFF_REASONS[s.kind]}: ${d}`));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// src/staleness.ts
|
|
334
|
-
function evaluateStaleness(facts) {
|
|
335
|
-
const reasons = [];
|
|
336
|
-
if (facts.isCurrent) return { needsUpdate: false, reasons };
|
|
337
|
-
const baseBreaking = facts.baseBreakingSinceMergeBase && !(facts.prIsDocs && !facts.prIsBreaking);
|
|
338
|
-
const prBreaking = facts.prIsBreaking && !facts.baseOnlyDocsSinceMergeBase;
|
|
339
|
-
const prCi = facts.prIsCi && !facts.prIsHotfix;
|
|
340
|
-
if (baseBreaking) {
|
|
341
|
-
reasons.push(
|
|
342
|
-
withDetail(
|
|
343
|
-
"A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
|
|
344
|
-
facts.baseBreakingCommits.map(formatBaseCommit)
|
|
345
|
-
)
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
if (prBreaking) {
|
|
349
|
-
reasons.push(
|
|
350
|
-
withDetail(
|
|
351
|
-
"This PR is a breaking change \u2014 it must be current with the base before merge.",
|
|
352
|
-
breakingSignalDetail(facts.prBreakingDiffSignals)
|
|
353
|
-
)
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
if (prCi) {
|
|
357
|
-
reasons.push(
|
|
358
|
-
"This PR is a CI change \u2014 it must be current with the base before merge, so it is re-tested against the latest base and cannot green a guard against a pattern that has since regressed."
|
|
359
|
-
);
|
|
360
|
-
}
|
|
361
|
-
if (facts.baseCiSinceMergeBase) {
|
|
362
|
-
reasons.push(
|
|
363
|
-
withDetail(
|
|
364
|
-
"A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
|
|
365
|
-
facts.baseCiCommits.map(formatBaseCommit)
|
|
366
|
-
)
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
if (facts.fileOverlap) {
|
|
370
|
-
reasons.push(
|
|
371
|
-
withDetail(
|
|
372
|
-
"This PR changes files the base also changed since merge-base \u2014 sync with base and re-run CI before merging to ensure the changes are compatible:",
|
|
373
|
-
facts.overlappingFiles
|
|
374
|
-
)
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
const needsUpdate = baseBreaking || facts.baseCiSinceMergeBase || prBreaking || prCi || facts.fileOverlap;
|
|
378
|
-
return { needsUpdate, reasons };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// src/base-health.ts
|
|
382
|
-
var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
|
|
383
|
-
var GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
384
|
-
var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
|
|
385
|
-
function isGitHubActionsCheck(check) {
|
|
386
|
-
return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
|
|
387
|
-
}
|
|
388
|
-
function isNonBuildPlatformCheck(name) {
|
|
389
|
-
return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
|
|
390
|
-
}
|
|
391
|
-
function isFailingCiConclusion(conclusion) {
|
|
392
|
-
return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
|
|
393
|
-
}
|
|
394
|
-
function failingRequiredBaseChecks(checks, requiredContexts) {
|
|
395
|
-
if (!requiredContexts || requiredContexts.length === 0) return [];
|
|
396
|
-
const required = new Set(requiredContexts);
|
|
397
|
-
return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
|
|
398
|
-
}
|
|
399
|
-
function failingFallbackBaseChecks(checks) {
|
|
400
|
-
return checks.filter(
|
|
401
|
-
(c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
|
|
402
|
-
).map((c) => c.name);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// src/merge-safety.ts
|
|
406
|
-
var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
|
|
407
|
-
function isEvaluablePrState(state) {
|
|
408
|
-
return state === "OPEN";
|
|
409
|
-
}
|
|
410
|
-
var HOTFIX_LABEL = "hotfix";
|
|
411
|
-
function overlappingFiles(prFiles, baseFiles) {
|
|
412
|
-
if (!prFiles.length || !baseFiles.length) return [];
|
|
413
|
-
const base = new Set(baseFiles);
|
|
414
|
-
return prFiles.filter((f) => base.has(f));
|
|
415
|
-
}
|
|
416
|
-
function evaluateMergeSafety(facts) {
|
|
417
|
-
const reasons = [];
|
|
418
|
-
if (facts.hasConflict) {
|
|
419
|
-
reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
|
|
420
|
-
}
|
|
421
|
-
const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
|
|
422
|
-
if (baseUnhealthy) {
|
|
423
|
-
reasons.push(
|
|
424
|
-
withDetail(
|
|
425
|
-
"The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
|
|
426
|
-
facts.failingBaseChecks
|
|
427
|
-
)
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
|
|
431
|
-
reasons.push(...stalenessReasons);
|
|
432
|
-
const needsAction = facts.hasConflict || baseUnhealthy;
|
|
433
|
-
const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
|
|
434
|
-
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.");
|
|
435
|
-
const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : "No update required";
|
|
436
|
-
const add = [];
|
|
437
|
-
if (needsUpdate) add.push("update required");
|
|
438
|
-
if (facts.hasConflict) add.push("merge conflict");
|
|
439
|
-
const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
|
|
440
|
-
return {
|
|
441
|
-
conclusion,
|
|
442
|
-
needsUpdate,
|
|
443
|
-
hasConflict: facts.hasConflict,
|
|
444
|
-
baseUnhealthy,
|
|
445
|
-
title,
|
|
446
|
-
reasons,
|
|
447
|
-
summary,
|
|
448
|
-
labels: { add, remove }
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
function errorMergeSafetyDecision(message) {
|
|
452
|
-
return {
|
|
453
|
-
conclusion: "failure",
|
|
454
|
-
needsUpdate: false,
|
|
455
|
-
hasConflict: false,
|
|
456
|
-
baseUnhealthy: false,
|
|
457
|
-
title: "Could not evaluate",
|
|
458
|
-
reasons: [message],
|
|
459
|
-
summary: `Could not evaluate merge safety: ${message}`,
|
|
460
|
-
labels: { add: [], remove: [] }
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// src/breaking-diff.ts
|
|
465
|
-
var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
|
|
466
|
-
var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
|
|
467
|
-
var NPM_DEP_RE = /"([^"]+)"\s*:\s*"[\^~]?(\d+)\.(\d+)\.(\d+)/;
|
|
468
|
-
var PIP_DEP_RE = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:==|~=|>=|<=|!=|<|>)\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
|
|
469
|
-
var TEST_FILE_RE = /(\.test\.tsx?|\.spec\.tsx?|test_[^/]*\.py|[^/]*_test\.py)$/;
|
|
470
|
-
var DIFF_HEADER_PATH_RE = / b\/(.+)$/;
|
|
471
|
-
function normalizePackageName(name) {
|
|
472
|
-
return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
|
|
473
|
-
}
|
|
474
|
-
function dependencyFileKind(diffGitLine) {
|
|
475
|
-
if (diffGitLine.includes("package.json")) return "npm";
|
|
476
|
-
if (/requirements[\w./-]*\.txt/.test(diffGitLine)) return "pip";
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
function isFileHeader(line) {
|
|
480
|
-
return line.startsWith("---") || line.startsWith("+++");
|
|
481
|
-
}
|
|
482
|
-
function dependencyChanges(diffText) {
|
|
483
|
-
const changes = [];
|
|
484
|
-
let kind = null;
|
|
485
|
-
for (const line of diffText.split("\n")) {
|
|
486
|
-
if (line.startsWith("diff --git")) {
|
|
487
|
-
kind = dependencyFileKind(line);
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
if (kind === null) continue;
|
|
491
|
-
if (isFileHeader(line)) continue;
|
|
492
|
-
if (!line.startsWith("-") && !line.startsWith("+")) continue;
|
|
493
|
-
const sign = line[0] === "-" ? "-" : "+";
|
|
494
|
-
const match = (kind === "npm" ? NPM_DEP_RE : PIP_DEP_RE).exec(line.slice(1));
|
|
495
|
-
if (!match) continue;
|
|
496
|
-
const name = match[1] ?? "";
|
|
497
|
-
if (kind === "npm" && NON_DEPENDENCY_NPM_KEYS.has(name.trim().toLowerCase())) continue;
|
|
498
|
-
const major = Number(match[2] ?? 0);
|
|
499
|
-
const minor = Number(match[3] ?? 0);
|
|
500
|
-
const patch = Number(match[4] ?? 0);
|
|
501
|
-
changes.push({ kind, sign, name, major, version: `${major}.${minor}.${patch}` });
|
|
502
|
-
}
|
|
503
|
-
return changes;
|
|
504
|
-
}
|
|
505
|
-
function indexChanges(changes) {
|
|
506
|
-
const removed = /* @__PURE__ */ new Map();
|
|
507
|
-
const added = /* @__PURE__ */ new Map();
|
|
508
|
-
for (const change of changes) {
|
|
509
|
-
const key = `${change.kind}:${normalizePackageName(change.name)}`;
|
|
510
|
-
(change.sign === "-" ? removed : added).set(key, change);
|
|
511
|
-
}
|
|
512
|
-
return { removed, added };
|
|
513
|
-
}
|
|
514
|
-
function majorVersionBumps(diffText) {
|
|
515
|
-
const { removed, added } = indexChanges(dependencyChanges(diffText));
|
|
516
|
-
const bumps = [];
|
|
517
|
-
for (const [key, addedChange] of added) {
|
|
518
|
-
const removedChange = removed.get(key);
|
|
519
|
-
if (removedChange && addedChange.major > removedChange.major) {
|
|
520
|
-
bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
return bumps;
|
|
524
|
-
}
|
|
525
|
-
function sensitivePackageBumps(diffText) {
|
|
526
|
-
const { removed, added } = indexChanges(dependencyChanges(diffText));
|
|
527
|
-
const bumps = [];
|
|
528
|
-
for (const [key, addedChange] of added) {
|
|
529
|
-
if (!CI_SENSITIVE_PACKAGES.has(normalizePackageName(addedChange.name))) continue;
|
|
530
|
-
const removedChange = removed.get(key);
|
|
531
|
-
if (removedChange && removedChange.version !== addedChange.version) {
|
|
532
|
-
bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
return bumps;
|
|
536
|
-
}
|
|
537
|
-
function materialTestChanges(diffText) {
|
|
538
|
-
const changed = [];
|
|
539
|
-
let file = null;
|
|
540
|
-
let hasAdds = false;
|
|
541
|
-
let hasRemoves = false;
|
|
542
|
-
const flush = () => {
|
|
543
|
-
if (file && hasAdds && hasRemoves && TEST_FILE_RE.test(file)) changed.push(file);
|
|
544
|
-
};
|
|
545
|
-
for (const line of diffText.split("\n")) {
|
|
546
|
-
if (line.startsWith("diff --git")) {
|
|
547
|
-
flush();
|
|
548
|
-
file = DIFF_HEADER_PATH_RE.exec(line)?.[1] ?? null;
|
|
549
|
-
hasAdds = false;
|
|
550
|
-
hasRemoves = false;
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
if (isFileHeader(line)) continue;
|
|
554
|
-
if (line.startsWith("+")) hasAdds = true;
|
|
555
|
-
else if (line.startsWith("-")) hasRemoves = true;
|
|
556
|
-
}
|
|
557
|
-
flush();
|
|
558
|
-
return changed;
|
|
559
|
-
}
|
|
560
|
-
function breakingDiffSignals(diffText) {
|
|
561
|
-
const signals = [];
|
|
562
|
-
const major = majorVersionBumps(diffText);
|
|
563
|
-
if (major.length) signals.push({ kind: "major-version-bump", detail: major });
|
|
564
|
-
const sensitive = sensitivePackageBumps(diffText);
|
|
565
|
-
if (sensitive.length) signals.push({ kind: "sensitive-package-bump", detail: sensitive });
|
|
566
|
-
const tests = materialTestChanges(diffText);
|
|
567
|
-
if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
|
|
568
|
-
return signals;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// src/merge-safety-facts.ts
|
|
572
|
-
var GIT_TIMEOUT_MS = 3e4;
|
|
573
|
-
function baseBranchName(baseRef) {
|
|
574
|
-
return baseRef.replace(/^refs\/heads\//, "").replace(/^origin\//, "");
|
|
575
|
-
}
|
|
576
|
-
function makeGitRunner(cwd) {
|
|
577
|
-
return async (args) => {
|
|
578
|
-
const r = await boundedRun("git", args, { timeoutMs: GIT_TIMEOUT_MS, cwd });
|
|
579
|
-
return r.code === 0 ? r.stdout : null;
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
function splitLines(out) {
|
|
583
|
-
return (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
584
|
-
}
|
|
585
|
-
function parseBaseCommits(logOut) {
|
|
586
|
-
return logOut.split("\0").map((record) => record.replace(/^\n+/, "")).filter((record) => record.trim().length > 0).map((record) => {
|
|
587
|
-
const newline = record.indexOf("\n");
|
|
588
|
-
const sha = (newline === -1 ? record : record.slice(0, newline)).trim();
|
|
589
|
-
const message = newline === -1 ? "" : record.slice(newline + 1).trim();
|
|
590
|
-
return { sha, message };
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
var CI_PATH_RE = /^\.github\/(?:workflows|actions)\//;
|
|
594
|
-
function isCiPath(path) {
|
|
595
|
-
return CI_PATH_RE.test(path);
|
|
596
|
-
}
|
|
597
|
-
function parseCiPathShas(logOut) {
|
|
598
|
-
const shas = /* @__PURE__ */ new Set();
|
|
599
|
-
for (const record of logOut.split("\0")) {
|
|
600
|
-
const [sha, ...files] = splitLines(record);
|
|
601
|
-
if (sha && files.some(isCiPath)) shas.add(sha);
|
|
602
|
-
}
|
|
603
|
-
return shas;
|
|
604
|
-
}
|
|
605
|
-
function selectCommits(commits, predicate) {
|
|
606
|
-
return commits.filter(predicate).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
|
|
607
|
-
}
|
|
608
|
-
async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, baseChecks, requiredChecks }) {
|
|
609
|
-
const mergeBase = (await git(["merge-base", meta.headSha, baseRef]))?.trim();
|
|
610
|
-
const baseTip = (await git(["rev-parse", baseRef]))?.trim();
|
|
611
|
-
if (!mergeBase || !baseTip) {
|
|
612
|
-
throw new Error(`could not resolve merge-base/base tip for ${meta.headSha}..${baseRef}`);
|
|
613
|
-
}
|
|
614
|
-
const isCurrent = mergeBase === baseTip;
|
|
615
|
-
const baseBranch = baseBranchName(baseRef);
|
|
616
|
-
const checks = await baseChecks(baseTip);
|
|
617
|
-
const required = await requiredChecks(baseBranch);
|
|
618
|
-
const failingBaseChecks = required && required.length > 0 ? failingRequiredBaseChecks(checks, required) : failingFallbackBaseChecks(checks);
|
|
619
|
-
const logOut = await git(["log", "-z", "--format=%H%n%B", `${mergeBase}..${baseRef}`]);
|
|
620
|
-
if (logOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
621
|
-
const commits = parseBaseCommits(logOut);
|
|
622
|
-
const baseBreakingCommits = selectCommits(commits, (c) => isBreakingCommitMessage(c.message));
|
|
623
|
-
const fileLogOut = await git([
|
|
624
|
-
"log",
|
|
625
|
-
"--format=%x00%H",
|
|
626
|
-
"--name-only",
|
|
627
|
-
`${mergeBase}..${baseRef}`
|
|
628
|
-
]);
|
|
629
|
-
if (fileLogOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
630
|
-
const ciPathShas = parseCiPathShas(fileLogOut);
|
|
631
|
-
const baseCiCommits = selectCommits(
|
|
632
|
-
commits,
|
|
633
|
-
(c) => isCiCommitMessage(c.message) || ciPathShas.has(c.sha)
|
|
634
|
-
);
|
|
635
|
-
const baseFilesOut = await git(["diff", "--name-only", mergeBase, baseRef]);
|
|
636
|
-
if (baseFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${baseRef}`);
|
|
637
|
-
const baseFiles = splitLines(baseFilesOut);
|
|
638
|
-
const prFilesOut = await git(["diff", "--name-only", mergeBase, meta.headSha]);
|
|
639
|
-
if (prFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
|
|
640
|
-
const prFiles = splitLines(prFilesOut);
|
|
641
|
-
const overlaps = overlappingFiles(prFiles, baseFiles);
|
|
642
|
-
const prDiff = await git(["diff", "--unified=0", mergeBase, meta.headSha]);
|
|
643
|
-
if (prDiff === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
|
|
644
|
-
const diffSignals = breakingDiffSignals(prDiff);
|
|
645
|
-
const labels = meta.labels.map((l) => l.toLowerCase());
|
|
646
|
-
return {
|
|
647
|
-
isCurrent,
|
|
648
|
-
// Each boolean is derived from its detail list — one computation, two views.
|
|
649
|
-
baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
|
|
650
|
-
baseCiSinceMergeBase: baseCiCommits.length > 0,
|
|
651
|
-
// The title's `!` is the only declared input (#82) — pr-policy's title check
|
|
652
|
-
// keeps it in step with the `breaking change` label. The diff can only add a
|
|
653
|
-
// reason to re-test the PR against a moved base (#53).
|
|
654
|
-
prIsBreaking: isBreakingTitle(meta.title) || diffSignals.length > 0,
|
|
655
|
-
prIsDocs: isDocsTitle(meta.title),
|
|
656
|
-
baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
|
|
657
|
-
// Title or path (#67): a release-typed PR that changes CI is still re-tested.
|
|
658
|
-
prIsCi: isCiTitle(meta.title) || prFiles.some(isCiPath),
|
|
659
|
-
fileOverlap: overlaps.length > 0,
|
|
660
|
-
hasConflict: meta.mergeable.toUpperCase() === "CONFLICTING",
|
|
661
|
-
baseCiFailing: failingBaseChecks.length > 0,
|
|
662
|
-
prIsHotfix: labels.includes(HOTFIX_LABEL),
|
|
663
|
-
baseBreakingCommits,
|
|
664
|
-
baseCiCommits,
|
|
665
|
-
overlappingFiles: overlaps,
|
|
666
|
-
failingBaseChecks,
|
|
667
|
-
prBreakingDiffSignals: diffSignals
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
|
|
671
253
|
// src/bin/merge-safety.ts
|
|
672
254
|
var DEFAULT_CALLER_WORKFLOW = "merge-safety.yml";
|
|
673
255
|
function usage() {
|