@rmartz/merge-safety 0.6.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 ADDED
@@ -0,0 +1,122 @@
1
+ # @rmartz/merge-safety
2
+
3
+ The **pre-auto-merge safety verdict** for a pull request, packaged so a repo can
4
+ use GitHub's native auto-merge safely. merge-safety gathers base-currency,
5
+ breaking-change, and conflict facts for a PR, posts them as the `merge-safety`
6
+ check-run, and re-holds every open PR when the base branch moves — so a PR only
7
+ auto-merges once it is genuinely safe against the current base.
8
+
9
+ It is distributed the same way [`@rmartz/repo-hygiene`](https://github.com/rmartz/repo-hygiene)
10
+ is:
11
+
12
+ 1. **Updates propagate automatically.** Consuming repos pin one reusable workflow
13
+ by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin.
14
+ 2. **The check-run name is a fleet contract.** Every consumer requires a status
15
+ check named exactly `merge-safety`; see
16
+ [docs/check-run-contract.md](docs/check-run-contract.md).
17
+
18
+ > **Status: released.** `v0.1.0` ships the full `evaluate` / `invalidate`
19
+ > implementation and the `ai-merge-safety` CLI — the package is functional and
20
+ > ready to adopt. It was split out of `@rmartz/pr-review` per
21
+ > [ai-tools#247](https://github.com/rmartz/ai-tools/issues/247); see
22
+ > [docs/migration.md](docs/migration.md) for the record of how it was extracted.
23
+
24
+ ## Using it in a consuming repo
25
+
26
+ Add one caller workflow (this is what Dependabot keeps current). Unlike a
27
+ read-only hygiene check, this caller carries the triggers, grants write scopes,
28
+ and passes secrets through — because a reusable workflow can't declare its own
29
+ triggers and runs with the intersection of granted and declared permissions:
30
+
31
+ ```yaml
32
+ # .github/workflows/merge-safety.yml
33
+ name: merge-safety
34
+ on:
35
+ pull_request:
36
+ types: [opened, synchronize, reopened, edited, labeled, unlabeled]
37
+ push:
38
+ branches: [main]
39
+ workflow_dispatch:
40
+ inputs:
41
+ pr:
42
+ description: PR number to evaluate
43
+ required: true
44
+ permissions:
45
+ checks: write
46
+ pull-requests: write
47
+ contents: read
48
+ actions: write
49
+ packages: read
50
+ jobs:
51
+ merge-safety:
52
+ uses: rmartz/merge-safety/.github/workflows/merge-safety.yml@<sha> # vX.Y.Z
53
+ with:
54
+ pr: ${{ inputs.pr }}
55
+ secrets: inherit
56
+ ```
57
+
58
+ Then require the `merge-safety` status check on the default branch. Both the
59
+ caller and the Dependabot entry are seeded once by
60
+ [`@rmartz/bootstrap`](https://github.com/rmartz/ai-tools)
61
+ (`ai-ensure-project-config`); after that Dependabot maintains the pin. The public
62
+ `@rmartz/merge-safety` package on GitHub Packages is readable with the built-in
63
+ `GITHUB_TOKEN`, so no consumer PAT is required.
64
+
65
+ > For the full walkthrough — the caller's permissions, why it isn't trigger-free,
66
+ > and how to verify the setup — see the
67
+ > [consumer setup guide](docs/consuming.md).
68
+
69
+ ## Requirements
70
+
71
+ - Node.js >= 20.11
72
+ - pnpm 9 (pinned via `packageManager`)
73
+
74
+ Consuming repos need neither — the reusable workflow runs the published CLI on a
75
+ GitHub-hosted runner.
76
+
77
+ ## Local development
78
+
79
+ ```bash
80
+ pnpm install
81
+ pnpm run build # tsup → dist (ESM + d.ts)
82
+ pnpm run typecheck
83
+ pnpm run lint
84
+ pnpm run format:check
85
+ pnpm run test # vitest
86
+ ```
87
+
88
+ ## Releases
89
+
90
+ Fully automatic via [`semantic-release`](https://semantic-release.gitbook.io) — no
91
+ manual `pnpm version` step. Every push to `main` runs
92
+ [`release.yml`](.github/workflows/release.yml): it analyzes the conventional-commit
93
+ subjects since the last `vX.Y.Z` tag (the squash-merged **PR title** is that subject),
94
+ computes the next version, builds and publishes the package to GitHub Packages
95
+ (public), and creates the git tag + GitHub Release with generated notes — using only
96
+ the built-in `GITHUB_TOKEN`, no release-please and no PAT. There is deliberately **no
97
+ `@semantic-release/git`**: `package.json`'s `version` is a frozen `0.0.0` placeholder
98
+ and is never committed back. The version installed by the reusable workflow is
99
+ resolved at runtime from the **release tag at its own pinned commit**, so it lives
100
+ only in the git tag. Config: [`.releaserc.json`](.releaserc.json).
101
+
102
+ **Version mapping (v0):** `feat:` → minor; `fix:`/`perf:` → patch. Pre-1.0, a
103
+ breaking change (`!`) is **capped at minor** so it can't auto-jump to `1.0.0`;
104
+ leaving v0 (cutting `1.0.0`) is a deliberate manual act. Dependabot uses the
105
+ split-prefix convention (rmartz/ai#82): a **production** bump arrives as
106
+ `fix(deps):` → patch (it ships to users); **dev** (`chore(deps):`) and
107
+ **github-actions** (`chore(github-actions):`) bumps are release-less — as a
108
+ zero-runtime-dependency package, all Dependabot bumps are dev-only in practice.
109
+
110
+ Three guards back the automatic flow: [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml)
111
+ (pre-merge title format), [`commit-convention.yml`](.github/workflows/commit-convention.yml)
112
+ (post-merge tripwire for a non-conventional subject that would make semantic-release
113
+ silently skip), and, in [`ci.yml`](.github/workflows/ci.yml), the `Release notes render`
114
+ job (renders the notes through the real changelog toolchain so an incompatible
115
+ preset/writer pairing fails the PR) and the `Package`
116
+ job (packs exactly what `pnpm publish` would upload and fails if the tarball is
117
+ malformed or missing an `exports`/`bin` entry point) — so both a broken release config
118
+ and a broken packaging manifest are caught before merge, not on the real release run.
119
+
120
+ ---
121
+
122
+ 🤖 Created by Claude Opus 4.8
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import { MergeSafetyCommand } from '../index.js';
3
+
4
+ /** The open PR that heads a branch, reduced to what the barrier needs. */
5
+ interface BasePr {
6
+ number: number;
7
+ /** The PR's label names, matched case-insensitively against the exempt set. */
8
+ labels: readonly string[];
9
+ }
10
+
11
+ /**
12
+ * Base-health classification — "is the base branch's own CI failing?", the
13
+ * PR-independent axis of the merge-safety verdict.
14
+ *
15
+ * Extracted from `merge-safety.ts` when the self-derived breaking-change signal
16
+ * (#53) pushed that file past its 480-line cap. It is a genuinely separate
17
+ * concern: every other clause of the verdict reads *this PR* against *its base*,
18
+ * while these functions read only the base tip's check-runs and know nothing
19
+ * about any PR. Pure — the network boundary stays in the bin's probes.
20
+ */
21
+ /**
22
+ * A base-tip check-run reduced to what base-health classification needs: its name
23
+ * (matched against the base branch's required status checks), its conclusion, and
24
+ * the slug of the producing GitHub App (used by the fallback heuristic to tell a
25
+ * GitHub Actions CI run from an external deploy integration).
26
+ */
27
+ interface BaseCheckRun {
28
+ /** The check-run name (e.g. the workflow / job name), matched to a required context. */
29
+ name: string;
30
+ /** The check-run conclusion, or `null` while still in progress. */
31
+ conclusion: string | null;
32
+ /** The producing GitHub App's slug (e.g. `github-actions`, `vercel`), or `null`. */
33
+ appSlug: string | null;
34
+ }
35
+
36
+ /**
37
+ * Fetches the base branch tip's check-runs (deduped to the latest per name) so
38
+ * base-health can be judged. Injected like {@link GitRunner} so the assembly logic
39
+ * stays testable with a fake and the network boundary lives in the bin. Soft-fails
40
+ * to `[]` (an unreadable base is treated as *not* failing) so a transient `gh`
41
+ * error never wedges the whole merge queue on a base-health false positive.
42
+ */
43
+ type BaseChecksProbe = (baseSha: string) => Promise<readonly BaseCheckRun[]>;
44
+ /**
45
+ * Fetches the base branch's **required status check** contexts — the set base-health
46
+ * scopes failures to (#40). Returns `null` when protection can't be read (transient
47
+ * `gh` error, no ruleset, missing scope); a `null` or empty set means base-health
48
+ * reports no failure, so a probe hiccup never wedges the merge queue. Injected like
49
+ * {@link BaseChecksProbe}, with the network boundary in the bin.
50
+ */
51
+ type RequiredChecksProbe = (baseBranch: string) => Promise<readonly string[] | null>;
52
+ /**
53
+ * Fetches the open PR whose **head** is a given branch — this PR's stacked parent
54
+ * (#54) — or `null` when no open PR heads it. A failed probe also yields `null`,
55
+ * which deliberately reads as "not stacked": an unreadable lookup must not hold a
56
+ * PR that may not be stacked at all, the same never-wedge posture as the check
57
+ * probes above. Injected like {@link BaseChecksProbe}.
58
+ */
59
+ type BasePrProbe = (baseBranch: string) => Promise<BasePr | null>;
60
+
61
+ interface Args {
62
+ mode: MergeSafetyCommand;
63
+ pr?: number;
64
+ exclude?: number;
65
+ repo?: string;
66
+ baseRef: string;
67
+ /**
68
+ * The consumer's caller workflow filename that `invalidate` re-dispatches per PR
69
+ * (issue #247's dispatch-target wrinkle: a reusable workflow's fan-out targets
70
+ * the *caller* file, not this reusable file). Defaults to the conventional name.
71
+ */
72
+ workflow: string;
73
+ cwd?: string;
74
+ /**
75
+ * The repository default branch, used by the stacked-PR barrier (#54). Absent →
76
+ * resolved from `gh repo view`; unresolvable → the barrier is disabled rather
77
+ * than holding every PR.
78
+ */
79
+ defaultBranch?: string;
80
+ /** Labels on a base PR that exempt its children from the barrier (#54). */
81
+ exemptBaseLabels: readonly string[];
82
+ /** Decision-only: print the verdict as JSON and perform no side effects. */
83
+ json: boolean;
84
+ }
85
+ /**
86
+ * A real base-checks probe: fetch the base tip's check-runs from the GitHub Checks
87
+ * API (deduped to the latest run per name via `?filter=latest`) and reduce each to
88
+ * the {@link BaseCheckRun} shape base-health classifies. `--jq` streams the array
89
+ * as JSONL so `--paginate` can concatenate pages for a base with many checks.
90
+ * Soft-fails to `[]` on any read error, so a transient `gh` failure never becomes
91
+ * a base-health false positive that wedges the merge queue.
92
+ */
93
+ declare function makeBaseChecksProbe(repo: string, cwd?: string): BaseChecksProbe;
94
+ /**
95
+ * A real required-checks probe: fetch the base branch's **required status check**
96
+ * contexts from the repository rulesets that apply to it (`/rules/branches/{branch}`,
97
+ * the endpoint that surfaces ruleset-declared rules the way this fleet configures
98
+ * them). `--jq` streams each `required_status_checks` rule's contexts as lines,
99
+ * deduped here. Soft-fails to `null` on any read error (no ruleset, missing scope,
100
+ * transient `gh` failure) so base-health degrades to "nothing gates" rather than
101
+ * wedging the queue on an unreadable protection config (#40).
102
+ */
103
+ declare function makeRequiredChecksProbe(repo: string, cwd?: string): RequiredChecksProbe;
104
+ /**
105
+ * A real base-PR probe: the open PR whose **head** is `baseBranch`, i.e. this PR's
106
+ * stacked parent (#54). Soft-fails to `null` (not stacked) on any read error or
107
+ * malformed payload, so a transient `gh` failure never holds a PR that may not be
108
+ * stacked at all. `--limit 1` suffices: a branch heads at most one open PR.
109
+ */
110
+ declare function makeBasePrProbe(repo: string, cwd?: string): BasePrProbe;
111
+ declare function runEvaluate(repo: string, pr: number, args: Args): Promise<void>;
112
+ declare function runInvalidate(repo: string, args: Args): Promise<void>;
113
+
114
+ export { type Args, makeBaseChecksProbe, makeBasePrProbe, makeRequiredChecksProbe, runEvaluate, runInvalidate };
@@ -0,0 +1,892 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ MERGE_SAFETY_CHECK_NAME,
4
+ isMergeSafetyCommand
5
+ } from "../chunk-ITPKHZGC.js";
6
+
7
+ // src/bin/merge-safety.ts
8
+ import { realpathSync } from "fs";
9
+ import { fileURLToPath } from "url";
10
+
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
+ // src/lib/github.ts
51
+ var GH_API_TIMEOUT_MS = 3e4;
52
+ var MAX_RETRIES = 2;
53
+ var BACKOFF_BASE_MS = 1e3;
54
+ var BACKOFF_CAP_MS = 8e3;
55
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
56
+ function isRateLimited(text) {
57
+ const t = (text ?? "").toLowerCase();
58
+ return t.includes("rate limit") || t.includes("rate-limit");
59
+ }
60
+ async function runTransport({ argv, stdin }, cwd) {
61
+ const [command, ...args] = argv;
62
+ if (command === void 0) return { stdout: null, stderr: "empty argv" };
63
+ try {
64
+ const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
65
+ if (r.code === 0) return { stdout: r.stdout, stderr: "" };
66
+ return { stdout: null, stderr: r.stderr || "" };
67
+ } catch (err) {
68
+ return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
69
+ }
70
+ }
71
+ async function ghCall(primary, fallback, opts = {}) {
72
+ const sleep = opts.sleep ?? realSleep;
73
+ for (const transport of [primary, fallback]) {
74
+ if (!transport) continue;
75
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
76
+ const { stdout, stderr } = await runTransport(transport, opts.cwd);
77
+ if (stdout !== null) return stdout;
78
+ if (isRateLimited(stderr)) break;
79
+ if (attempt < MAX_RETRIES) {
80
+ await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
81
+ }
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ function parseSlugFromRemoteUrl(url) {
87
+ const trimmed = url.trim();
88
+ const match = trimmed.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
89
+ if (match) return `${match[1]}/${match[2]}`;
90
+ const bare = trimmed.match(/^([^/:\s]+)\/([^/:\s]+?)(?:\.git)?$/);
91
+ return bare ? `${bare[1]}/${bare[2]}` : null;
92
+ }
93
+ async function currentRepo(opts = {}) {
94
+ const out = await ghCall(
95
+ { argv: ["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"] },
96
+ null,
97
+ opts
98
+ );
99
+ if (out && out.trim()) return out.trim();
100
+ const remote = await boundedRun("git", ["remote", "get-url", "origin"], {
101
+ timeoutMs: GH_API_TIMEOUT_MS,
102
+ cwd: opts.cwd
103
+ });
104
+ return remote.code === 0 ? parseSlugFromRemoteUrl(remote.stdout) : null;
105
+ }
106
+ async function resolveRepoTarget(opts = {}) {
107
+ const explicit = opts.repo?.trim();
108
+ if (explicit) return explicit;
109
+ const ghRepo = (opts.env ?? process.env).GH_REPO?.trim();
110
+ if (ghRepo) return ghRepo;
111
+ return currentRepo(opts);
112
+ }
113
+ function issueNumber(ref) {
114
+ const s = String(ref);
115
+ if (/^\d+$/.test(s)) return s;
116
+ const m = s.match(/\/(?:issues|pull)\/(\d+)/);
117
+ return m?.[1] ?? null;
118
+ }
119
+ async function addLabels(repo, issue, labels, call = {}) {
120
+ const number = issueNumber(issue);
121
+ if (number === null || !labels.length) return null;
122
+ const rest = {
123
+ argv: ["gh", "api", "-X", "POST", `repos/${repo}/issues/${number}/labels`, "--input", "-"],
124
+ stdin: JSON.stringify({ labels })
125
+ };
126
+ const fb = {
127
+ argv: ["gh", "issue", "edit", number, "--repo", repo, "--add-label", labels.join(",")]
128
+ };
129
+ const out = await ghCall(rest, fb, call);
130
+ return out !== null ? out.trim() : null;
131
+ }
132
+ async function removeLabel(repo, issue, label, call = {}) {
133
+ const number = issueNumber(issue);
134
+ if (number === null) return null;
135
+ const rest = {
136
+ argv: [
137
+ "gh",
138
+ "api",
139
+ "-X",
140
+ "DELETE",
141
+ `repos/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`
142
+ ]
143
+ };
144
+ const fb = {
145
+ argv: ["gh", "issue", "edit", number, "--repo", repo, "--remove-label", label]
146
+ };
147
+ const out = await ghCall(rest, fb, call);
148
+ return out !== null ? out.trim() : null;
149
+ }
150
+
151
+ // src/lib/check-run.ts
152
+ async function openCheckRunIds(repo, headSha, cwd) {
153
+ const out = await ghCall(
154
+ {
155
+ argv: [
156
+ "gh",
157
+ "api",
158
+ "--paginate",
159
+ `repos/${repo}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(MERGE_SAFETY_CHECK_NAME)}&filter=all`,
160
+ "--jq",
161
+ '.check_runs[] | select(.status != "completed") | .id'
162
+ ]
163
+ },
164
+ null,
165
+ { cwd }
166
+ );
167
+ if (out === null) return null;
168
+ return out.split("\n").map((l) => Number(l.trim())).filter((id) => Number.isInteger(id) && id > 0);
169
+ }
170
+ async function postCheck(repo, headSha, output, conclusion, cwd) {
171
+ const state = conclusion === "pending" ? { status: "in_progress", output } : { status: "completed", conclusion, completed_at: (/* @__PURE__ */ new Date()).toISOString(), output };
172
+ let updated = false;
173
+ for (const id of await openCheckRunIds(repo, headSha, cwd) ?? []) {
174
+ const out = await ghCall(
175
+ {
176
+ argv: ["gh", "api", "-X", "PATCH", `repos/${repo}/check-runs/${id}`, "--input", "-"],
177
+ stdin: JSON.stringify(state)
178
+ },
179
+ null,
180
+ { cwd }
181
+ );
182
+ if (out !== null) updated = true;
183
+ }
184
+ if (updated) return;
185
+ await ghCall(
186
+ {
187
+ argv: ["gh", "api", "-X", "POST", `repos/${repo}/check-runs`, "--input", "-"],
188
+ stdin: JSON.stringify({
189
+ // The fleet-contract check-run name (src/index.ts). Never a local literal —
190
+ // every consumer's required status check matches exactly this name.
191
+ name: MERGE_SAFETY_CHECK_NAME,
192
+ head_sha: headSha,
193
+ ...state
194
+ })
195
+ },
196
+ null,
197
+ { cwd }
198
+ );
199
+ }
200
+
201
+ // src/conventional-commits.ts
202
+ var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
203
+ var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
204
+ var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
205
+ var FUNCTIONAL_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "perf", "revert"]);
206
+ var CONVENTIONAL_TYPE_RE = /^([a-z]+)(?:!|\([^)]*\))*:/;
207
+ function firstLine(message) {
208
+ return message.split("\n", 1)[0] ?? "";
209
+ }
210
+ function isBreakingCommitMessage(message) {
211
+ return BREAKING_SUBJECT_RE.test(firstLine(message)) || BREAKING_FOOTER_RE.test(message);
212
+ }
213
+ function isCiCommitMessage(message) {
214
+ return CI_SUBJECT_RE.test(firstLine(message));
215
+ }
216
+ function isBreakingTitle(title) {
217
+ return BREAKING_SUBJECT_RE.test(title.trim());
218
+ }
219
+ function isCiTitle(title) {
220
+ return CI_SUBJECT_RE.test(title.trim());
221
+ }
222
+ function mayCarryBreakingMarker(title) {
223
+ const type = CONVENTIONAL_TYPE_RE.exec(title.trim())?.[1];
224
+ return type !== void 0 && FUNCTIONAL_TYPES.has(type);
225
+ }
226
+
227
+ // src/stacked-base.ts
228
+ var DEFAULT_EXEMPT_BASE_LABELS = ["release", "epic"];
229
+ function isExemptBasePr(basePr, exemptLabels) {
230
+ const exempt = new Set(exemptLabels.map((l) => l.trim().toLowerCase()));
231
+ return basePr.labels.some((l) => exempt.has(l.trim().toLowerCase()));
232
+ }
233
+ function stackedParentPr({
234
+ baseBranch,
235
+ defaultBranch,
236
+ basePr,
237
+ exemptLabels
238
+ }) {
239
+ if (!defaultBranch) return null;
240
+ if (!baseBranch || baseBranch === defaultBranch) return null;
241
+ if (!basePr) return null;
242
+ if (isExemptBasePr(basePr, exemptLabels)) return null;
243
+ return basePr.number;
244
+ }
245
+ function stackedBaseReason(baseBranch, parentPr) {
246
+ return `This PR targets \`${baseBranch}\`, the head of open PR #${parentPr} \u2014 it is stacked and must not merge until that PR does. Once #${parentPr} merges, GitHub retargets this PR to the default branch and it merges normally. Review and fixes proceed meanwhile; only the merge is held.`;
247
+ }
248
+
249
+ // src/reasons.ts
250
+ var BREAKING_DIFF_REASONS = {
251
+ "major-version-bump": "dependency major bump",
252
+ "sensitive-package-bump": "CI-sensitive package version change",
253
+ "material-test-changes": "existing test file modified"
254
+ };
255
+ function nestedBullets(items) {
256
+ return items.map((item) => ` - ${item}`).join("\n");
257
+ }
258
+ function formatBaseCommit(commit) {
259
+ return `${commit.sha.slice(0, 7)} ${commit.subject}`;
260
+ }
261
+ function withDetail(sentence, detail) {
262
+ return detail.length ? `${sentence}
263
+ ${nestedBullets(detail)}` : sentence;
264
+ }
265
+ function breakingSignalDetail(signals) {
266
+ return signals.flatMap((s) => s.detail.map((d) => `${BREAKING_DIFF_REASONS[s.kind]}: ${d}`));
267
+ }
268
+
269
+ // src/breaking-diff.ts
270
+ var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
271
+ var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
272
+ var NPM_DEP_RE = /"([^"]+)"\s*:\s*"[\^~]?(\d+)\.(\d+)\.(\d+)/;
273
+ var PIP_DEP_RE = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:==|~=|>=|<=|!=|<|>)\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?/;
274
+ var TEST_FILE_RE = /(\.test\.tsx?|\.spec\.tsx?|test_[^/]*\.py|[^/]*_test\.py)$/;
275
+ var DIFF_HEADER_PATH_RE = / b\/(.+)$/;
276
+ function normalizePackageName(name) {
277
+ return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
278
+ }
279
+ function dependencyFileKind(diffGitLine) {
280
+ if (diffGitLine.includes("package.json")) return "npm";
281
+ if (/requirements[\w./-]*\.txt/.test(diffGitLine)) return "pip";
282
+ return null;
283
+ }
284
+ function isFileHeader(line) {
285
+ return line.startsWith("---") || line.startsWith("+++");
286
+ }
287
+ function dependencyChanges(diffText) {
288
+ const changes = [];
289
+ let kind = null;
290
+ for (const line of diffText.split("\n")) {
291
+ if (line.startsWith("diff --git")) {
292
+ kind = dependencyFileKind(line);
293
+ continue;
294
+ }
295
+ if (kind === null) continue;
296
+ if (isFileHeader(line)) continue;
297
+ if (!line.startsWith("-") && !line.startsWith("+")) continue;
298
+ const sign = line[0] === "-" ? "-" : "+";
299
+ const match = (kind === "npm" ? NPM_DEP_RE : PIP_DEP_RE).exec(line.slice(1));
300
+ if (!match) continue;
301
+ const name = match[1] ?? "";
302
+ if (kind === "npm" && NON_DEPENDENCY_NPM_KEYS.has(name.trim().toLowerCase())) continue;
303
+ const major = Number(match[2] ?? 0);
304
+ const minor = Number(match[3] ?? 0);
305
+ const patch = Number(match[4] ?? 0);
306
+ changes.push({ kind, sign, name, major, version: `${major}.${minor}.${patch}` });
307
+ }
308
+ return changes;
309
+ }
310
+ function indexChanges(changes) {
311
+ const removed = /* @__PURE__ */ new Map();
312
+ const added = /* @__PURE__ */ new Map();
313
+ for (const change of changes) {
314
+ const key = `${change.kind}:${normalizePackageName(change.name)}`;
315
+ (change.sign === "-" ? removed : added).set(key, change);
316
+ }
317
+ return { removed, added };
318
+ }
319
+ function majorVersionBumps(diffText) {
320
+ const { removed, added } = indexChanges(dependencyChanges(diffText));
321
+ const bumps = [];
322
+ for (const [key, addedChange] of added) {
323
+ const removedChange = removed.get(key);
324
+ if (removedChange && addedChange.major > removedChange.major) {
325
+ bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
326
+ }
327
+ }
328
+ return bumps;
329
+ }
330
+ function sensitivePackageBumps(diffText) {
331
+ const { removed, added } = indexChanges(dependencyChanges(diffText));
332
+ const bumps = [];
333
+ for (const [key, addedChange] of added) {
334
+ if (!CI_SENSITIVE_PACKAGES.has(normalizePackageName(addedChange.name))) continue;
335
+ const removedChange = removed.get(key);
336
+ if (removedChange && removedChange.version !== addedChange.version) {
337
+ bumps.push(`${addedChange.name} ${removedChange.version} \u2192 ${addedChange.version}`);
338
+ }
339
+ }
340
+ return bumps;
341
+ }
342
+ function materialTestChanges(diffText) {
343
+ const changed = [];
344
+ let file = null;
345
+ let hasAdds = false;
346
+ let hasRemoves = false;
347
+ const flush = () => {
348
+ if (file && hasAdds && hasRemoves && TEST_FILE_RE.test(file)) changed.push(file);
349
+ };
350
+ for (const line of diffText.split("\n")) {
351
+ if (line.startsWith("diff --git")) {
352
+ flush();
353
+ file = DIFF_HEADER_PATH_RE.exec(line)?.[1] ?? null;
354
+ hasAdds = false;
355
+ hasRemoves = false;
356
+ continue;
357
+ }
358
+ if (isFileHeader(line)) continue;
359
+ if (line.startsWith("+")) hasAdds = true;
360
+ else if (line.startsWith("-")) hasRemoves = true;
361
+ }
362
+ flush();
363
+ return changed;
364
+ }
365
+ function breakingDiffSignals(diffText) {
366
+ const signals = [];
367
+ const major = majorVersionBumps(diffText);
368
+ if (major.length) signals.push({ kind: "major-version-bump", detail: major });
369
+ const sensitive = sensitivePackageBumps(diffText);
370
+ if (sensitive.length) signals.push({ kind: "sensitive-package-bump", detail: sensitive });
371
+ const tests = materialTestChanges(diffText);
372
+ if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
373
+ return signals;
374
+ }
375
+ function hasSignal(signals, kind) {
376
+ return signals.some((s) => s.kind === kind);
377
+ }
378
+ function signalDetail(signals, kind) {
379
+ return signals.find((s) => s.kind === kind)?.detail ?? [];
380
+ }
381
+
382
+ // src/base-health.ts
383
+ var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
384
+ var GITHUB_ACTIONS_APP_SLUG = "github-actions";
385
+ var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
386
+ function isGitHubActionsCheck(check) {
387
+ return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
388
+ }
389
+ function isNonBuildPlatformCheck(name) {
390
+ return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
391
+ }
392
+ function isFailingCiConclusion(conclusion) {
393
+ return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
394
+ }
395
+ function failingRequiredBaseChecks(checks, requiredContexts) {
396
+ if (!requiredContexts || requiredContexts.length === 0) return [];
397
+ const required = new Set(requiredContexts);
398
+ return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
399
+ }
400
+ function failingFallbackBaseChecks(checks) {
401
+ return checks.filter(
402
+ (c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
403
+ ).map((c) => c.name);
404
+ }
405
+
406
+ // src/merge-safety.ts
407
+ var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
408
+ function isEvaluablePrState(state) {
409
+ return state === "OPEN";
410
+ }
411
+ var HOTFIX_LABEL = "hotfix";
412
+ var BREAKING_LABEL = "breaking change";
413
+ function overlappingFiles(prFiles, baseFiles) {
414
+ if (!prFiles.length || !baseFiles.length) return [];
415
+ const base = new Set(baseFiles);
416
+ return prFiles.filter((f) => base.has(f));
417
+ }
418
+ function evaluateMergeSafety(facts) {
419
+ const reasons = [];
420
+ if (facts.hasConflict) {
421
+ reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
422
+ }
423
+ const stackedOnPr = facts.stackedOnPr;
424
+ const stackedBarred = stackedOnPr !== null;
425
+ if (stackedOnPr !== null) reasons.push(stackedBaseReason(facts.baseBranch, stackedOnPr));
426
+ const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
427
+ if (baseUnhealthy) {
428
+ reasons.push(
429
+ withDetail(
430
+ "The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
431
+ facts.failingBaseChecks
432
+ )
433
+ );
434
+ }
435
+ const stale = !facts.isCurrent;
436
+ if (stale && facts.baseBreakingSinceMergeBase) {
437
+ reasons.push(
438
+ withDetail(
439
+ "A breaking change landed on the base since merge-base \u2014 rebase and re-run CI:",
440
+ facts.baseBreakingCommits.map(formatBaseCommit)
441
+ )
442
+ );
443
+ }
444
+ if (stale && facts.prIsBreaking) {
445
+ reasons.push(
446
+ withDetail(
447
+ "This PR is a breaking change \u2014 it must be current with the base before merge.",
448
+ breakingSignalDetail(facts.prBreakingDiffSignals)
449
+ )
450
+ );
451
+ }
452
+ if (stale && facts.prIsCi && !facts.prIsHotfix) {
453
+ reasons.push(
454
+ "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."
455
+ );
456
+ }
457
+ if (stale && facts.baseCiSinceMergeBase) {
458
+ reasons.push(
459
+ withDetail(
460
+ "A CI change landed on the base since merge-base \u2014 rebase to re-test under it:",
461
+ facts.baseCiCommits.map(formatBaseCommit)
462
+ )
463
+ );
464
+ }
465
+ if (stale && facts.fileOverlap) {
466
+ reasons.push(
467
+ withDetail(
468
+ "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:",
469
+ facts.overlappingFiles
470
+ )
471
+ );
472
+ }
473
+ const needsCiRetitle = hasSignal(facts.prBreakingDiffSignals, "sensitive-package-bump") ? !facts.prIsCi : false;
474
+ const needsUpdate = stale && (facts.baseBreakingSinceMergeBase || facts.baseCiSinceMergeBase || facts.prIsBreaking || facts.prIsCi && !facts.prIsHotfix || facts.fileOverlap);
475
+ if (needsCiRetitle) {
476
+ reasons.push(
477
+ withDetail(
478
+ "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:",
479
+ signalDetail(facts.prBreakingDiffSignals, "sensitive-package-bump")
480
+ )
481
+ );
482
+ }
483
+ const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle || stackedBarred;
484
+ const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
485
+ 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.");
486
+ const title = facts.hasConflict ? "Merge conflict" : stackedBarred ? "Base PR not merged" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
487
+ const add = [];
488
+ if (needsUpdate) add.push("update required");
489
+ if (facts.hasConflict) add.push("merge conflict");
490
+ const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
491
+ const addOnly = [];
492
+ if (hasSignal(facts.prBreakingDiffSignals, "major-version-bump") && facts.prMayCarryBreakingMarker)
493
+ addOnly.push(BREAKING_LABEL);
494
+ return {
495
+ conclusion,
496
+ needsUpdate,
497
+ hasConflict: facts.hasConflict,
498
+ baseUnhealthy,
499
+ needsCiRetitle,
500
+ stackedBarred,
501
+ title,
502
+ reasons,
503
+ summary,
504
+ labels: { add, remove, addOnly }
505
+ };
506
+ }
507
+ function errorMergeSafetyDecision(message) {
508
+ return {
509
+ conclusion: "failure",
510
+ needsUpdate: false,
511
+ hasConflict: false,
512
+ baseUnhealthy: false,
513
+ needsCiRetitle: false,
514
+ stackedBarred: false,
515
+ title: "Could not evaluate",
516
+ reasons: [message],
517
+ summary: `Could not evaluate merge safety: ${message}`,
518
+ labels: { add: [], remove: [], addOnly: [] }
519
+ };
520
+ }
521
+
522
+ // src/merge-safety-facts.ts
523
+ var GIT_TIMEOUT_MS = 3e4;
524
+ function baseBranchName(baseRef) {
525
+ return baseRef.replace(/^refs\/heads\//, "").replace(/^origin\//, "");
526
+ }
527
+ function makeGitRunner(cwd) {
528
+ return async (args) => {
529
+ const r = await boundedRun("git", args, { timeoutMs: GIT_TIMEOUT_MS, cwd });
530
+ return r.code === 0 ? r.stdout : null;
531
+ };
532
+ }
533
+ function splitLines(out) {
534
+ return (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean);
535
+ }
536
+ function parseBaseCommits(logOut) {
537
+ return logOut.split("\0").map((record) => record.replace(/^\n+/, "")).filter((record) => record.trim().length > 0).map((record) => {
538
+ const newline = record.indexOf("\n");
539
+ const sha = (newline === -1 ? record : record.slice(0, newline)).trim();
540
+ const message = newline === -1 ? "" : record.slice(newline + 1).trim();
541
+ return { sha, message };
542
+ });
543
+ }
544
+ function selectCommits(commits, predicate) {
545
+ return commits.filter((c) => predicate(c.message)).map((c) => ({ sha: c.sha, subject: c.message.split("\n", 1)[0] ?? "" }));
546
+ }
547
+ async function gatherMergeSafetyFacts(meta, {
548
+ baseRef = "origin/main",
549
+ git,
550
+ baseChecks,
551
+ requiredChecks,
552
+ basePr = async () => null,
553
+ defaultBranch = null,
554
+ exemptBaseLabels = DEFAULT_EXEMPT_BASE_LABELS
555
+ }) {
556
+ const mergeBase = (await git(["merge-base", meta.headSha, baseRef]))?.trim();
557
+ const baseTip = (await git(["rev-parse", baseRef]))?.trim();
558
+ if (!mergeBase || !baseTip) {
559
+ throw new Error(`could not resolve merge-base/base tip for ${meta.headSha}..${baseRef}`);
560
+ }
561
+ const isCurrent = mergeBase === baseTip;
562
+ const baseBranch = baseBranchName(baseRef);
563
+ const checks = await baseChecks(baseTip);
564
+ const required = await requiredChecks(baseBranch);
565
+ const failingBaseChecks = required && required.length > 0 ? failingRequiredBaseChecks(checks, required) : failingFallbackBaseChecks(checks);
566
+ const logOut = await git(["log", "-z", "--format=%H%n%B", `${mergeBase}..${baseRef}`]);
567
+ if (logOut === null) throw new Error(`git log failed for ${mergeBase}..${baseRef}`);
568
+ const commits = parseBaseCommits(logOut);
569
+ const baseBreakingCommits = selectCommits(commits, isBreakingCommitMessage);
570
+ const baseCiCommits = selectCommits(commits, isCiCommitMessage);
571
+ const baseFilesOut = await git(["diff", "--name-only", mergeBase, baseRef]);
572
+ if (baseFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${baseRef}`);
573
+ const baseFiles = splitLines(baseFilesOut);
574
+ const prFilesOut = await git(["diff", "--name-only", mergeBase, meta.headSha]);
575
+ if (prFilesOut === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
576
+ const prFiles = splitLines(prFilesOut);
577
+ const overlaps = overlappingFiles(prFiles, baseFiles);
578
+ const prDiff = await git(["diff", "--unified=0", mergeBase, meta.headSha]);
579
+ if (prDiff === null) throw new Error(`git diff failed for ${mergeBase}..${meta.headSha}`);
580
+ const diffSignals = breakingDiffSignals(prDiff);
581
+ const parentPr = defaultBranch && baseBranch !== defaultBranch ? await basePr(baseBranch) : null;
582
+ const stackedOnPr = stackedParentPr({
583
+ baseBranch,
584
+ defaultBranch,
585
+ basePr: parentPr,
586
+ exemptLabels: exemptBaseLabels
587
+ });
588
+ const labels = meta.labels.map((l) => l.toLowerCase());
589
+ return {
590
+ isCurrent,
591
+ // Each boolean is derived from its detail list — one computation, two views.
592
+ baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
593
+ baseCiSinceMergeBase: baseCiCommits.length > 0,
594
+ // Additive by construction: the title marker and the label remain inputs, and
595
+ // the diff can only ever add a reason to treat the PR as breaking (#53).
596
+ prIsBreaking: isBreakingTitle(meta.title) || labels.includes(BREAKING_LABEL) || diffSignals.length > 0,
597
+ prIsCi: isCiTitle(meta.title),
598
+ fileOverlap: overlaps.length > 0,
599
+ hasConflict: meta.mergeable.toUpperCase() === "CONFLICTING",
600
+ baseCiFailing: failingBaseChecks.length > 0,
601
+ prIsHotfix: labels.includes(HOTFIX_LABEL),
602
+ baseBreakingCommits,
603
+ baseCiCommits,
604
+ overlappingFiles: overlaps,
605
+ failingBaseChecks,
606
+ prBreakingDiffSignals: diffSignals,
607
+ prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title),
608
+ baseBranch,
609
+ stackedOnPr
610
+ };
611
+ }
612
+
613
+ // src/bin/merge-safety.ts
614
+ var DEFAULT_CALLER_WORKFLOW = "merge-safety.yml";
615
+ function usage() {
616
+ console.error(
617
+ "usage: ai-merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n [--default-branch <name>] [--exempt-base-labels <csv>]\n ai-merge-safety invalidate [--workflow <file>] [--exclude <n>] [--repo <o/r>] [--cwd <path>]"
618
+ );
619
+ process.exit(2);
620
+ }
621
+ function parse(argv) {
622
+ const mode = argv[0];
623
+ if (!isMergeSafetyCommand(mode)) usage();
624
+ const args = {
625
+ mode,
626
+ baseRef: "origin/main",
627
+ workflow: DEFAULT_CALLER_WORKFLOW,
628
+ exemptBaseLabels: DEFAULT_EXEMPT_BASE_LABELS,
629
+ json: false
630
+ };
631
+ for (let i = 1; i < argv.length; i++) {
632
+ const a = argv[i];
633
+ if (a === "--pr") args.pr = Number(argv[++i]);
634
+ else if (a === "--exclude") args.exclude = Number(argv[++i]);
635
+ else if (a === "--repo") args.repo = argv[++i];
636
+ else if (a === "--base") args.baseRef = argv[++i] ?? args.baseRef;
637
+ else if (a === "--workflow") args.workflow = argv[++i] ?? args.workflow;
638
+ else if (a === "--cwd") args.cwd = argv[++i];
639
+ else if (a === "--default-branch") args.defaultBranch = argv[++i];
640
+ else if (a === "--exempt-base-labels")
641
+ args.exemptBaseLabels = (argv[++i] ?? "").split(",").map((l) => l.trim()).filter(Boolean);
642
+ else if (a === "--json" || a === "--dry-run") args.json = true;
643
+ else usage();
644
+ }
645
+ if (mode === "evaluate" && !args.pr) usage();
646
+ if (args.json && mode !== "evaluate") usage();
647
+ return args;
648
+ }
649
+ async function ghJson(argv, cwd) {
650
+ const out = await ghCall({ argv }, null, { cwd });
651
+ if (out === null) return null;
652
+ try {
653
+ return JSON.parse(out);
654
+ } catch {
655
+ return null;
656
+ }
657
+ }
658
+ function makeBaseChecksProbe(repo, cwd) {
659
+ return async (baseSha) => {
660
+ const out = await ghCall(
661
+ {
662
+ argv: [
663
+ "gh",
664
+ "api",
665
+ "--paginate",
666
+ `repos/${repo}/commits/${baseSha}/check-runs?filter=latest`,
667
+ "--jq",
668
+ ".check_runs[] | {name: .name, conclusion: .conclusion, appSlug: .app.slug}"
669
+ ]
670
+ },
671
+ null,
672
+ { cwd }
673
+ );
674
+ if (!out) return [];
675
+ const checks = [];
676
+ for (const line of out.split("\n")) {
677
+ const trimmed = line.trim();
678
+ if (!trimmed) continue;
679
+ try {
680
+ const c = JSON.parse(trimmed);
681
+ checks.push({ name: c.name, conclusion: c.conclusion, appSlug: c.appSlug });
682
+ } catch {
683
+ }
684
+ }
685
+ return checks;
686
+ };
687
+ }
688
+ function makeRequiredChecksProbe(repo, cwd) {
689
+ return async (baseBranch) => {
690
+ const out = await ghCall(
691
+ {
692
+ argv: [
693
+ "gh",
694
+ "api",
695
+ "--paginate",
696
+ `repos/${repo}/rules/branches/${encodeURIComponent(baseBranch)}`,
697
+ "--jq",
698
+ '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context'
699
+ ]
700
+ },
701
+ null,
702
+ { cwd }
703
+ );
704
+ if (out === null) return null;
705
+ const contexts = out.split("\n").map((l) => l.trim()).filter(Boolean);
706
+ return [...new Set(contexts)];
707
+ };
708
+ }
709
+ function makeBasePrProbe(repo, cwd) {
710
+ return async (baseBranch) => {
711
+ const prs = await ghJson(
712
+ [
713
+ "gh",
714
+ "pr",
715
+ "list",
716
+ "--repo",
717
+ repo,
718
+ "--state",
719
+ "open",
720
+ "--head",
721
+ baseBranch,
722
+ "--limit",
723
+ "1",
724
+ "--json",
725
+ "number,labels"
726
+ ],
727
+ cwd
728
+ );
729
+ const pr = prs?.[0];
730
+ if (!pr) return null;
731
+ return { number: pr.number, labels: (pr.labels ?? []).map((l) => l.name) };
732
+ };
733
+ }
734
+ async function resolveDefaultBranch(repo, override, cwd) {
735
+ if (override) return override;
736
+ const view = await ghJson(
737
+ ["gh", "repo", "view", repo, "--json", "defaultBranchRef"],
738
+ cwd
739
+ );
740
+ return view?.defaultBranchRef?.name ?? null;
741
+ }
742
+ async function fetchPrView(repo, pr, cwd) {
743
+ const fields = "number,headRefOid,title,labels,mergeable,state";
744
+ for (let attempt = 0; attempt < 3; attempt++) {
745
+ const view = await ghJson(
746
+ ["gh", "pr", "view", String(pr), "--repo", repo, "--json", fields],
747
+ cwd
748
+ );
749
+ if (!view) return null;
750
+ if (view.mergeable !== "UNKNOWN" || attempt === 2) return view;
751
+ await new Promise((r) => setTimeout(r, 2e3));
752
+ }
753
+ return null;
754
+ }
755
+ async function reconcileLabels(repo, pr, add, remove, cwd) {
756
+ if (add.length) await addLabels(repo, pr, [...add], { cwd });
757
+ for (const label of remove) await removeLabel(repo, pr, label, { cwd });
758
+ }
759
+ async function runEvaluate(repo, pr, args) {
760
+ const view = await fetchPrView(repo, pr, args.cwd);
761
+ if (!view) {
762
+ const msg = `could not read PR #${pr}`;
763
+ if (args.json) return emitDecisionJson(errorMergeSafetyDecision(msg), true);
764
+ throw new Error(msg);
765
+ }
766
+ if (!isEvaluablePrState(view.state)) {
767
+ console.log(`#${pr}: ${view.state.toLowerCase()} \u2014 skipping merge-safety evaluate`);
768
+ return;
769
+ }
770
+ const meta = {
771
+ headSha: view.headRefOid,
772
+ title: view.title,
773
+ labels: view.labels.map((l) => l.name),
774
+ mergeable: view.mergeable
775
+ };
776
+ let decision;
777
+ try {
778
+ const facts = await gatherMergeSafetyFacts(meta, {
779
+ baseRef: args.baseRef,
780
+ git: makeGitRunner(args.cwd),
781
+ baseChecks: makeBaseChecksProbe(repo, args.cwd),
782
+ requiredChecks: makeRequiredChecksProbe(repo, args.cwd),
783
+ basePr: makeBasePrProbe(repo, args.cwd),
784
+ defaultBranch: await resolveDefaultBranch(repo, args.defaultBranch, args.cwd),
785
+ exemptBaseLabels: args.exemptBaseLabels
786
+ });
787
+ decision = evaluateMergeSafety(facts);
788
+ } catch (err) {
789
+ const msg = err instanceof Error ? err.message : String(err);
790
+ const failed = errorMergeSafetyDecision(msg);
791
+ if (args.json) return emitDecisionJson(failed, true);
792
+ await postCheck(
793
+ repo,
794
+ meta.headSha,
795
+ { title: failed.title, summary: failed.summary },
796
+ "failure",
797
+ args.cwd
798
+ );
799
+ console.error(`#${pr}: failure \u2014 could not evaluate: ${msg}`);
800
+ process.exitCode = 1;
801
+ return;
802
+ }
803
+ if (args.json) return emitDecisionJson(decision, false);
804
+ const detail = decision.reasons.length ? decision.reasons.map((r) => `- ${r}`).join("\n") : decision.summary;
805
+ await postCheck(
806
+ repo,
807
+ meta.headSha,
808
+ { title: decision.title, summary: `${decision.summary}
809
+
810
+ ${detail}` },
811
+ decision.conclusion,
812
+ args.cwd
813
+ );
814
+ await reconcileLabels(
815
+ repo,
816
+ pr,
817
+ [...decision.labels.add, ...decision.labels.addOnly],
818
+ decision.labels.remove,
819
+ args.cwd
820
+ );
821
+ console.log(`#${pr}: ${decision.conclusion} \u2014 ${decision.summary}`);
822
+ }
823
+ function emitDecisionJson(decision, isError) {
824
+ console.log(JSON.stringify(decision, null, 2));
825
+ if (isError) process.exitCode = 1;
826
+ }
827
+ async function runInvalidate(repo, args) {
828
+ const prs = await ghJson(
829
+ [
830
+ "gh",
831
+ "pr",
832
+ "list",
833
+ "--repo",
834
+ repo,
835
+ "--state",
836
+ "open",
837
+ "--base",
838
+ "main",
839
+ "--limit",
840
+ "1000",
841
+ "--json",
842
+ "number,headRefOid"
843
+ ],
844
+ args.cwd
845
+ );
846
+ if (!prs) throw new Error("could not list open PRs");
847
+ for (const pr of prs) {
848
+ if (pr.number === args.exclude) continue;
849
+ await postCheck(
850
+ repo,
851
+ pr.headRefOid,
852
+ { title: "Re-evaluating", summary: "Re-evaluating against the updated base\u2026" },
853
+ "pending",
854
+ args.cwd
855
+ );
856
+ await ghCall(
857
+ {
858
+ argv: ["gh", "workflow", "run", args.workflow, "--repo", repo, "-f", `pr=${pr.number}`]
859
+ },
860
+ null,
861
+ { cwd: args.cwd }
862
+ );
863
+ console.log(`#${pr.number}: invalidated (pending) + evaluate dispatched`);
864
+ }
865
+ }
866
+ async function main() {
867
+ const args = parse(process.argv.slice(2));
868
+ const repo = await resolveRepoTarget({ repo: args.repo, cwd: args.cwd });
869
+ if (!repo) {
870
+ console.error("error: could not resolve target repo (pass --repo <owner/repo>)");
871
+ process.exit(2);
872
+ }
873
+ if (args.mode === "evaluate") await runEvaluate(repo, args.pr, args);
874
+ else await runInvalidate(repo, args);
875
+ }
876
+ function isDirectRun() {
877
+ const entry = process.argv[1];
878
+ if (!entry) return false;
879
+ try {
880
+ return realpathSync(entry) === fileURLToPath(import.meta.url);
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+ if (isDirectRun()) void main();
886
+ export {
887
+ makeBaseChecksProbe,
888
+ makeBasePrProbe,
889
+ makeRequiredChecksProbe,
890
+ runEvaluate,
891
+ runInvalidate
892
+ };
@@ -0,0 +1,12 @@
1
+ // src/index.ts
2
+ var MERGE_SAFETY_CHECK_NAME = "merge-safety";
3
+ var MERGE_SAFETY_COMMANDS = ["evaluate", "invalidate"];
4
+ function isMergeSafetyCommand(value) {
5
+ return value !== void 0 && MERGE_SAFETY_COMMANDS.includes(value);
6
+ }
7
+
8
+ export {
9
+ MERGE_SAFETY_CHECK_NAME,
10
+ MERGE_SAFETY_COMMANDS,
11
+ isMergeSafetyCommand
12
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @rmartz/merge-safety — public entry point.
3
+ *
4
+ * Encodes the package's stable public contract: the `merge-safety` check-run
5
+ * name and the CLI command surface. The evaluate/invalidate implementation
6
+ * (`merge-safety.ts` + `merge-safety-facts.ts` and the `ai-merge-safety` bin)
7
+ * lives alongside it; this module is intentionally the narrow, frozen surface
8
+ * consumers and the reusable workflow depend on.
9
+ */
10
+ /**
11
+ * The name of the check-run merge-safety posts for each PR.
12
+ *
13
+ * This is a **fleet contract**, not an internal label: every consumer repo
14
+ * configures a *required status check* of exactly this name, and the auto-merge
15
+ * gate (`goldenGateChecks`) keys off it. Renaming it is a coordinated fleet
16
+ * migration (every consumer's required-check config + the auto-merge verifier +
17
+ * the gate floor, all at once), never a local edit. See
18
+ * docs/check-run-contract.md.
19
+ */
20
+ declare const MERGE_SAFETY_CHECK_NAME = "merge-safety";
21
+ /**
22
+ * The two operations the `ai-merge-safety` CLI dispatches:
23
+ * - `evaluate` — gather base-currency + breaking-change + conflict facts for one
24
+ * PR, post the `merge-safety` check-run, and reconcile the update-required /
25
+ * merge-conflict labels.
26
+ * - `invalidate` — on a push to the base branch, flip every *other* open PR's
27
+ * check to pending and re-dispatch its `evaluate`, so a moved base holds native
28
+ * auto-merge until each PR re-clears.
29
+ */
30
+ declare const MERGE_SAFETY_COMMANDS: readonly ["evaluate", "invalidate"];
31
+ type MergeSafetyCommand = (typeof MERGE_SAFETY_COMMANDS)[number];
32
+ /** Type guard for the CLI command surface. */
33
+ declare function isMergeSafetyCommand(value: string | undefined): value is MergeSafetyCommand;
34
+
35
+ export { MERGE_SAFETY_CHECK_NAME, MERGE_SAFETY_COMMANDS, type MergeSafetyCommand, isMergeSafetyCommand };
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import {
2
+ MERGE_SAFETY_CHECK_NAME,
3
+ MERGE_SAFETY_COMMANDS,
4
+ isMergeSafetyCommand
5
+ } from "./chunk-ITPKHZGC.js";
6
+ export {
7
+ MERGE_SAFETY_CHECK_NAME,
8
+ MERGE_SAFETY_COMMANDS,
9
+ isMergeSafetyCommand
10
+ };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@rmartz/merge-safety",
3
+ "version": "0.6.0",
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
+ "keywords": [
6
+ "ci",
7
+ "merge-safety",
8
+ "auto-merge",
9
+ "github-actions",
10
+ "reusable-workflow",
11
+ "check-run"
12
+ ],
13
+ "homepage": "https://github.com/rmartz/merge-safety#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/rmartz/merge-safety/issues"
16
+ },
17
+ "type": "module",
18
+ "packageManager": "pnpm@9.12.0",
19
+ "engines": {
20
+ "node": ">=20.11.0"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "ai-merge-safety": "./dist/bin/merge-safety.js"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "@rmartz:registry": "https://registry.npmjs.org/",
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/rmartz/merge-safety.git"
42
+ },
43
+ "scripts": {
44
+ "build": "tsup",
45
+ "typecheck": "tsc --noEmit",
46
+ "lint": "eslint .",
47
+ "format": "prettier --write .",
48
+ "format:check": "prettier --check .",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "verify:release-notes": "node scripts/verify-changelog-render.mjs"
52
+ },
53
+ "devDependencies": {
54
+ "@semantic-release/release-notes-generator": "^14.1.1",
55
+ "@types/node": "^26.6.2",
56
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
57
+ "@typescript-eslint/parser": "^8.70.0",
58
+ "@vitest/coverage-v8": "^5.0.1",
59
+ "conventional-changelog-conventionalcommits": "^9.3.1",
60
+ "eslint": "^10.11.0",
61
+ "eslint-import-resolver-typescript": "^4.4.5",
62
+ "eslint-plugin-import": "^2.32.0",
63
+ "prettier": "^3.9.8",
64
+ "semantic-release": "^25.0.9",
65
+ "tsup": "^8.5.1",
66
+ "typescript": "^6.0.3",
67
+ "vite": "^8.3.0",
68
+ "vitest": "^5.0.1"
69
+ },
70
+ "pnpm": {
71
+ "overrides": {
72
+ "esbuild": ">=0.28.1"
73
+ }
74
+ }
75
+ }