@rmartz/bot-automerge 0.2.1 → 1.0.2
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 +29 -46
- package/dist/bin/bot-automerge.js +73 -25
- package/dist/index.d.ts +8 -4
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -11,69 +11,46 @@ merge?", bot-automerge answers "is this a bot PR we trust enough to enable
|
|
|
11
11
|
auto-merge on?". Unlike merge-safety, it posts **no check-run**.
|
|
12
12
|
|
|
13
13
|
It is distributed the same way [`@rmartz/repo-hygiene`](https://github.com/rmartz/repo-hygiene)
|
|
14
|
-
and `@rmartz/merge-safety` are
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
and `@rmartz/merge-safety` are — the package lives here, and consumers reach it
|
|
15
|
+
through a separate action repo:
|
|
16
|
+
|
|
17
|
+
1. **Updates propagate automatically.** Consuming repos pin
|
|
18
|
+
[`rmartz/bot-automerge-action`](https://github.com/rmartz/bot-automerge-action)
|
|
19
|
+
by SHA; Dependabot's `github-actions` ecosystem opens PRs to bump that pin. The
|
|
20
|
+
action pins this package's version in its own `package.json`, bumped by
|
|
21
|
+
Dependabot's `npm` ecosystem, so a new CLI release reaches consumers as an
|
|
22
|
+
action release.
|
|
18
23
|
2. **No check-run contract.** bot-automerge is purely an eligibility enabler — it
|
|
19
24
|
requires no fleet-wide status-check name. See
|
|
20
25
|
[docs/bot-automerge-contract.md](docs/bot-automerge-contract.md).
|
|
21
26
|
|
|
22
27
|
> **Status: implemented.** The `enable` classification — Dependabot `patch`/`minor`
|
|
23
|
-
> bumps and release-please release PRs —
|
|
24
|
-
>
|
|
28
|
+
> bumps and release-please release PRs — shipped in `v0.1.0`, the first release to
|
|
29
|
+
> GitHub Packages. It was built per
|
|
25
30
|
> [ai-tools#264](https://github.com/rmartz/ai-tools/issues/264); see
|
|
26
31
|
> [docs/bot-automerge-contract.md](docs/bot-automerge-contract.md) for the
|
|
27
32
|
> eligibility contract.
|
|
28
33
|
|
|
29
34
|
## Using it in a consuming repo
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
36
|
+
Use [`rmartz/bot-automerge-action`](https://github.com/rmartz/bot-automerge-action)
|
|
37
|
+
— its [consumer guide](https://github.com/rmartz/bot-automerge-action/blob/main/docs/consuming.md)
|
|
38
|
+
has the caller workflow, its permissions, and the `merge-safety` prerequisite.
|
|
39
|
+
This repo no longer ships a reusable workflow; see
|
|
40
|
+
[docs/consuming.md](docs/consuming.md) for what that means for existing callers.
|
|
56
41
|
|
|
57
42
|
> **Require `merge-safety` + your CI checks on the default branch _before_ adopting
|
|
58
|
-
>
|
|
43
|
+
> it.** `gh pr merge --auto` merges a PR immediately if the repo has no required
|
|
59
44
|
> status checks — bot-automerge only makes a bot PR _eligible_ to auto-merge; the
|
|
60
|
-
> repo's required checks are what it waits on.
|
|
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).
|
|
45
|
+
> repo's required checks are what it waits on.
|
|
69
46
|
|
|
70
47
|
## Requirements
|
|
71
48
|
|
|
72
49
|
- Node.js >= 20.11
|
|
73
50
|
- pnpm 9 (pinned via `packageManager`)
|
|
74
51
|
|
|
75
|
-
Consuming repos need neither —
|
|
76
|
-
GitHub-hosted runner.
|
|
52
|
+
Consuming repos need neither — `rmartz/bot-automerge-action` runs the published
|
|
53
|
+
CLI on a GitHub-hosted runner.
|
|
77
54
|
|
|
78
55
|
## Local development
|
|
79
56
|
|
|
@@ -90,10 +67,16 @@ pnpm run test # vitest
|
|
|
90
67
|
|
|
91
68
|
Versioned by [semantic-release](.releaserc.json). A push to `main` analyzes the
|
|
92
69
|
Conventional-Commit history since the last `bot-automerge-v*` tag and, when a
|
|
93
|
-
release is warranted, publishes the package to
|
|
94
|
-
|
|
70
|
+
release is warranted, publishes the package to npmjs (public, via OIDC trusted
|
|
71
|
+
publishing with provenance — no npm token) and creates the git tag + GitHub
|
|
72
|
+
Release. Versions up to 0.2.1 were published to GitHub Packages and stay there for
|
|
73
|
+
existing pins; new versions go to npmjs only. There is no release PR and no
|
|
74
|
+
commit-back, so the
|
|
95
75
|
built-in `GITHUB_TOKEN` suffices. `tagFormat` stays `bot-automerge-v${version}`
|
|
96
|
-
for continuity with the prior release-please tags.
|
|
76
|
+
for continuity with the prior release-please tags. A `Release dry-run` CI job
|
|
77
|
+
validates the semantic-release config (that the changelog toolchain renders) on
|
|
78
|
+
every PR, so a broken release setup is caught before merge rather than on the
|
|
79
|
+
post-merge release run.
|
|
97
80
|
|
|
98
81
|
---
|
|
99
82
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import {
|
|
3
3
|
AUTOMERGE_HANDLED_LABEL,
|
|
4
4
|
RELEASE_PLEASE_BRANCH_PREFIX,
|
|
5
|
-
RELEASE_PLEASE_PENDING_LABEL,
|
|
6
5
|
isBotAutomergeCommand,
|
|
7
6
|
isDependabotAuthor,
|
|
8
7
|
isDependabotUpdateType,
|
|
@@ -60,29 +59,38 @@ function isRateLimited(text) {
|
|
|
60
59
|
}
|
|
61
60
|
async function runTransport({ argv, stdin }, cwd) {
|
|
62
61
|
const [command, ...args] = argv;
|
|
63
|
-
if (command === void 0) return { stdout: null, stderr: "empty argv" };
|
|
62
|
+
if (command === void 0) return { stdout: null, failure: { code: null, stderr: "empty argv" } };
|
|
64
63
|
try {
|
|
65
64
|
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
|
-
|
|
65
|
+
if (r.code === 0) return { stdout: r.stdout, failure: { code: 0, stderr: "" } };
|
|
66
|
+
const stderr = r.timedOut ? `timed out after ${GH_API_TIMEOUT_MS}ms` : r.stderr || "";
|
|
67
|
+
return { stdout: null, failure: { code: r.timedOut ? null : r.code, stderr } };
|
|
68
68
|
} catch (err) {
|
|
69
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
stdout: null,
|
|
71
|
+
failure: { code: null, stderr: err instanceof Error ? err.message : String(err) }
|
|
72
|
+
};
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
|
-
async function
|
|
75
|
+
async function ghCallDetailed(primary, fallback, opts = {}) {
|
|
73
76
|
const sleep = opts.sleep ?? realSleep;
|
|
77
|
+
let lastFailure = { code: null, stderr: "no transport attempted" };
|
|
74
78
|
for (const transport of [primary, fallback]) {
|
|
75
79
|
if (!transport) continue;
|
|
76
80
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
77
|
-
const { stdout,
|
|
78
|
-
if (stdout !== null) return stdout;
|
|
79
|
-
|
|
81
|
+
const { stdout, failure } = await runTransport(transport, opts.cwd);
|
|
82
|
+
if (stdout !== null) return { stdout, failure: null };
|
|
83
|
+
lastFailure = failure;
|
|
84
|
+
if (isRateLimited(failure.stderr)) break;
|
|
80
85
|
if (attempt < MAX_RETRIES) {
|
|
81
86
|
await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
|
-
return null;
|
|
90
|
+
return { stdout: null, failure: lastFailure };
|
|
91
|
+
}
|
|
92
|
+
async function ghCall(primary, fallback, opts = {}) {
|
|
93
|
+
return (await ghCallDetailed(primary, fallback, opts)).stdout;
|
|
86
94
|
}
|
|
87
95
|
function parseSlugFromRemoteUrl(url) {
|
|
88
96
|
const trimmed = url.trim();
|
|
@@ -131,6 +139,43 @@ async function addLabels(repo, issue, labels, call = {}) {
|
|
|
131
139
|
return out !== null ? out.trim() : null;
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
// src/lib/gh-failure.ts
|
|
143
|
+
var MAX_DETAIL_CHARS = 200;
|
|
144
|
+
function classifyGhFailure(stderr) {
|
|
145
|
+
if (/rate limit|HTTP 429|abuse detection/i.test(stderr)) return "rate-limited";
|
|
146
|
+
if (/HTTP 401|Bad credentials|SAML|\bSSO\b|gh auth login/i.test(stderr)) {
|
|
147
|
+
return "token-rejected";
|
|
148
|
+
}
|
|
149
|
+
if (/Resource not accessible|HTTP 403|scope/i.test(stderr)) return "permission-denied";
|
|
150
|
+
if (/Could not resolve to a PullRequest/i.test(stderr)) return "pr-not-found";
|
|
151
|
+
if (/Could not resolve to a Repository/i.test(stderr)) return "repo-not-found";
|
|
152
|
+
if (/HTTP 404|Not Found/i.test(stderr)) return "pr-not-found";
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
function tokenSource(env = process.env) {
|
|
156
|
+
if (env.GH_TOKEN) return "GH_TOKEN";
|
|
157
|
+
if (env.GITHUB_TOKEN) return "GITHUB_TOKEN";
|
|
158
|
+
return "gh's stored login";
|
|
159
|
+
}
|
|
160
|
+
var SUMMARIES = {
|
|
161
|
+
"rate-limited": () => "rate limited by GitHub \u2014 wait for the limit to reset, then re-run",
|
|
162
|
+
"token-rejected": (source) => `token rejected (from ${source}) \u2014 it is expired, revoked, or SSO-deauthorized; rotate it, or unset it to fall back to the workflow token`,
|
|
163
|
+
"permission-denied": (source) => `token lacks permission (from ${source}) \u2014 it needs contents:write and pull-requests:write on this repo`,
|
|
164
|
+
"pr-not-found": () => "PR not found \u2014 check the PR number the caller passed",
|
|
165
|
+
"repo-not-found": (source) => `repository not found or not visible to the token (from ${source}) \u2014 check --repo and the token's repo access`,
|
|
166
|
+
unknown: () => "gh failed"
|
|
167
|
+
};
|
|
168
|
+
function firstLine(stderr) {
|
|
169
|
+
const line = stderr.split("\n").map((l) => l.trim()).find((l) => l !== "");
|
|
170
|
+
if (!line) return "(no stderr)";
|
|
171
|
+
return line.length > MAX_DETAIL_CHARS ? `${line.slice(0, MAX_DETAIL_CHARS)}\u2026` : line;
|
|
172
|
+
}
|
|
173
|
+
function describeGhFailure(failure, env = process.env) {
|
|
174
|
+
const summary = SUMMARIES[classifyGhFailure(failure.stderr)](tokenSource(env));
|
|
175
|
+
const exit = failure.code === null ? "gh did not exit cleanly" : `gh exited ${failure.code}`;
|
|
176
|
+
return `${summary} (${exit}: ${firstLine(failure.stderr)})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
// src/lib/automerge-label.ts
|
|
135
180
|
async function markPrHandled(repo, pr, opts = {}) {
|
|
136
181
|
const out = await addLabels(repo, pr, [AUTOMERGE_HANDLED_LABEL], opts);
|
|
@@ -143,9 +188,7 @@ function isEvaluablePrState(state) {
|
|
|
143
188
|
}
|
|
144
189
|
function detectBotPrType(view) {
|
|
145
190
|
if (isDependabotAuthor(view.author)) return "dependabot";
|
|
146
|
-
if (view.headRefName.startsWith(RELEASE_PLEASE_BRANCH_PREFIX)
|
|
147
|
-
return "release-please";
|
|
148
|
-
}
|
|
191
|
+
if (view.headRefName.startsWith(RELEASE_PLEASE_BRANCH_PREFIX)) return "release-please";
|
|
149
192
|
return null;
|
|
150
193
|
}
|
|
151
194
|
function semverWord(updateType) {
|
|
@@ -220,12 +263,14 @@ function parse(argv) {
|
|
|
220
263
|
return args;
|
|
221
264
|
}
|
|
222
265
|
async function ghJson(argv, cwd) {
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
266
|
+
const { stdout, failure } = await ghCallDetailed({ argv }, null, { cwd });
|
|
267
|
+
if (stdout === null) {
|
|
268
|
+
return { ok: false, why: failure ? describeGhFailure(failure) : "gh failed" };
|
|
269
|
+
}
|
|
225
270
|
try {
|
|
226
|
-
return JSON.parse(
|
|
271
|
+
return { ok: true, value: JSON.parse(stdout) };
|
|
227
272
|
} catch {
|
|
228
|
-
return
|
|
273
|
+
return { ok: false, why: "gh returned output that is not valid JSON" };
|
|
229
274
|
}
|
|
230
275
|
}
|
|
231
276
|
async function fetchPrView(repo, pr, cwd) {
|
|
@@ -248,22 +293,23 @@ function emitVerdictJson(verdict2, isError) {
|
|
|
248
293
|
if (isError) process.exitCode = 1;
|
|
249
294
|
}
|
|
250
295
|
async function enableAutoMerge(repo, pr, cwd) {
|
|
251
|
-
const
|
|
296
|
+
const { failure } = await ghCallDetailed(
|
|
252
297
|
{ argv: ["gh", "pr", "merge", "--auto", "--squash", String(pr), "--repo", repo] },
|
|
253
298
|
null,
|
|
254
299
|
{ cwd }
|
|
255
300
|
);
|
|
256
|
-
return
|
|
301
|
+
return failure ? describeGhFailure(failure) : null;
|
|
257
302
|
}
|
|
258
303
|
async function runEnable(repo, pr, args) {
|
|
259
|
-
const
|
|
260
|
-
if (!
|
|
261
|
-
const msg = `could not read PR #${pr}`;
|
|
304
|
+
const fetched = await fetchPrView(repo, pr, args.cwd);
|
|
305
|
+
if (!fetched.ok) {
|
|
306
|
+
const msg = `could not read PR #${pr} \u2014 ${fetched.why}`;
|
|
262
307
|
if (args.json) return emitVerdictJson(errorBotAutomergeVerdict(msg), true);
|
|
263
308
|
console.error(`#${pr}: ${msg} \u2014 not enabling auto-merge`);
|
|
264
309
|
process.exitCode = 1;
|
|
265
310
|
return;
|
|
266
311
|
}
|
|
312
|
+
const view = fetched.value;
|
|
267
313
|
const prView = {
|
|
268
314
|
author: view.author?.login ?? "",
|
|
269
315
|
headRefName: view.headRefName,
|
|
@@ -282,9 +328,11 @@ async function runEnable(repo, pr, args) {
|
|
|
282
328
|
console.log(`#${pr}: not eligible \u2014 ${verdict2.reason}`);
|
|
283
329
|
return;
|
|
284
330
|
}
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
287
|
-
console.error(
|
|
331
|
+
const mergeFailure = await enableAutoMerge(repo, pr, args.cwd);
|
|
332
|
+
if (mergeFailure !== null) {
|
|
333
|
+
console.error(
|
|
334
|
+
`#${pr}: eligible (${verdict2.reason}) but failed to enable auto-merge \u2014 ${mergeFailure}`
|
|
335
|
+
);
|
|
288
336
|
process.exitCode = 1;
|
|
289
337
|
return;
|
|
290
338
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The classification + enablement implementation (and the `ai-bot-automerge` bin)
|
|
11
11
|
* lives alongside it; this module is intentionally the narrow, frozen surface the
|
|
12
|
-
*
|
|
12
|
+
* bot-automerge-action and consumers depend on. Extracted per rmartz/ai-tools#264.
|
|
13
13
|
*/
|
|
14
14
|
/**
|
|
15
15
|
* The operations the `ai-bot-automerge` CLI dispatches:
|
|
@@ -45,11 +45,15 @@ declare const DEPENDABOT_AUTHORS: readonly ["dependabot[bot]", "app/dependabot"]
|
|
|
45
45
|
declare function isDependabotAuthor(login: string): boolean;
|
|
46
46
|
/**
|
|
47
47
|
* The head-branch prefix release-please gives its release PR (e.g.
|
|
48
|
-
* `release-please--branches--main`).
|
|
49
|
-
*
|
|
48
|
+
* `release-please--branches--main`). This prefix alone marks a PR as a
|
|
49
|
+
* release-please release PR.
|
|
50
50
|
*/
|
|
51
51
|
declare const RELEASE_PLEASE_BRANCH_PREFIX = "release-please--";
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* The label release-please applies to its open release PR. Informational only:
|
|
54
|
+
* it is NOT an eligibility signal, because anyone with triage permission can
|
|
55
|
+
* apply it to any PR (GHSA-4f7f-7fcp-gcm6).
|
|
56
|
+
*/
|
|
53
57
|
declare const RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
|
|
54
58
|
/**
|
|
55
59
|
* The Dependabot semver update-type, as emitted verbatim by
|
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/bot-automerge",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "Enables GitHub-native auto-merge for trustworthy bot pull requests (Dependabot patch/minor bumps and release-please release PRs), distributed via
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Enables GitHub-native auto-merge for trustworthy bot pull requests (Dependabot patch/minor bumps and release-please release PRs), distributed via rmartz/bot-automerge-action.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ci",
|
|
7
7
|
"bot-automerge",
|
|
8
8
|
"auto-merge",
|
|
9
9
|
"github-actions",
|
|
10
|
-
"reusable-workflow",
|
|
11
10
|
"dependabot"
|
|
12
11
|
],
|
|
13
12
|
"homepage": "https://github.com/rmartz/bot-automerge#readme",
|
|
@@ -50,12 +49,12 @@
|
|
|
50
49
|
"test:watch": "vitest"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
|
-
"@types/node": "^26.6.
|
|
52
|
+
"@types/node": "^26.6.2",
|
|
54
53
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
55
54
|
"@typescript-eslint/parser": "^8.70.0",
|
|
56
55
|
"@vitest/coverage-v8": "^5.0.1",
|
|
57
56
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
58
|
-
"eslint": "^10.
|
|
57
|
+
"eslint": "^10.11.0",
|
|
59
58
|
"eslint-import-resolver-typescript": "^4.4.5",
|
|
60
59
|
"eslint-plugin-import": "^2.32.0",
|
|
61
60
|
"prettier": "^3.9.6",
|