@rmartz/bot-automerge 1.0.1 → 1.0.3
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 +14 -10
- package/dist/bin/bot-automerge.js +72 -21
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -65,16 +65,20 @@ pnpm run test # vitest
|
|
|
65
65
|
|
|
66
66
|
## Releases
|
|
67
67
|
|
|
68
|
-
Versioned by [semantic-release](.releaserc.json)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for
|
|
77
|
-
|
|
68
|
+
Versioned by [semantic-release](.releaserc.json), run through the fleet's shared
|
|
69
|
+
[rmartz/semantic-release-ci](https://github.com/rmartz/semantic-release-ci)
|
|
70
|
+
workflow. A push to `main` analyzes the Conventional-Commit history since the
|
|
71
|
+
last `bot-automerge-v*` tag and, when a release is warranted, publishes the
|
|
72
|
+
package to npmjs (public, via OIDC trusted publishing with provenance — no npm
|
|
73
|
+
token) and creates the git tag + GitHub Release. The package is published
|
|
74
|
+
**before** the tag is pushed, so a failed publish never leaves a tag with no
|
|
75
|
+
package. Versions up to 0.2.1 were published to GitHub Packages and stay there
|
|
76
|
+
for existing pins; new versions go to npmjs only. `bot-automerge-v1.0.0` is a tag
|
|
77
|
+
with no package (its publish failed after tagging); use 1.0.1 or later. There is
|
|
78
|
+
no release PR and no commit-back, so the built-in `GITHUB_TOKEN` suffices.
|
|
79
|
+
`tagFormat` stays `bot-automerge-v${version}` for continuity with the prior
|
|
80
|
+
release-please tags. The shared `release-check / release-check` check validates
|
|
81
|
+
the semantic-release config (that commits analyze and the changelog renders) on
|
|
78
82
|
every PR, so a broken release setup is caught before merge rather than on the
|
|
79
83
|
post-merge release run.
|
|
80
84
|
|
|
@@ -59,29 +59,38 @@ function isRateLimited(text) {
|
|
|
59
59
|
}
|
|
60
60
|
async function runTransport({ argv, stdin }, cwd) {
|
|
61
61
|
const [command, ...args] = argv;
|
|
62
|
-
if (command === void 0) return { stdout: null, stderr: "empty argv" };
|
|
62
|
+
if (command === void 0) return { stdout: null, failure: { code: null, stderr: "empty argv" } };
|
|
63
63
|
try {
|
|
64
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
|
-
|
|
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 } };
|
|
67
68
|
} catch (err) {
|
|
68
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
stdout: null,
|
|
71
|
+
failure: { code: null, stderr: err instanceof Error ? err.message : String(err) }
|
|
72
|
+
};
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
|
-
async function
|
|
75
|
+
async function ghCallDetailed(primary, fallback, opts = {}) {
|
|
72
76
|
const sleep = opts.sleep ?? realSleep;
|
|
77
|
+
let lastFailure = { code: null, stderr: "no transport attempted" };
|
|
73
78
|
for (const transport of [primary, fallback]) {
|
|
74
79
|
if (!transport) continue;
|
|
75
80
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
76
|
-
const { stdout,
|
|
77
|
-
if (stdout !== null) return stdout;
|
|
78
|
-
|
|
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;
|
|
79
85
|
if (attempt < MAX_RETRIES) {
|
|
80
86
|
await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
}
|
|
84
|
-
return null;
|
|
90
|
+
return { stdout: null, failure: lastFailure };
|
|
91
|
+
}
|
|
92
|
+
async function ghCall(primary, fallback, opts = {}) {
|
|
93
|
+
return (await ghCallDetailed(primary, fallback, opts)).stdout;
|
|
85
94
|
}
|
|
86
95
|
function parseSlugFromRemoteUrl(url) {
|
|
87
96
|
const trimmed = url.trim();
|
|
@@ -130,6 +139,43 @@ async function addLabels(repo, issue, labels, call = {}) {
|
|
|
130
139
|
return out !== null ? out.trim() : null;
|
|
131
140
|
}
|
|
132
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
|
+
|
|
133
179
|
// src/lib/automerge-label.ts
|
|
134
180
|
async function markPrHandled(repo, pr, opts = {}) {
|
|
135
181
|
const out = await addLabels(repo, pr, [AUTOMERGE_HANDLED_LABEL], opts);
|
|
@@ -217,12 +263,14 @@ function parse(argv) {
|
|
|
217
263
|
return args;
|
|
218
264
|
}
|
|
219
265
|
async function ghJson(argv, cwd) {
|
|
220
|
-
const
|
|
221
|
-
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
|
+
}
|
|
222
270
|
try {
|
|
223
|
-
return JSON.parse(
|
|
271
|
+
return { ok: true, value: JSON.parse(stdout) };
|
|
224
272
|
} catch {
|
|
225
|
-
return
|
|
273
|
+
return { ok: false, why: "gh returned output that is not valid JSON" };
|
|
226
274
|
}
|
|
227
275
|
}
|
|
228
276
|
async function fetchPrView(repo, pr, cwd) {
|
|
@@ -245,22 +293,23 @@ function emitVerdictJson(verdict2, isError) {
|
|
|
245
293
|
if (isError) process.exitCode = 1;
|
|
246
294
|
}
|
|
247
295
|
async function enableAutoMerge(repo, pr, cwd) {
|
|
248
|
-
const
|
|
296
|
+
const { failure } = await ghCallDetailed(
|
|
249
297
|
{ argv: ["gh", "pr", "merge", "--auto", "--squash", String(pr), "--repo", repo] },
|
|
250
298
|
null,
|
|
251
299
|
{ cwd }
|
|
252
300
|
);
|
|
253
|
-
return
|
|
301
|
+
return failure ? describeGhFailure(failure) : null;
|
|
254
302
|
}
|
|
255
303
|
async function runEnable(repo, pr, args) {
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
258
|
-
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}`;
|
|
259
307
|
if (args.json) return emitVerdictJson(errorBotAutomergeVerdict(msg), true);
|
|
260
308
|
console.error(`#${pr}: ${msg} \u2014 not enabling auto-merge`);
|
|
261
309
|
process.exitCode = 1;
|
|
262
310
|
return;
|
|
263
311
|
}
|
|
312
|
+
const view = fetched.value;
|
|
264
313
|
const prView = {
|
|
265
314
|
author: view.author?.login ?? "",
|
|
266
315
|
headRefName: view.headRefName,
|
|
@@ -279,9 +328,11 @@ async function runEnable(repo, pr, args) {
|
|
|
279
328
|
console.log(`#${pr}: not eligible \u2014 ${verdict2.reason}`);
|
|
280
329
|
return;
|
|
281
330
|
}
|
|
282
|
-
const
|
|
283
|
-
if (
|
|
284
|
-
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
|
+
);
|
|
285
336
|
process.exitCode = 1;
|
|
286
337
|
return;
|
|
287
338
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmartz/bot-automerge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"test:watch": "vitest"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
+
"@semantic-release/exec": "^7.1.0",
|
|
52
53
|
"@types/node": "^26.6.2",
|
|
53
54
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
54
55
|
"@typescript-eslint/parser": "^8.70.0",
|