@rmartz/bot-automerge 0.2.1
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 +100 -0
- package/dist/bin/bot-automerge.d.ts +1 -0
- package/dist/bin/bot-automerge.js +304 -0
- package/dist/chunk-MM4DLJ6N.js +47 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.js +32 -0
- package/package.json +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @rmartz/bot-automerge
|
|
2
|
+
|
|
3
|
+
The **eligibility enabler** for trustworthy bot pull requests, packaged so a repo
|
|
4
|
+
can turn on GitHub's native auto-merge for the bot PRs it trusts. bot-automerge
|
|
5
|
+
classifies a bot PR — Dependabot `patch`/`minor` bumps and release-please release
|
|
6
|
+
PRs — and, when the PR is eligible, runs `gh pr merge --auto --squash` to enable
|
|
7
|
+
native auto-merge. It is the counterpart to
|
|
8
|
+
[`@rmartz/merge-safety`](https://github.com/rmartz/merge-safety), which computes
|
|
9
|
+
the pre-auto-merge safety verdict: where merge-safety answers "is it safe to
|
|
10
|
+
merge?", bot-automerge answers "is this a bot PR we trust enough to enable
|
|
11
|
+
auto-merge on?". Unlike merge-safety, it posts **no check-run**.
|
|
12
|
+
|
|
13
|
+
It is distributed the same way [`@rmartz/repo-hygiene`](https://github.com/rmartz/repo-hygiene)
|
|
14
|
+
and `@rmartz/merge-safety` are:
|
|
15
|
+
|
|
16
|
+
1. **Updates propagate automatically.** Consuming repos pin one reusable workflow
|
|
17
|
+
by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin.
|
|
18
|
+
2. **No check-run contract.** bot-automerge is purely an eligibility enabler — it
|
|
19
|
+
requires no fleet-wide status-check name. See
|
|
20
|
+
[docs/bot-automerge-contract.md](docs/bot-automerge-contract.md).
|
|
21
|
+
|
|
22
|
+
> **Status: implemented.** The `enable` classification — Dependabot `patch`/`minor`
|
|
23
|
+
> bumps and release-please release PRs — and the reusable workflow are complete and
|
|
24
|
+
> ship in `v0.1.0`, the first release to GitHub Packages. It was built per
|
|
25
|
+
> [ai-tools#264](https://github.com/rmartz/ai-tools/issues/264); see
|
|
26
|
+
> [docs/bot-automerge-contract.md](docs/bot-automerge-contract.md) for the
|
|
27
|
+
> eligibility contract.
|
|
28
|
+
|
|
29
|
+
## Using it in a consuming repo
|
|
30
|
+
|
|
31
|
+
Add one caller workflow (this is what Dependabot keeps current). Unlike a
|
|
32
|
+
read-only hygiene check, this caller carries the triggers, grants write scopes,
|
|
33
|
+
and passes secrets through — because a reusable workflow can't declare its own
|
|
34
|
+
triggers and runs with the intersection of granted and declared permissions:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
# .github/workflows/bot-automerge.yml
|
|
38
|
+
name: bot-automerge
|
|
39
|
+
on:
|
|
40
|
+
pull_request_target:
|
|
41
|
+
types: [opened, reopened, synchronize, labeled]
|
|
42
|
+
permissions:
|
|
43
|
+
contents: write
|
|
44
|
+
pull-requests: write
|
|
45
|
+
packages: read # install the CLI from GitHub Packages
|
|
46
|
+
jobs:
|
|
47
|
+
bot-automerge:
|
|
48
|
+
uses: rmartz/bot-automerge/.github/workflows/bot-automerge.yml@<sha> # vX.Y.Z
|
|
49
|
+
with:
|
|
50
|
+
pr: ${{ github.event.pull_request.number }}
|
|
51
|
+
secrets: inherit
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It uses `pull_request_target` (not `pull_request`) because Dependabot PRs run with
|
|
55
|
+
a read-only token, and enabling auto-merge needs base-context write.
|
|
56
|
+
|
|
57
|
+
> **Require `merge-safety` + your CI checks on the default branch _before_ adopting
|
|
58
|
+
> this.** `gh pr merge --auto` merges a PR immediately if the repo has no required
|
|
59
|
+
> status checks — bot-automerge only makes a bot PR _eligible_ to auto-merge; the
|
|
60
|
+
> repo's required checks are what it waits on. See the
|
|
61
|
+
> [consumer setup guide](docs/consuming.md) for the full prerequisite.
|
|
62
|
+
|
|
63
|
+
The public `@rmartz/bot-automerge` package on GitHub Packages is readable with the
|
|
64
|
+
built-in `GITHUB_TOKEN`, so no consumer PAT is required.
|
|
65
|
+
|
|
66
|
+
> For the full walkthrough — the caller's permissions, why it isn't trigger-free,
|
|
67
|
+
> and how the pin stays current — see the
|
|
68
|
+
> [consumer setup guide](docs/consuming.md).
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- Node.js >= 20.11
|
|
73
|
+
- pnpm 9 (pinned via `packageManager`)
|
|
74
|
+
|
|
75
|
+
Consuming repos need neither — the reusable workflow runs the published CLI on a
|
|
76
|
+
GitHub-hosted runner.
|
|
77
|
+
|
|
78
|
+
## Local development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pnpm install
|
|
82
|
+
pnpm run build # tsup → dist (ESM + d.ts)
|
|
83
|
+
pnpm run typecheck
|
|
84
|
+
pnpm run lint
|
|
85
|
+
pnpm run format:check
|
|
86
|
+
pnpm run test # vitest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Releases
|
|
90
|
+
|
|
91
|
+
Versioned by [semantic-release](.releaserc.json). A push to `main` analyzes the
|
|
92
|
+
Conventional-Commit history since the last `bot-automerge-v*` tag and, when a
|
|
93
|
+
release is warranted, publishes the package to GitHub Packages (public) and
|
|
94
|
+
creates the git tag + GitHub Release — no release PR and no commit-back, so the
|
|
95
|
+
built-in `GITHUB_TOKEN` suffices. `tagFormat` stays `bot-automerge-v${version}`
|
|
96
|
+
for continuity with the prior release-please tags.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
🤖 Created by Claude Opus 4.8
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
AUTOMERGE_HANDLED_LABEL,
|
|
4
|
+
RELEASE_PLEASE_BRANCH_PREFIX,
|
|
5
|
+
RELEASE_PLEASE_PENDING_LABEL,
|
|
6
|
+
isBotAutomergeCommand,
|
|
7
|
+
isDependabotAuthor,
|
|
8
|
+
isDependabotUpdateType,
|
|
9
|
+
isEligibleDependabotUpdateType
|
|
10
|
+
} from "../chunk-MM4DLJ6N.js";
|
|
11
|
+
|
|
12
|
+
// src/lib/bounded-subprocess.ts
|
|
13
|
+
import { spawn } from "child_process";
|
|
14
|
+
function boundedRun(command, args, options) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(command, args, {
|
|
17
|
+
cwd: options.cwd,
|
|
18
|
+
env: options.env,
|
|
19
|
+
detached: true
|
|
20
|
+
// new process group, so we can kill the whole tree
|
|
21
|
+
});
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
let timedOut = false;
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
timedOut = true;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(-child.pid, "SIGKILL");
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
}, options.timeoutMs);
|
|
32
|
+
if (options.input !== void 0 && child.stdin) {
|
|
33
|
+
child.stdin.on("error", () => {
|
|
34
|
+
});
|
|
35
|
+
child.stdin.write(options.input);
|
|
36
|
+
child.stdin.end();
|
|
37
|
+
}
|
|
38
|
+
child.stdout.on("data", (chunk) => stdout += chunk.toString());
|
|
39
|
+
child.stderr.on("data", (chunk) => stderr += chunk.toString());
|
|
40
|
+
child.on("error", (err) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
child.on("close", (code) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/lib/github.ts
|
|
52
|
+
var GH_API_TIMEOUT_MS = 3e4;
|
|
53
|
+
var MAX_RETRIES = 2;
|
|
54
|
+
var BACKOFF_BASE_MS = 1e3;
|
|
55
|
+
var BACKOFF_CAP_MS = 8e3;
|
|
56
|
+
var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
57
|
+
function isRateLimited(text) {
|
|
58
|
+
const t = (text ?? "").toLowerCase();
|
|
59
|
+
return t.includes("rate limit") || t.includes("rate-limit");
|
|
60
|
+
}
|
|
61
|
+
async function runTransport({ argv, stdin }, cwd) {
|
|
62
|
+
const [command, ...args] = argv;
|
|
63
|
+
if (command === void 0) return { stdout: null, stderr: "empty argv" };
|
|
64
|
+
try {
|
|
65
|
+
const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
|
|
66
|
+
if (r.code === 0) return { stdout: r.stdout, stderr: "" };
|
|
67
|
+
return { stdout: null, stderr: r.stderr || "" };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function ghCall(primary, fallback, opts = {}) {
|
|
73
|
+
const sleep = opts.sleep ?? realSleep;
|
|
74
|
+
for (const transport of [primary, fallback]) {
|
|
75
|
+
if (!transport) continue;
|
|
76
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
77
|
+
const { stdout, stderr } = await runTransport(transport, opts.cwd);
|
|
78
|
+
if (stdout !== null) return stdout;
|
|
79
|
+
if (isRateLimited(stderr)) break;
|
|
80
|
+
if (attempt < MAX_RETRIES) {
|
|
81
|
+
await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function parseSlugFromRemoteUrl(url) {
|
|
88
|
+
const trimmed = url.trim();
|
|
89
|
+
const match = trimmed.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
90
|
+
if (match) return `${match[1]}/${match[2]}`;
|
|
91
|
+
const bare = trimmed.match(/^([^/:\s]+)\/([^/:\s]+?)(?:\.git)?$/);
|
|
92
|
+
return bare ? `${bare[1]}/${bare[2]}` : null;
|
|
93
|
+
}
|
|
94
|
+
async function currentRepo(opts = {}) {
|
|
95
|
+
const out = await ghCall(
|
|
96
|
+
{ argv: ["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"] },
|
|
97
|
+
null,
|
|
98
|
+
opts
|
|
99
|
+
);
|
|
100
|
+
if (out && out.trim()) return out.trim();
|
|
101
|
+
const remote = await boundedRun("git", ["remote", "get-url", "origin"], {
|
|
102
|
+
timeoutMs: GH_API_TIMEOUT_MS,
|
|
103
|
+
cwd: opts.cwd
|
|
104
|
+
});
|
|
105
|
+
return remote.code === 0 ? parseSlugFromRemoteUrl(remote.stdout) : null;
|
|
106
|
+
}
|
|
107
|
+
async function resolveRepoTarget(opts = {}) {
|
|
108
|
+
const explicit = opts.repo?.trim();
|
|
109
|
+
if (explicit) return explicit;
|
|
110
|
+
const ghRepo = (opts.env ?? process.env).GH_REPO?.trim();
|
|
111
|
+
if (ghRepo) return ghRepo;
|
|
112
|
+
return currentRepo(opts);
|
|
113
|
+
}
|
|
114
|
+
function issueNumber(ref) {
|
|
115
|
+
const s = String(ref);
|
|
116
|
+
if (/^\d+$/.test(s)) return s;
|
|
117
|
+
const m = s.match(/\/(?:issues|pull)\/(\d+)/);
|
|
118
|
+
return m?.[1] ?? null;
|
|
119
|
+
}
|
|
120
|
+
async function addLabels(repo, issue, labels, call = {}) {
|
|
121
|
+
const number = issueNumber(issue);
|
|
122
|
+
if (number === null || !labels.length) return null;
|
|
123
|
+
const rest = {
|
|
124
|
+
argv: ["gh", "api", "-X", "POST", `repos/${repo}/issues/${number}/labels`, "--input", "-"],
|
|
125
|
+
stdin: JSON.stringify({ labels })
|
|
126
|
+
};
|
|
127
|
+
const fb = {
|
|
128
|
+
argv: ["gh", "issue", "edit", number, "--repo", repo, "--add-label", labels.join(",")]
|
|
129
|
+
};
|
|
130
|
+
const out = await ghCall(rest, fb, call);
|
|
131
|
+
return out !== null ? out.trim() : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/lib/automerge-label.ts
|
|
135
|
+
async function markPrHandled(repo, pr, opts = {}) {
|
|
136
|
+
const out = await addLabels(repo, pr, [AUTOMERGE_HANDLED_LABEL], opts);
|
|
137
|
+
return out !== null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/bot-automerge.ts
|
|
141
|
+
function isEvaluablePrState(state) {
|
|
142
|
+
return state === "OPEN";
|
|
143
|
+
}
|
|
144
|
+
function detectBotPrType(view) {
|
|
145
|
+
if (isDependabotAuthor(view.author)) return "dependabot";
|
|
146
|
+
if (view.headRefName.startsWith(RELEASE_PLEASE_BRANCH_PREFIX) || view.labels.includes(RELEASE_PLEASE_PENDING_LABEL)) {
|
|
147
|
+
return "release-please";
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function semverWord(updateType) {
|
|
152
|
+
return updateType.slice("version-update:semver-".length);
|
|
153
|
+
}
|
|
154
|
+
function verdict(eligible, reason, prType, updateType) {
|
|
155
|
+
return { eligible, reason, prType, updateType };
|
|
156
|
+
}
|
|
157
|
+
function classifyBotPr(view, updateType) {
|
|
158
|
+
const prType = detectBotPrType(view);
|
|
159
|
+
if (!isEvaluablePrState(view.state)) {
|
|
160
|
+
return verdict(false, `PR is ${view.state.toLowerCase()} \u2014 no auto-merge action`, prType, null);
|
|
161
|
+
}
|
|
162
|
+
if (view.isCrossRepository) {
|
|
163
|
+
return verdict(false, "PR is from a fork (cross-repository) \u2014 never eligible", null, null);
|
|
164
|
+
}
|
|
165
|
+
if (prType === null) {
|
|
166
|
+
return verdict(false, "not a recognized bot PR (unknown or human author)", null, null);
|
|
167
|
+
}
|
|
168
|
+
if (prType === "release-please") {
|
|
169
|
+
return verdict(
|
|
170
|
+
true,
|
|
171
|
+
"release-please release PR \u2014 eligible once its required checks pass",
|
|
172
|
+
"release-please",
|
|
173
|
+
null
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (!isDependabotUpdateType(updateType)) {
|
|
177
|
+
const reason = updateType === void 0 || updateType === "" ? "Dependabot PR but update-type unavailable \u2014 not enabling auto-merge" : `Dependabot PR with unrecognized update-type "${updateType}" \u2014 not enabling auto-merge`;
|
|
178
|
+
return verdict(false, reason, "dependabot", null);
|
|
179
|
+
}
|
|
180
|
+
if (isEligibleDependabotUpdateType(updateType)) {
|
|
181
|
+
return verdict(
|
|
182
|
+
true,
|
|
183
|
+
`Dependabot ${semverWord(updateType)} update \u2014 eligible`,
|
|
184
|
+
"dependabot",
|
|
185
|
+
updateType
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return verdict(
|
|
189
|
+
false,
|
|
190
|
+
`Dependabot ${semverWord(updateType)} update \u2014 held for manual review`,
|
|
191
|
+
"dependabot",
|
|
192
|
+
updateType
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
function errorBotAutomergeVerdict(message) {
|
|
196
|
+
return verdict(false, `could not evaluate: ${message}`, null, null);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/bin/bot-automerge.ts
|
|
200
|
+
function usage() {
|
|
201
|
+
console.error(
|
|
202
|
+
"usage: ai-bot-automerge enable --pr <n> [--repo <o/r>] [--update-type <t>] [--json] [--cwd <path>]"
|
|
203
|
+
);
|
|
204
|
+
process.exit(2);
|
|
205
|
+
}
|
|
206
|
+
function parse(argv) {
|
|
207
|
+
const mode = argv[0];
|
|
208
|
+
if (!isBotAutomergeCommand(mode)) usage();
|
|
209
|
+
const args = { mode, json: false };
|
|
210
|
+
for (let i = 1; i < argv.length; i++) {
|
|
211
|
+
const a = argv[i];
|
|
212
|
+
if (a === "--pr") args.pr = Number(argv[++i]);
|
|
213
|
+
else if (a === "--repo") args.repo = argv[++i];
|
|
214
|
+
else if (a === "--update-type") args.updateType = argv[++i];
|
|
215
|
+
else if (a === "--cwd") args.cwd = argv[++i];
|
|
216
|
+
else if (a === "--json" || a === "--dry-run") args.json = true;
|
|
217
|
+
else usage();
|
|
218
|
+
}
|
|
219
|
+
if (mode === "enable" && !args.pr) usage();
|
|
220
|
+
return args;
|
|
221
|
+
}
|
|
222
|
+
async function ghJson(argv, cwd) {
|
|
223
|
+
const out = await ghCall({ argv }, null, { cwd });
|
|
224
|
+
if (out === null) return null;
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(out);
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function fetchPrView(repo, pr, cwd) {
|
|
232
|
+
return ghJson(
|
|
233
|
+
[
|
|
234
|
+
"gh",
|
|
235
|
+
"pr",
|
|
236
|
+
"view",
|
|
237
|
+
String(pr),
|
|
238
|
+
"--repo",
|
|
239
|
+
repo,
|
|
240
|
+
"--json",
|
|
241
|
+
"number,author,headRefName,headRepository,isCrossRepository,labels,state"
|
|
242
|
+
],
|
|
243
|
+
cwd
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
function emitVerdictJson(verdict2, isError) {
|
|
247
|
+
console.log(JSON.stringify(verdict2, null, 2));
|
|
248
|
+
if (isError) process.exitCode = 1;
|
|
249
|
+
}
|
|
250
|
+
async function enableAutoMerge(repo, pr, cwd) {
|
|
251
|
+
const out = await ghCall(
|
|
252
|
+
{ argv: ["gh", "pr", "merge", "--auto", "--squash", String(pr), "--repo", repo] },
|
|
253
|
+
null,
|
|
254
|
+
{ cwd }
|
|
255
|
+
);
|
|
256
|
+
return out !== null;
|
|
257
|
+
}
|
|
258
|
+
async function runEnable(repo, pr, args) {
|
|
259
|
+
const view = await fetchPrView(repo, pr, args.cwd);
|
|
260
|
+
if (!view) {
|
|
261
|
+
const msg = `could not read PR #${pr}`;
|
|
262
|
+
if (args.json) return emitVerdictJson(errorBotAutomergeVerdict(msg), true);
|
|
263
|
+
console.error(`#${pr}: ${msg} \u2014 not enabling auto-merge`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const prView = {
|
|
268
|
+
author: view.author?.login ?? "",
|
|
269
|
+
headRefName: view.headRefName,
|
|
270
|
+
// Fail safe: a missing field or a deleted head repository counts as a fork.
|
|
271
|
+
isCrossRepository: view.isCrossRepository !== false || !view.headRepository,
|
|
272
|
+
labels: view.labels.map((l) => l.name),
|
|
273
|
+
state: view.state
|
|
274
|
+
};
|
|
275
|
+
const verdict2 = classifyBotPr(prView, args.updateType);
|
|
276
|
+
if (args.json) return emitVerdictJson(verdict2, false);
|
|
277
|
+
if (!isEvaluablePrState(view.state)) {
|
|
278
|
+
console.log(`#${pr}: ${view.state.toLowerCase()} \u2014 skipping (no auto-merge action)`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (!verdict2.eligible) {
|
|
282
|
+
console.log(`#${pr}: not eligible \u2014 ${verdict2.reason}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const enabled = await enableAutoMerge(repo, pr, args.cwd);
|
|
286
|
+
if (!enabled) {
|
|
287
|
+
console.error(`#${pr}: eligible (${verdict2.reason}) but failed to enable auto-merge`);
|
|
288
|
+
process.exitCode = 1;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const labeled = await markPrHandled(repo, pr, { cwd: args.cwd });
|
|
292
|
+
const labelNote = labeled ? `; labeled "${AUTOMERGE_HANDLED_LABEL}"` : `; could not apply "${AUTOMERGE_HANDLED_LABEL}" label (non-fatal)`;
|
|
293
|
+
console.log(`#${pr}: eligible \u2014 auto-merge enabled (${verdict2.reason})${labelNote}`);
|
|
294
|
+
}
|
|
295
|
+
async function main() {
|
|
296
|
+
const args = parse(process.argv.slice(2));
|
|
297
|
+
const repo = await resolveRepoTarget({ repo: args.repo, cwd: args.cwd });
|
|
298
|
+
if (!repo) {
|
|
299
|
+
console.error("error: could not resolve target repo (pass --repo <owner/repo>)");
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
await runEnable(repo, args.pr, args);
|
|
303
|
+
}
|
|
304
|
+
void main();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var BOT_AUTOMERGE_COMMANDS = ["enable"];
|
|
3
|
+
function isBotAutomergeCommand(value) {
|
|
4
|
+
return value !== void 0 && BOT_AUTOMERGE_COMMANDS.includes(value);
|
|
5
|
+
}
|
|
6
|
+
var DEPENDABOT_AUTHOR = "dependabot[bot]";
|
|
7
|
+
var DEPENDABOT_AUTHOR_GH_CLI = "app/dependabot";
|
|
8
|
+
var DEPENDABOT_AUTHORS = [DEPENDABOT_AUTHOR, DEPENDABOT_AUTHOR_GH_CLI];
|
|
9
|
+
function isDependabotAuthor(login) {
|
|
10
|
+
return DEPENDABOT_AUTHORS.includes(login);
|
|
11
|
+
}
|
|
12
|
+
var RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
|
|
13
|
+
var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
|
|
14
|
+
var DEPENDABOT_UPDATE_TYPES = [
|
|
15
|
+
"version-update:semver-patch",
|
|
16
|
+
"version-update:semver-minor",
|
|
17
|
+
"version-update:semver-major"
|
|
18
|
+
];
|
|
19
|
+
var AUTO_MERGE_ELIGIBLE_UPDATE_TYPES = [
|
|
20
|
+
"version-update:semver-patch",
|
|
21
|
+
"version-update:semver-minor"
|
|
22
|
+
];
|
|
23
|
+
function isDependabotUpdateType(value) {
|
|
24
|
+
return value !== void 0 && DEPENDABOT_UPDATE_TYPES.includes(value);
|
|
25
|
+
}
|
|
26
|
+
function isEligibleDependabotUpdateType(value) {
|
|
27
|
+
return value !== void 0 && AUTO_MERGE_ELIGIBLE_UPDATE_TYPES.includes(value);
|
|
28
|
+
}
|
|
29
|
+
var BOT_PR_TYPES = ["dependabot", "release-please"];
|
|
30
|
+
var AUTOMERGE_HANDLED_LABEL = "auto-merge enabled";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
BOT_AUTOMERGE_COMMANDS,
|
|
34
|
+
isBotAutomergeCommand,
|
|
35
|
+
DEPENDABOT_AUTHOR,
|
|
36
|
+
DEPENDABOT_AUTHOR_GH_CLI,
|
|
37
|
+
DEPENDABOT_AUTHORS,
|
|
38
|
+
isDependabotAuthor,
|
|
39
|
+
RELEASE_PLEASE_BRANCH_PREFIX,
|
|
40
|
+
RELEASE_PLEASE_PENDING_LABEL,
|
|
41
|
+
DEPENDABOT_UPDATE_TYPES,
|
|
42
|
+
AUTO_MERGE_ELIGIBLE_UPDATE_TYPES,
|
|
43
|
+
isDependabotUpdateType,
|
|
44
|
+
isEligibleDependabotUpdateType,
|
|
45
|
+
BOT_PR_TYPES,
|
|
46
|
+
AUTOMERGE_HANDLED_LABEL
|
|
47
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rmartz/bot-automerge — public entry point.
|
|
3
|
+
*
|
|
4
|
+
* Encodes the package's stable public contract: the CLI command surface that
|
|
5
|
+
* enables GitHub-native auto-merge for trustworthy bot pull requests. This is the
|
|
6
|
+
* "eligibility enabler" counterpart to `@rmartz/merge-safety`'s "safety verdict" —
|
|
7
|
+
* it classifies a bot PR and, when eligible, turns on native auto-merge. Unlike
|
|
8
|
+
* merge-safety it posts NO check-run and carries no fleet check-run contract.
|
|
9
|
+
*
|
|
10
|
+
* The classification + enablement implementation (and the `ai-bot-automerge` bin)
|
|
11
|
+
* lives alongside it; this module is intentionally the narrow, frozen surface the
|
|
12
|
+
* reusable workflow and consumers depend on. Extracted per rmartz/ai-tools#264.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The operations the `ai-bot-automerge` CLI dispatches:
|
|
16
|
+
* - `enable` — classify one bot PR and, when it is an eligible bot bump, turn on
|
|
17
|
+
* GitHub-native auto-merge (`gh pr merge --auto --squash`).
|
|
18
|
+
*/
|
|
19
|
+
declare const BOT_AUTOMERGE_COMMANDS: readonly ["enable"];
|
|
20
|
+
type BotAutomergeCommand = (typeof BOT_AUTOMERGE_COMMANDS)[number];
|
|
21
|
+
/** Type guard for the CLI command surface. */
|
|
22
|
+
declare function isBotAutomergeCommand(value: string | undefined): value is BotAutomergeCommand;
|
|
23
|
+
/**
|
|
24
|
+
* The GitHub login of the Dependabot app account in REST/GraphQL form. A PR
|
|
25
|
+
* authored by this login takes the Dependabot classification path (its
|
|
26
|
+
* eligibility then turns on the caller-supplied semver update-type).
|
|
27
|
+
*/
|
|
28
|
+
declare const DEPENDABOT_AUTHOR = "dependabot[bot]";
|
|
29
|
+
/**
|
|
30
|
+
* The same Dependabot author login as the `gh` CLI reports it. `gh pr view
|
|
31
|
+
* --json author` surfaces bot logins in `app/<slug>` form, not the REST/GraphQL
|
|
32
|
+
* `<slug>[bot]` form — so the CLI path (`ai-bot-automerge enable`, which reads
|
|
33
|
+
* the author via `gh`) sees this, and detection must accept it alongside
|
|
34
|
+
* {@link DEPENDABOT_AUTHOR}. (Missing this form is why every Dependabot PR was
|
|
35
|
+
* misclassified as "not a recognized bot PR" — issue #22.)
|
|
36
|
+
*/
|
|
37
|
+
declare const DEPENDABOT_AUTHOR_GH_CLI = "app/dependabot";
|
|
38
|
+
/**
|
|
39
|
+
* Every author login that marks a PR as Dependabot-authored, across the two
|
|
40
|
+
* shapes GitHub surfaces the account under: REST/GraphQL {@link DEPENDABOT_AUTHOR}
|
|
41
|
+
* and `gh` CLI {@link DEPENDABOT_AUTHOR_GH_CLI}. Detection accepts either.
|
|
42
|
+
*/
|
|
43
|
+
declare const DEPENDABOT_AUTHORS: readonly ["dependabot[bot]", "app/dependabot"];
|
|
44
|
+
/** True when `login` is a recognized Dependabot author login (either surface form). */
|
|
45
|
+
declare function isDependabotAuthor(login: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* The head-branch prefix release-please gives its release PR (e.g.
|
|
48
|
+
* `release-please--branches--main`). Either this prefix OR the pending label
|
|
49
|
+
* below marks a PR as a release-please release PR.
|
|
50
|
+
*/
|
|
51
|
+
declare const RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
|
|
52
|
+
/** The label release-please applies to its open release PR. */
|
|
53
|
+
declare const RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
|
|
54
|
+
/**
|
|
55
|
+
* The Dependabot semver update-type, as emitted verbatim by
|
|
56
|
+
* `dependabot/fetch-metadata` (`steps.metadata.outputs.update-type`). This is
|
|
57
|
+
* the ONLY source of the update-type — the CLI takes it via `--update-type` and
|
|
58
|
+
* never re-derives it from the PR title (which would be brittle and could
|
|
59
|
+
* disagree with fetch-metadata).
|
|
60
|
+
*/
|
|
61
|
+
declare const DEPENDABOT_UPDATE_TYPES: readonly ["version-update:semver-patch", "version-update:semver-minor", "version-update:semver-major"];
|
|
62
|
+
type DependabotUpdateType = (typeof DEPENDABOT_UPDATE_TYPES)[number];
|
|
63
|
+
/**
|
|
64
|
+
* The Dependabot update-types eligible for auto-merge: patch and minor only.
|
|
65
|
+
* Majors are potentially breaking and stay manual — a deliberate subset of
|
|
66
|
+
* {@link DEPENDABOT_UPDATE_TYPES}.
|
|
67
|
+
*/
|
|
68
|
+
declare const AUTO_MERGE_ELIGIBLE_UPDATE_TYPES: readonly ["version-update:semver-patch", "version-update:semver-minor"];
|
|
69
|
+
type EligibleDependabotUpdateType = (typeof AUTO_MERGE_ELIGIBLE_UPDATE_TYPES)[number];
|
|
70
|
+
/** Type guard: is `value` one of the three recognized Dependabot update-types? */
|
|
71
|
+
declare function isDependabotUpdateType(value: string | undefined): value is DependabotUpdateType;
|
|
72
|
+
/** Type guard: is `value` an auto-merge-eligible (patch/minor) update-type? */
|
|
73
|
+
declare function isEligibleDependabotUpdateType(value: string | undefined): value is EligibleDependabotUpdateType;
|
|
74
|
+
/**
|
|
75
|
+
* The kind of trustworthy bot PR bot-automerge recognizes. `dependabot` PRs are
|
|
76
|
+
* gated on their update-type; `release-please` release PRs are eligible as a
|
|
77
|
+
* whole. A PR matching neither is not a bot PR and is never eligible.
|
|
78
|
+
*/
|
|
79
|
+
declare const BOT_PR_TYPES: readonly ["dependabot", "release-please"];
|
|
80
|
+
type BotPrType = (typeof BOT_PR_TYPES)[number];
|
|
81
|
+
/**
|
|
82
|
+
* The verdict `classifyBotPr` yields and the `--json` / `--dry-run` mode prints.
|
|
83
|
+
* This shape is the frozen decision contract:
|
|
84
|
+
* - `eligible` — true iff bot-automerge should enable native auto-merge.
|
|
85
|
+
* - `reason` — a one-line human-readable justification (positive or negative).
|
|
86
|
+
* - `prType` — the detected bot path, or `null` when the PR is not a bot PR.
|
|
87
|
+
* - `updateType` — the recognized Dependabot update-type, or `null` (always
|
|
88
|
+
* `null` for release-please, unknown bots, or an unavailable/unrecognized
|
|
89
|
+
* update-type).
|
|
90
|
+
*/
|
|
91
|
+
interface BotAutomergeVerdict {
|
|
92
|
+
eligible: boolean;
|
|
93
|
+
reason: string;
|
|
94
|
+
prType: BotPrType | null;
|
|
95
|
+
updateType: DependabotUpdateType | null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The label bot-automerge applies to a PR at the moment it turns on native
|
|
99
|
+
* auto-merge, so external processes (triage bots, dashboards, PR coordinators)
|
|
100
|
+
* can see the PR is already owned by bot-automerge and need not route it for
|
|
101
|
+
* manual merge handling.
|
|
102
|
+
*
|
|
103
|
+
* This is additive and human-visible; unlike `@rmartz/merge-safety`'s check-run
|
|
104
|
+
* it carries NO required-status contract — bot-automerge still posts no check-run.
|
|
105
|
+
* The application is best-effort: it uses the caller's existing
|
|
106
|
+
* `pull-requests: write` scope, and consuming repos seed the label through their
|
|
107
|
+
* label roster (`ai-ensure-labels` / `labels.yml`).
|
|
108
|
+
*
|
|
109
|
+
* PUBLIC CONTRACT: external processes key on this string verbatim, so a package
|
|
110
|
+
* test pins it the way merge-safety pins its check-run name — a silent edit here
|
|
111
|
+
* would break every consumer keying off the label.
|
|
112
|
+
*/
|
|
113
|
+
declare const AUTOMERGE_HANDLED_LABEL = "auto-merge enabled";
|
|
114
|
+
|
|
115
|
+
export { AUTOMERGE_HANDLED_LABEL, AUTO_MERGE_ELIGIBLE_UPDATE_TYPES, BOT_AUTOMERGE_COMMANDS, BOT_PR_TYPES, type BotAutomergeCommand, type BotAutomergeVerdict, type BotPrType, DEPENDABOT_AUTHOR, DEPENDABOT_AUTHORS, DEPENDABOT_AUTHOR_GH_CLI, DEPENDABOT_UPDATE_TYPES, type DependabotUpdateType, type EligibleDependabotUpdateType, RELEASE_PLEASE_BRANCH_PREFIX, RELEASE_PLEASE_PENDING_LABEL, isBotAutomergeCommand, isDependabotAuthor, isDependabotUpdateType, isEligibleDependabotUpdateType };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTOMERGE_HANDLED_LABEL,
|
|
3
|
+
AUTO_MERGE_ELIGIBLE_UPDATE_TYPES,
|
|
4
|
+
BOT_AUTOMERGE_COMMANDS,
|
|
5
|
+
BOT_PR_TYPES,
|
|
6
|
+
DEPENDABOT_AUTHOR,
|
|
7
|
+
DEPENDABOT_AUTHORS,
|
|
8
|
+
DEPENDABOT_AUTHOR_GH_CLI,
|
|
9
|
+
DEPENDABOT_UPDATE_TYPES,
|
|
10
|
+
RELEASE_PLEASE_BRANCH_PREFIX,
|
|
11
|
+
RELEASE_PLEASE_PENDING_LABEL,
|
|
12
|
+
isBotAutomergeCommand,
|
|
13
|
+
isDependabotAuthor,
|
|
14
|
+
isDependabotUpdateType,
|
|
15
|
+
isEligibleDependabotUpdateType
|
|
16
|
+
} from "./chunk-MM4DLJ6N.js";
|
|
17
|
+
export {
|
|
18
|
+
AUTOMERGE_HANDLED_LABEL,
|
|
19
|
+
AUTO_MERGE_ELIGIBLE_UPDATE_TYPES,
|
|
20
|
+
BOT_AUTOMERGE_COMMANDS,
|
|
21
|
+
BOT_PR_TYPES,
|
|
22
|
+
DEPENDABOT_AUTHOR,
|
|
23
|
+
DEPENDABOT_AUTHORS,
|
|
24
|
+
DEPENDABOT_AUTHOR_GH_CLI,
|
|
25
|
+
DEPENDABOT_UPDATE_TYPES,
|
|
26
|
+
RELEASE_PLEASE_BRANCH_PREFIX,
|
|
27
|
+
RELEASE_PLEASE_PENDING_LABEL,
|
|
28
|
+
isBotAutomergeCommand,
|
|
29
|
+
isDependabotAuthor,
|
|
30
|
+
isDependabotUpdateType,
|
|
31
|
+
isEligibleDependabotUpdateType
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rmartz/bot-automerge",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Enables GitHub-native auto-merge for trustworthy bot pull requests (Dependabot patch/minor bumps and release-please release PRs), distributed via a reusable workflow kept current by Dependabot.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ci",
|
|
7
|
+
"bot-automerge",
|
|
8
|
+
"auto-merge",
|
|
9
|
+
"github-actions",
|
|
10
|
+
"reusable-workflow",
|
|
11
|
+
"dependabot"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/rmartz/bot-automerge#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/rmartz/bot-automerge/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-bot-automerge": "./dist/bin/bot-automerge.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/bot-automerge.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
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^26.6.1",
|
|
54
|
+
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
55
|
+
"@typescript-eslint/parser": "^8.70.0",
|
|
56
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
57
|
+
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
58
|
+
"eslint": "^10.10.0",
|
|
59
|
+
"eslint-import-resolver-typescript": "^4.4.5",
|
|
60
|
+
"eslint-plugin-import": "^2.32.0",
|
|
61
|
+
"prettier": "^3.9.6",
|
|
62
|
+
"semantic-release": "^25.0.9",
|
|
63
|
+
"tsup": "^8.5.1",
|
|
64
|
+
"typescript": "^6.0.3",
|
|
65
|
+
"vite": "^8.3.0",
|
|
66
|
+
"vitest": "^5.0.1"
|
|
67
|
+
},
|
|
68
|
+
"pnpm": {
|
|
69
|
+
"overrides": {
|
|
70
|
+
"esbuild": ">=0.28.1"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|