@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 CHANGED
@@ -65,16 +65,20 @@ pnpm run test # vitest
65
65
 
66
66
  ## Releases
67
67
 
68
- Versioned by [semantic-release](.releaserc.json). A push to `main` analyzes the
69
- Conventional-Commit history since the last `bot-automerge-v*` tag and, when a
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
75
- built-in `GITHUB_TOKEN` suffices. `tagFormat` stays `bot-automerge-v${version}`
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
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
- return { stdout: null, stderr: r.stderr || "" };
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 { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
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 ghCall(primary, fallback, opts = {}) {
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, stderr } = await runTransport(transport, opts.cwd);
77
- if (stdout !== null) return stdout;
78
- if (isRateLimited(stderr)) break;
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 out = await ghCall({ argv }, null, { cwd });
221
- if (out === null) return null;
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(out);
271
+ return { ok: true, value: JSON.parse(stdout) };
224
272
  } catch {
225
- return null;
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 out = await ghCall(
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 out !== null;
301
+ return failure ? describeGhFailure(failure) : null;
254
302
  }
255
303
  async function runEnable(repo, pr, args) {
256
- const view = await fetchPrView(repo, pr, args.cwd);
257
- if (!view) {
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 enabled = await enableAutoMerge(repo, pr, args.cwd);
283
- if (!enabled) {
284
- console.error(`#${pr}: eligible (${verdict2.reason}) but failed to enable auto-merge`);
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.1",
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",