@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 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
+ };
@@ -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
+ }