@rmartz/bot-automerge 1.0.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.
@@ -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.2",
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",