@rmartz/merge-safety 0.14.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 +75 -425
- 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
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// src/lib/bounded-subprocess.ts
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
function boundedRun(command, args, options) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
const child = spawn(command, args, {
|
|
6
|
+
cwd: options.cwd,
|
|
7
|
+
env: options.env,
|
|
8
|
+
detached: true
|
|
9
|
+
// new process group, so we can kill the whole tree
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
let timedOut = false;
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
timedOut = true;
|
|
16
|
+
try {
|
|
17
|
+
process.kill(-child.pid, "SIGKILL");
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}, options.timeoutMs);
|
|
21
|
+
if (options.input !== void 0 && child.stdin) {
|
|
22
|
+
child.stdin.on("error", () => {
|
|
23
|
+
});
|
|
24
|
+
child.stdin.write(options.input);
|
|
25
|
+
child.stdin.end();
|
|
26
|
+
}
|
|
27
|
+
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
28
|
+
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
29
|
+
child.on("error", (err) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
reject(err);
|
|
32
|
+
});
|
|
33
|
+
child.on("close", (code) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/breaking-diff.ts
|
|
41
|
+
var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
|
|
42
|
+
var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
|
|
43
|
+
var NPM_DEP_RE = /"([^"]+)"\s*:\s*"[\^~]?(\d+)\.(\d+)\.(\d+)/;
|
|
44
|
+
var PIP_DEP_RE = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:==|~=|>=|<=|!=|<|>)\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
|
|
45
|
+
var TEST_FILE_RE = /(\.test\.tsx?|\.spec\.tsx?|test_[^/]*\.py|[^/]*_test\.py)$/;
|
|
46
|
+
var DIFF_HEADER_PATH_RE = / b\/(.+)$/;
|
|
47
|
+
function normalizePackageName(name) {
|
|
48
|
+
return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
|
|
49
|
+
}
|
|
50
|
+
function dependencyFileKind(diffGitLine) {
|
|
51
|
+
if (diffGitLine.includes("package.json")) return "npm";
|
|
52
|
+
if (/requirements[\w./-]*\.txt/.test(diffGitLine)) return "pip";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function isFileHeader(line) {
|
|
56
|
+
return line.startsWith("---") || line.startsWith("+++");
|
|
57
|
+
}
|
|
58
|
+
function dependencyChanges(diffText) {
|
|
59
|
+
const changes = [];
|
|
60
|
+
let kind = null;
|
|
61
|
+
for (const line of diffText.split("\n")) {
|
|
62
|
+
if (line.startsWith("diff --git")) {
|
|
63
|
+
kind = dependencyFileKind(line);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (kind === null) continue;
|
|
67
|
+
if (isFileHeader(line)) continue;
|
|
68
|
+
if (!line.startsWith("-") && !line.startsWith("+")) continue;
|
|
69
|
+
const sign = line[0] === "-" ? "-" : "+";
|
|
70
|
+
const match = (kind === "npm" ? NPM_DEP_RE : PIP_DEP_RE).exec(line.slice(1));
|
|
71
|
+
if (!match) continue;
|
|
72
|
+
const name = match[1] ?? "";
|
|
73
|
+
if (kind === "npm" && NON_DEPENDENCY_NPM_KEYS.has(name.trim().toLowerCase())) continue;
|
|
74
|
+
const major = Number(match[2] ?? 0);
|
|
75
|
+
const minor = Number(match[3] ?? 0);
|
|
76
|
+
const patch = Number(match[4] ?? 0);
|
|
77
|
+
changes.push({ kind, sign, name, major, version: `${major}.${minor}.${patch}` });
|
|
78
|
+
}
|
|
79
|
+
return changes;
|
|
80
|
+
}
|
|
81
|
+
function indexChanges(changes) {
|
|
82
|
+
const removed = /* @__PURE__ */ new Map();
|
|
83
|
+
const added = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
const key = `${change.kind}:${normalizePackageName(change.name)}`;
|
|
86
|
+
(change.sign === "-" ? removed : added).set(key, change);
|
|
87
|
+
}
|
|
88
|
+
return { removed, added };
|
|
89
|
+
}
|
|
90
|
+
function majorVersionBumps(diffText) {
|
|
91
|
+
const { removed, added } = indexChanges(dependencyChanges(diffText));
|
|
92
|
+
const bumps = [];
|
|
93
|
+
for (const [key, addedChange] of added) {
|
|
94
|
+
const removedChange = removed.get(key);
|
|
95
|
+
if (removedChange && addedChange.major > removedChange.major) {
|
|
96
|
+
bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return bumps;
|
|
100
|
+
}
|
|
101
|
+
function sensitivePackageBumps(diffText) {
|
|
102
|
+
const { removed, added } = indexChanges(dependencyChanges(diffText));
|
|
103
|
+
const bumps = [];
|
|
104
|
+
for (const [key, addedChange] of added) {
|
|
105
|
+
if (!CI_SENSITIVE_PACKAGES.has(normalizePackageName(addedChange.name))) continue;
|
|
106
|
+
const removedChange = removed.get(key);
|
|
107
|
+
if (removedChange && removedChange.version !== addedChange.version) {
|
|
108
|
+
bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return bumps;
|
|
112
|
+
}
|
|
113
|
+
function materialTestChanges(diffText) {
|
|
114
|
+
const changed = [];
|
|
115
|
+
let file = null;
|
|
116
|
+
let hasAdds = false;
|
|
117
|
+
let hasRemoves = false;
|
|
118
|
+
const flush = () => {
|
|
119
|
+
if (file && hasAdds && hasRemoves && TEST_FILE_RE.test(file)) changed.push(file);
|
|
120
|
+
};
|
|
121
|
+
for (const line of diffText.split("\n")) {
|
|
122
|
+
if (line.startsWith("diff --git")) {
|
|
123
|
+
flush();
|
|
124
|
+
file = DIFF_HEADER_PATH_RE.exec(line)?.[1] ?? null;
|
|
125
|
+
hasAdds = false;
|
|
126
|
+
hasRemoves = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (isFileHeader(line)) continue;
|
|
130
|
+
if (line.startsWith("+")) hasAdds = true;
|
|
131
|
+
else if (line.startsWith("-")) hasRemoves = true;
|
|
132
|
+
}
|
|
133
|
+
flush();
|
|
134
|
+
return changed;
|
|
135
|
+
}
|
|
136
|
+
function breakingDiffSignals(diffText) {
|
|
137
|
+
const signals = [];
|
|
138
|
+
const major = majorVersionBumps(diffText);
|
|
139
|
+
if (major.length) signals.push({ kind: "major-version-bump", detail: major });
|
|
140
|
+
const sensitive = sensitivePackageBumps(diffText);
|
|
141
|
+
if (sensitive.length) signals.push({ kind: "sensitive-package-bump", detail: sensitive });
|
|
142
|
+
const tests = materialTestChanges(diffText);
|
|
143
|
+
if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
|
|
144
|
+
return signals;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/conventional-commits.ts
|
|
148
|
+
var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
|
|
149
|
+
var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
|
|
150
|
+
var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
|
|
151
|
+
var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
|
|
152
|
+
function firstLine(message) {
|
|
153
|
+
return message.split("\n", 1)[0] ?? "";
|
|
154
|
+
}
|
|
155
|
+
function isBreakingCommitMessage(message) {
|
|
156
|
+
return BREAKING_SUBJECT_RE.test(firstLine(message)) || BREAKING_FOOTER_RE.test(message);
|
|
157
|
+
}
|
|
158
|
+
function isCiCommitMessage(message) {
|
|
159
|
+
return CI_SUBJECT_RE.test(firstLine(message));
|
|
160
|
+
}
|
|
161
|
+
function isDocsCommitMessage(message) {
|
|
162
|
+
return DOCS_SUBJECT_RE.test(firstLine(message));
|
|
163
|
+
}
|
|
164
|
+
function isBreakingTitle(title) {
|
|
165
|
+
return BREAKING_SUBJECT_RE.test(title.trim());
|
|
166
|
+
}
|
|
167
|
+
function isCiTitle(title) {
|
|
168
|
+
return CI_SUBJECT_RE.test(title.trim());
|
|
169
|
+
}
|
|
170
|
+
function isDocsTitle(title) {
|
|
171
|
+
return DOCS_SUBJECT_RE.test(title.trim());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/reasons.ts
|
|
175
|
+
var BREAKING_DIFF_REASONS = {
|
|
176
|
+
"major-version-bump": "dependency major bump",
|
|
177
|
+
"sensitive-package-bump": "CI-sensitive package version change",
|
|
178
|
+
"material-test-changes": "existing test file modified"
|
|
179
|
+
};
|
|
180
|
+
function nestedBullets(items) {
|
|
181
|
+
return items.map((item) => ` - ${item}`).join("\n");
|
|
182
|
+
}
|
|
183
|
+
function formatBaseCommit(commit) {
|
|
184
|
+
return `${commit.sha.slice(0, 7)} ${commit.subject}`;
|
|
185
|
+
}
|
|
186
|
+
function withDetail(sentence, detail) {
|
|
187
|
+
return detail.length ? `${sentence}
|
|
188
|
+
${nestedBullets(detail)}` : sentence;
|
|
189
|
+
}
|
|
190
|
+
function breakingSignalDetail(signals) {
|
|
191
|
+
return signals.flatMap((s) => s.detail.map((d) => `${BREAKING_DIFF_REASONS[s.kind]}: ${d}`));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/staleness.ts
|
|
195
|
+
function evaluateStaleness(facts) {
|
|
196
|
+
const reasons = [];
|
|
197
|
+
if (facts.isCurrent) return { needsUpdate: false, reasons };
|
|
198
|
+
const baseBreaking = facts.baseBreakingSinceMergeBase && !(facts.prIsDocs && !facts.prIsBreaking);
|
|
199
|
+
const prBreaking = facts.prIsBreaking && !facts.baseOnlyDocsSinceMergeBase;
|
|
200
|
+
const prCi = facts.prIsCi && !facts.prIsHotfix;
|
|
201
|
+
if (baseBreaking) {
|
|
202
|
+
reasons.push(
|
|
203
|
+
withDetail(
|
|
204
|
+
"A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
|
|
205
|
+
facts.baseBreakingCommits.map(formatBaseCommit)
|
|
206
|
+
)
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (prBreaking) {
|
|
210
|
+
reasons.push(
|
|
211
|
+
withDetail(
|
|
212
|
+
"This PR is a breaking change \u2014 it must be current with the base before merge.",
|
|
213
|
+
breakingSignalDetail(facts.prBreakingDiffSignals)
|
|
214
|
+
)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (prCi) {
|
|
218
|
+
reasons.push(
|
|
219
|
+
"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."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (facts.baseCiSinceMergeBase) {
|
|
223
|
+
reasons.push(
|
|
224
|
+
withDetail(
|
|
225
|
+
"A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
|
|
226
|
+
facts.baseCiCommits.map(formatBaseCommit)
|
|
227
|
+
)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (facts.fileOverlap) {
|
|
231
|
+
reasons.push(
|
|
232
|
+
withDetail(
|
|
233
|
+
"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:",
|
|
234
|
+
facts.overlappingFiles
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const needsUpdate = baseBreaking || facts.baseCiSinceMergeBase || prBreaking || prCi || facts.fileOverlap;
|
|
239
|
+
return { needsUpdate, reasons };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/base-health.ts
|
|
243
|
+
var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
|
|
244
|
+
var GITHUB_ACTIONS_APP_SLUG = "github-actions";
|
|
245
|
+
var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
|
|
246
|
+
function isGitHubActionsCheck(check) {
|
|
247
|
+
return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
|
|
248
|
+
}
|
|
249
|
+
function isNonBuildPlatformCheck(name) {
|
|
250
|
+
return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
|
|
251
|
+
}
|
|
252
|
+
function isFailingCiConclusion(conclusion) {
|
|
253
|
+
return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
|
|
254
|
+
}
|
|
255
|
+
function failingRequiredBaseChecks(checks, requiredContexts) {
|
|
256
|
+
if (!requiredContexts || requiredContexts.length === 0) return [];
|
|
257
|
+
const required = new Set(requiredContexts);
|
|
258
|
+
return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
|
|
259
|
+
}
|
|
260
|
+
function failingFallbackBaseChecks(checks) {
|
|
261
|
+
return checks.filter(
|
|
262
|
+
(c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
|
|
263
|
+
).map((c) => c.name);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/merge-safety.ts
|
|
267
|
+
var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
|
|
268
|
+
function isEvaluablePrState(state) {
|
|
269
|
+
return state === "OPEN";
|
|
270
|
+
}
|
|
271
|
+
var HOTFIX_LABEL = "hotfix";
|
|
272
|
+
function overlappingFiles(prFiles, baseFiles) {
|
|
273
|
+
if (!prFiles.length || !baseFiles.length) return [];
|
|
274
|
+
const base = new Set(baseFiles);
|
|
275
|
+
return prFiles.filter((f) => base.has(f));
|
|
276
|
+
}
|
|
277
|
+
function evaluateMergeSafety(facts) {
|
|
278
|
+
const reasons = [];
|
|
279
|
+
if (facts.hasConflict) {
|
|
280
|
+
reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
|
|
281
|
+
}
|
|
282
|
+
const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
|
|
283
|
+
if (baseUnhealthy) {
|
|
284
|
+
reasons.push(
|
|
285
|
+
withDetail(
|
|
286
|
+
"The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
|
|
287
|
+
facts.failingBaseChecks
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
|
|
292
|
+
reasons.push(...stalenessReasons);
|
|
293
|
+
const needsAction = facts.hasConflict || baseUnhealthy;
|
|
294
|
+
const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
|
|
295
|
+
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.");
|
|
296
|
+
const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : "No update required";
|
|
297
|
+
const add = [];
|
|
298
|
+
if (needsUpdate) add.push("update required");
|
|
299
|
+
if (facts.hasConflict) add.push("merge conflict");
|
|
300
|
+
const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
|
|
301
|
+
return {
|
|
302
|
+
conclusion,
|
|
303
|
+
needsUpdate,
|
|
304
|
+
hasConflict: facts.hasConflict,
|
|
305
|
+
baseUnhealthy,
|
|
306
|
+
title,
|
|
307
|
+
reasons,
|
|
308
|
+
summary,
|
|
309
|
+
labels: { add, remove }
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function errorMergeSafetyDecision(message) {
|
|
313
|
+
return {
|
|
314
|
+
conclusion: "failure",
|
|
315
|
+
needsUpdate: false,
|
|
316
|
+
hasConflict: false,
|
|
317
|
+
baseUnhealthy: false,
|
|
318
|
+
title: "Could not evaluate",
|
|
319
|
+
reasons: [message],
|
|
320
|
+
summary: `Could not evaluate merge safety: ${message}`,
|
|
321
|
+
labels: { add: [], remove: [] }
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/merge-safety-facts.ts
|
|
326
|
+
var GIT_TIMEOUT_MS = 3e4;
|
|
327
|
+
function baseBranchName(baseRef) {
|
|
328
|
+
return baseRef.replace(/^refs\/heads\//, "").replace(/^origin\//, "");
|
|
329
|
+
}
|
|
330
|
+
function makeGitRunner(cwd) {
|
|
331
|
+
return async (args) => {
|
|
332
|
+
const r = await boundedRun("git", args, { timeoutMs: GIT_TIMEOUT_MS, cwd });
|
|
333
|
+
return r.code === 0 ? r.stdout : null;
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function splitLines(out) {
|
|
337
|
+
return (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
function parseBaseCommits(logOut) {
|
|
340
|
+
return logOut.split("\0").map((record) => record.replace(/^\n+/, "")).filter((record) => record.trim().length > 0).map((record) => {
|
|
341
|
+
const newline = record.indexOf("\n");
|
|
342
|
+
const sha = (newline === -1 ? record : record.slice(0, newline)).trim();
|
|
343
|
+
const message = newline === -1 ? "" : record.slice(newline + 1).trim();
|
|
344
|
+
return { sha, message };
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
var CI_PATH_RE = /^\.github\/(?:workflows|actions)\//;
|
|
348
|
+
function isCiPath(path) {
|
|
349
|
+
return CI_PATH_RE.test(path);
|
|
350
|
+
}
|
|
351
|
+
function parseCiPathShas(logOut) {
|
|
352
|
+
const shas = /* @__PURE__ */ new Set();
|
|
353
|
+
for (const record of logOut.split("\0")) {
|
|
354
|
+
const [sha, ...files] = splitLines(record);
|
|
355
|
+
if (sha && files.some(isCiPath)) shas.add(sha);
|
|
356
|
+
}
|
|
357
|
+
return shas;
|
|
358
|
+
}
|
|
359
|
+
function selectCommits(commits, predicate) {
|
|
360
|
+
return commits.filter(predicate).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
|
|
361
|
+
}
|
|
362
|
+
async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, baseChecks, requiredChecks }) {
|
|
363
|
+
const mergeBase = (await git(["merge-base", meta.headSha, baseRef]))?.trim();
|
|
364
|
+
const baseTip = (await git(["rev-parse", baseRef]))?.trim();
|
|
365
|
+
if (!mergeBase || !baseTip) {
|
|
366
|
+
throw new Error(`could not resolve merge-base/base tip for ${meta.headSha}..${baseRef}`);
|
|
367
|
+
}
|
|
368
|
+
const isCurrent = mergeBase === baseTip;
|
|
369
|
+
const baseBranch = baseBranchName(baseRef);
|
|
370
|
+
const checks = await baseChecks(baseTip);
|
|
371
|
+
const required = await requiredChecks(baseBranch);
|
|
372
|
+
const failingBaseChecks = required && required.length > 0 ? failingRequiredBaseChecks(checks, required) : failingFallbackBaseChecks(checks);
|
|
373
|
+
const logOut = await git(["log", "-z", "--format=%H%n%B", `${mergeBase}..${baseRef}`]);
|
|
374
|
+
if (logOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
375
|
+
const commits = parseBaseCommits(logOut);
|
|
376
|
+
const baseBreakingCommits = selectCommits(commits, (c) => isBreakingCommitMessage(c.message));
|
|
377
|
+
const fileLogOut = await git([
|
|
378
|
+
"log",
|
|
379
|
+
"--format=%x00%H",
|
|
380
|
+
"--name-only",
|
|
381
|
+
`${mergeBase}..${baseRef}`
|
|
382
|
+
]);
|
|
383
|
+
if (fileLogOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
|
|
384
|
+
const ciPathShas = parseCiPathShas(fileLogOut);
|
|
385
|
+
const baseCiCommits = selectCommits(
|
|
386
|
+
commits,
|
|
387
|
+
(c) => isCiCommitMessage(c.message) || ciPathShas.has(c.sha)
|
|
388
|
+
);
|
|
389
|
+
const baseFilesOut = await git(["diff", "--name-only", mergeBase, baseRef]);
|
|
390
|
+
if (baseFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${baseRef}`);
|
|
391
|
+
const baseFiles = splitLines(baseFilesOut);
|
|
392
|
+
const prFilesOut = await git(["diff", "--name-only", mergeBase, meta.headSha]);
|
|
393
|
+
if (prFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
|
|
394
|
+
const prFiles = splitLines(prFilesOut);
|
|
395
|
+
const overlaps = overlappingFiles(prFiles, baseFiles);
|
|
396
|
+
const prDiff = await git(["diff", "--unified=0", mergeBase, meta.headSha]);
|
|
397
|
+
if (prDiff === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
|
|
398
|
+
const diffSignals = breakingDiffSignals(prDiff);
|
|
399
|
+
const labels = meta.labels.map((l) => l.toLowerCase());
|
|
400
|
+
return {
|
|
401
|
+
isCurrent,
|
|
402
|
+
// Each boolean is derived from its detail list — one computation, two views.
|
|
403
|
+
baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
|
|
404
|
+
baseCiSinceMergeBase: baseCiCommits.length > 0,
|
|
405
|
+
// The title's `!` is the only declared input (#82) — pr-policy's title check
|
|
406
|
+
// keeps it in step with the `breaking change` label. The diff can only add a
|
|
407
|
+
// reason to re-test the PR against a moved base (#53).
|
|
408
|
+
prIsBreaking: isBreakingTitle(meta.title) || diffSignals.length > 0,
|
|
409
|
+
prIsDocs: isDocsTitle(meta.title),
|
|
410
|
+
baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
|
|
411
|
+
// Title or path (#67): a release-typed PR that changes CI is still re-tested.
|
|
412
|
+
prIsCi: isCiTitle(meta.title) || prFiles.some(isCiPath),
|
|
413
|
+
fileOverlap: overlaps.length > 0,
|
|
414
|
+
hasConflict: meta.mergeable.toUpperCase() === "CONFLICTING",
|
|
415
|
+
baseCiFailing: failingBaseChecks.length > 0,
|
|
416
|
+
prIsHotfix: labels.includes(HOTFIX_LABEL),
|
|
417
|
+
baseBreakingCommits,
|
|
418
|
+
baseCiCommits,
|
|
419
|
+
overlappingFiles: overlaps,
|
|
420
|
+
failingBaseChecks,
|
|
421
|
+
prBreakingDiffSignals: diffSignals
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/api-fact-source.ts
|
|
426
|
+
var COMPARE_MAX_COMMITS = 250;
|
|
427
|
+
var COMPARE_MAX_FILES = 300;
|
|
428
|
+
var COMMIT_MAX_FILES = 300;
|
|
429
|
+
async function mapBounded(items, limit, fn) {
|
|
430
|
+
const results = new Array(items.length);
|
|
431
|
+
let next = 0;
|
|
432
|
+
const worker = async () => {
|
|
433
|
+
while (next < items.length) {
|
|
434
|
+
const i = next++;
|
|
435
|
+
results[i] = await fn(items[i]);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
439
|
+
return results;
|
|
440
|
+
}
|
|
441
|
+
function requireFiles(files, what) {
|
|
442
|
+
if (!Array.isArray(files)) throw new Error(`the GitHub API returned no file list for ${what}`);
|
|
443
|
+
return files;
|
|
444
|
+
}
|
|
445
|
+
function compareFiles(compare, side) {
|
|
446
|
+
const files = side === "base" && compare.total_commits === 0 && compare.files === void 0 ? [] : requireFiles(compare.files, `the ${side} diff`);
|
|
447
|
+
if (files.length >= COMPARE_MAX_FILES) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`${side} diff lists ${files.length} files, at the compare API's ${COMPARE_MAX_FILES}-file cap`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return files;
|
|
453
|
+
}
|
|
454
|
+
function synthesizePatch(files) {
|
|
455
|
+
return files.map((f) => {
|
|
456
|
+
const header = `diff --git a/${f.previous_filename ?? f.filename} b/${f.filename}`;
|
|
457
|
+
if (f.patch !== void 0) return `${header}
|
|
458
|
+
${f.patch}
|
|
459
|
+
`;
|
|
460
|
+
if (f.changes !== 0) {
|
|
461
|
+
throw new Error(`the compare API omitted the patch for ${f.filename}`);
|
|
462
|
+
}
|
|
463
|
+
return `${header}
|
|
464
|
+
`;
|
|
465
|
+
}).join("");
|
|
466
|
+
}
|
|
467
|
+
function makeApiGitRunner({ repo, api, concurrency = 8 }) {
|
|
468
|
+
const tips = /* @__PURE__ */ new Map();
|
|
469
|
+
const comparisons = /* @__PURE__ */ new Map();
|
|
470
|
+
const loaded = [];
|
|
471
|
+
async function get(path) {
|
|
472
|
+
const body = await api(path);
|
|
473
|
+
if (body === null || body === void 0) throw new Error(`GitHub API read failed: ${path}`);
|
|
474
|
+
return body;
|
|
475
|
+
}
|
|
476
|
+
async function loadTip(baseRef) {
|
|
477
|
+
const branch = encodeURIComponent(baseBranchName(baseRef));
|
|
478
|
+
const b = await get(`repos/${repo}/branches/${branch}`);
|
|
479
|
+
return b.commit.sha;
|
|
480
|
+
}
|
|
481
|
+
function resolveTip(baseRef) {
|
|
482
|
+
let tip = tips.get(baseRef);
|
|
483
|
+
if (!tip) {
|
|
484
|
+
tip = loadTip(baseRef);
|
|
485
|
+
tips.set(baseRef, tip);
|
|
486
|
+
}
|
|
487
|
+
return tip;
|
|
488
|
+
}
|
|
489
|
+
async function loadComparison(head, baseRef) {
|
|
490
|
+
const tip = await resolveTip(baseRef);
|
|
491
|
+
const base = await get(`repos/${repo}/compare/${head}...${tip}`);
|
|
492
|
+
if (base.total_commits > base.commits.length) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`base moved by ${base.total_commits} commits, past the compare API's ${COMPARE_MAX_COMMITS}-commit cap`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const comparison = {
|
|
498
|
+
head,
|
|
499
|
+
baseRef,
|
|
500
|
+
tip,
|
|
501
|
+
mergeBase: base.merge_base_commit.sha,
|
|
502
|
+
base
|
|
503
|
+
};
|
|
504
|
+
loaded.push(comparison);
|
|
505
|
+
return comparison;
|
|
506
|
+
}
|
|
507
|
+
function compare(head, baseRef) {
|
|
508
|
+
const key = `${head}\0${baseRef}`;
|
|
509
|
+
let c = comparisons.get(key);
|
|
510
|
+
if (!c) {
|
|
511
|
+
c = loadComparison(head, baseRef);
|
|
512
|
+
comparisons.set(key, c);
|
|
513
|
+
}
|
|
514
|
+
return c;
|
|
515
|
+
}
|
|
516
|
+
function find(mergeBase, match) {
|
|
517
|
+
const c = loaded.find((l) => l.mergeBase === mergeBase && match(l));
|
|
518
|
+
if (!c) throw new Error(`no comparison loaded for merge base ${mergeBase}`);
|
|
519
|
+
return c;
|
|
520
|
+
}
|
|
521
|
+
function baseCommits(c) {
|
|
522
|
+
return [...c.base.commits].reverse();
|
|
523
|
+
}
|
|
524
|
+
async function loadPrFiles(c) {
|
|
525
|
+
const pr = await get(`repos/${repo}/compare/${c.tip}...${c.head}`);
|
|
526
|
+
if (pr.merge_base_commit.sha !== c.mergeBase) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`the PR and base compares disagree on the merge base (${pr.merge_base_commit.sha} vs ${c.mergeBase})`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return compareFiles(pr, "PR");
|
|
532
|
+
}
|
|
533
|
+
function prFiles(c) {
|
|
534
|
+
c.pr ??= loadPrFiles(c);
|
|
535
|
+
return c.pr;
|
|
536
|
+
}
|
|
537
|
+
async function commitRecord(commit) {
|
|
538
|
+
if ((commit.parents?.length ?? 1) > 1) return `\0${commit.sha}
|
|
539
|
+
`;
|
|
540
|
+
const full = await get(`repos/${repo}/commits/${commit.sha}`);
|
|
541
|
+
const files = requireFiles(full.files, `base commit ${commit.sha}`).map((f) => f.filename);
|
|
542
|
+
if (files.length >= COMMIT_MAX_FILES && !files.some(isCiPath)) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`base commit ${commit.sha} lists ${files.length} files, at the commit API's ${COMMIT_MAX_FILES}-file cap`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return `\0${commit.sha}
|
|
548
|
+
|
|
549
|
+
${files.join("\n")}
|
|
550
|
+
`;
|
|
551
|
+
}
|
|
552
|
+
async function loadCiLog(c) {
|
|
553
|
+
const records = await mapBounded(baseCommits(c), concurrency, commitRecord);
|
|
554
|
+
return records.join("");
|
|
555
|
+
}
|
|
556
|
+
function ciLog(c) {
|
|
557
|
+
c.ciLog ??= loadCiLog(c);
|
|
558
|
+
return c.ciLog;
|
|
559
|
+
}
|
|
560
|
+
return async (args) => {
|
|
561
|
+
const [cmd, ...rest] = args;
|
|
562
|
+
const joined = rest.join(" ");
|
|
563
|
+
if (cmd === "merge-base" && rest.length === 2) {
|
|
564
|
+
return (await compare(rest[0], rest[1])).mergeBase;
|
|
565
|
+
}
|
|
566
|
+
if (cmd === "rev-parse" && rest.length === 1) return resolveTip(rest[0]);
|
|
567
|
+
const range = /^(\S+)\.\.(\S+)$/.exec(rest[rest.length - 1] ?? "");
|
|
568
|
+
if (cmd === "log" && range) {
|
|
569
|
+
const mergeBase = range[1] ?? "";
|
|
570
|
+
const baseRef = range[2] ?? "";
|
|
571
|
+
const c = find(mergeBase, (l) => l.baseRef === baseRef);
|
|
572
|
+
if (joined === `-z --format=%H%n%B ${mergeBase}..${baseRef}`) {
|
|
573
|
+
return baseCommits(c).map((commit) => `${commit.sha}
|
|
574
|
+
${commit.commit.message}`).join("\0");
|
|
575
|
+
}
|
|
576
|
+
if (joined === `--format=%x00%H --name-only ${mergeBase}..${baseRef}`) return ciLog(c);
|
|
577
|
+
}
|
|
578
|
+
if (cmd === "diff" && rest.length === 3) {
|
|
579
|
+
const [flag, mergeBase, ref] = rest;
|
|
580
|
+
if (flag === "--name-only") {
|
|
581
|
+
const c = find(mergeBase, (l) => l.baseRef === ref || l.head === ref);
|
|
582
|
+
const files = c.baseRef === ref ? compareFiles(c.base, "base") : await prFiles(c);
|
|
583
|
+
return files.map((f) => f.filename).join("\n");
|
|
584
|
+
}
|
|
585
|
+
if (flag === "--unified=0") {
|
|
586
|
+
return synthesizePatch(await prFiles(find(mergeBase, (l) => l.head === ref)));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
throw new Error(`the API fact source does not support \`git ${args.join(" ")}\``);
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/index.ts
|
|
594
|
+
var MERGE_SAFETY_CHECK_NAME = "merge-safety";
|
|
595
|
+
var MERGE_SAFETY_COMMANDS = ["evaluate", "invalidate"];
|
|
596
|
+
function isMergeSafetyCommand(value) {
|
|
597
|
+
return value !== void 0 && MERGE_SAFETY_COMMANDS.includes(value);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export {
|
|
601
|
+
boundedRun,
|
|
602
|
+
isEvaluablePrState,
|
|
603
|
+
evaluateMergeSafety,
|
|
604
|
+
errorMergeSafetyDecision,
|
|
605
|
+
makeGitRunner,
|
|
606
|
+
gatherMergeSafetyFacts,
|
|
607
|
+
COMPARE_MAX_COMMITS,
|
|
608
|
+
COMPARE_MAX_FILES,
|
|
609
|
+
COMMIT_MAX_FILES,
|
|
610
|
+
makeApiGitRunner,
|
|
611
|
+
MERGE_SAFETY_CHECK_NAME,
|
|
612
|
+
MERGE_SAFETY_COMMANDS,
|
|
613
|
+
isMergeSafetyCommand
|
|
614
|
+
};
|