@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 +122 -0
- package/dist/bin/merge-safety.d.ts +114 -0
- package/dist/bin/merge-safety.js +892 -0
- package/dist/chunk-ITPKHZGC.js +12 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +10 -0
- package/package.json +75 -0
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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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
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
|
+
}
|