@link-assistant/hive-mind 2.11.11 → 2.11.13

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Shared human-readable rendering of structured error payloads (issue #2141).
3
+ *
4
+ * Agentic CLIs publish errors as *objects*, not strings. `@link-assistant/agent`
5
+ * 0.25.x emits `NamedError.toObject()`:
6
+ *
7
+ * {"type":"error","error":{"name":"RetryTimeoutExceededError","data":{"message":"…"}}}
8
+ *
9
+ * Every adapter that interpolated such a payload into a template literal
10
+ * (`Agent reported error: ${outputError.match}`) destroyed the diagnosis and
11
+ * published `AGENT execution failed with Agent reported error: [object Object]`
12
+ * to the GitHub issue, which is what issue #2141 reported. This module turns any
13
+ * of those shapes into text a human can act on, and is reused by every tool
14
+ * adapter so the defect cannot come back in one CLI at a time.
15
+ */
16
+
17
+ export const MAX_ERROR_TEXT_LENGTH = 2000;
18
+
19
+ /** Text that carries no diagnostic value even though it is a non-empty string. */
20
+ const PLACEHOLDER_ERROR_TEXTS = new Set(['[object object]', '[object error]', 'undefined', 'null', '{}', '[]']);
21
+
22
+ /**
23
+ * `[object Object]` (and friends) must never be published as a failure reason:
24
+ * it is the symptom this module exists to remove, so callers can assert on it.
25
+ */
26
+ export const isPlaceholderErrorText = value => {
27
+ if (typeof value !== 'string') return false;
28
+ return PLACEHOLDER_ERROR_TEXTS.has(value.trim().toLowerCase());
29
+ };
30
+
31
+ const truncateErrorText = (text, maxLength) => {
32
+ if (typeof text !== 'string') return '';
33
+ if (!Number.isFinite(maxLength) || maxLength <= 0) return text;
34
+ if (text.length <= maxLength) return text;
35
+ return `${text.slice(0, maxLength)}… (truncated)`;
36
+ };
37
+
38
+ const safeJsonStringify = value => {
39
+ const seen = new WeakSet();
40
+ try {
41
+ return JSON.stringify(value, (_key, entry) => {
42
+ if (entry && typeof entry === 'object') {
43
+ if (seen.has(entry)) return '[Circular]';
44
+ seen.add(entry);
45
+ }
46
+ if (typeof entry === 'bigint') return entry.toString();
47
+ if (typeof entry === 'function') return `[Function ${entry.name || 'anonymous'}]`;
48
+ return entry;
49
+ });
50
+ } catch {
51
+ return null;
52
+ }
53
+ };
54
+
55
+ const joinParts = parts => parts.filter(part => typeof part === 'string' && part.trim().length > 0).join(': ');
56
+
57
+ const stringifyValue = (value, depth) => {
58
+ if (value === null || value === undefined) return '';
59
+ if (typeof value === 'string') return value.trim();
60
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
61
+ if (typeof value === 'symbol' || typeof value === 'function') return String(value);
62
+
63
+ if (Array.isArray(value)) {
64
+ if (depth > 4) return safeJsonStringify(value) || String(value);
65
+ const rendered = value
66
+ .map(entry => stringifyValue(entry, depth + 1))
67
+ .filter(Boolean)
68
+ .join('; ');
69
+ return rendered || safeJsonStringify(value) || String(value);
70
+ }
71
+
72
+ if (typeof value !== 'object') return String(value);
73
+ if (depth > 4) return safeJsonStringify(value) || String(value);
74
+
75
+ if (value instanceof Error) {
76
+ const rendered = joinParts([value.name && value.name !== 'Error' ? value.name : '', value.message]);
77
+ return rendered || value.name || 'Error';
78
+ }
79
+
80
+ // `NamedError.toObject()` from @link-assistant/agent: {name, data:{message,…}}.
81
+ // The name alone ("RetryTimeoutExceededError") is already actionable, so keep
82
+ // it even when `data` carries no message.
83
+ const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null;
84
+
85
+ const nestedCandidates = [value.message, value.data, value.error, value.reason, value.cause, value.detail, value.details, value.description, value.hint, value.result, value.stderr];
86
+
87
+ for (const candidate of nestedCandidates) {
88
+ if (candidate === null || candidate === undefined) continue;
89
+ const rendered = stringifyValue(candidate, depth + 1);
90
+ if (!rendered || isPlaceholderErrorText(rendered)) continue;
91
+ // Avoid "Foo: Foo" when the nested value repeats the name.
92
+ if (name && rendered === name) return name;
93
+ return joinParts([name, rendered]);
94
+ }
95
+
96
+ if (name) return name;
97
+
98
+ const json = safeJsonStringify(value);
99
+ if (json && json !== '{}') return json;
100
+ return '';
101
+ };
102
+
103
+ /**
104
+ * Render any error payload (string, Error, NamedError object, nested envelope,
105
+ * array of the above) as a single human-readable line.
106
+ *
107
+ * @param {unknown} value - the payload as received from the tool stream.
108
+ * @param {object} [options]
109
+ * @param {number} [options.maxLength] - truncation budget for the rendered text.
110
+ * @param {string} [options.fallback] - returned when nothing readable is found.
111
+ * @returns {string} readable text, never `[object Object]`.
112
+ */
113
+ export const stringifyErrorValue = (value, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
114
+ const rendered = stringifyValue(value, 0);
115
+ if (!rendered || isPlaceholderErrorText(rendered)) return fallback;
116
+ return truncateErrorText(rendered, maxLength);
117
+ };
118
+
119
+ /**
120
+ * Pick the first readable rendering among several candidate payloads.
121
+ * Used where an adapter has to try `data.message`, then `data.error`, then the
122
+ * raw record text (the exact chain that produced `[object Object]` before).
123
+ */
124
+ export const firstErrorText = (candidates, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
125
+ for (const candidate of Array.isArray(candidates) ? candidates : [candidates]) {
126
+ const rendered = stringifyErrorValue(candidate, { maxLength });
127
+ if (rendered) return rendered;
128
+ }
129
+ return fallback;
130
+ };
@@ -17,6 +17,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
17
17
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
18
18
  const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
19
19
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
20
21
  import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs';
21
22
  import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
22
23
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
@@ -210,7 +211,8 @@ const applyGeminiJsonEvent = (event, nextState, modelId = null) => {
210
211
  }
211
212
 
212
213
  if (data.error) {
213
- nextState.errorMessages.push(extractGeminiTextContent(data.error) || JSON.stringify(data.error));
214
+ // Issue #2141: prefer readable text over a raw JSON dump of the payload.
215
+ nextState.errorMessages.push(extractGeminiTextContent(data.error) || stringifyErrorValue(data.error, { fallback: JSON.stringify(data.error) }));
214
216
  } else if (type.toLowerCase().includes('error')) {
215
217
  nextState.errorMessages.push(text || JSON.stringify(data));
216
218
  }
@@ -4,11 +4,18 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
4
4
  /**
5
5
  * Detect terminal GitHub entity states for long-running watch/merge loops.
6
6
  *
7
- * These checks intentionally treat 404-style repository, PR, issue, and branch
7
+ * These checks intentionally treat 404-style repository, PR, and branch
8
8
  * responses as terminal. In a solver loop, deleted entities and lost access are
9
9
  * not transient CI states; retrying them indefinitely wastes time and tokens.
10
10
  *
11
+ * Issue #2144: the linked *issue* is deliberately NOT terminal. A closed or
12
+ * deleted issue does not stop the pull request from becoming mergeable, so the
13
+ * watch/auto-restart loop must keep working. Those states are reported as
14
+ * `mergeBlockers` instead: they only block the final automatic merge, and the
15
+ * caller asks the user to reopen the issue or merge manually.
16
+ *
11
17
  * @see https://github.com/link-assistant/hive-mind/issues/1931
18
+ * @see https://github.com/link-assistant/hive-mind/issues/2144
12
19
  */
13
20
 
14
21
  let defaultCommandRunner = null;
@@ -65,15 +72,31 @@ const terminal = ({ reason, message, details = [], success = false, data = null
65
72
  message,
66
73
  details,
67
74
  data,
75
+ mergeBlockers: [],
68
76
  });
69
77
 
70
- const ok = (data = {}) => ({
78
+ const ok = (data = {}, mergeBlockers = []) => ({
71
79
  terminal: false,
72
80
  success: null,
73
81
  reason: null,
74
82
  message: null,
75
83
  details: [],
76
84
  data,
85
+ mergeBlockers,
86
+ });
87
+
88
+ /**
89
+ * Issue #2144: a non-terminal state that still prevents an *automatic* merge.
90
+ *
91
+ * The watch/auto-restart loop must keep making the pull request mergeable; only
92
+ * the final `--auto-merge` step is gated, and the user is asked to reopen the
93
+ * issue or merge manually.
94
+ */
95
+ const mergeBlocker = ({ reason, message, details = [], resolution }) => ({
96
+ reason,
97
+ message,
98
+ details,
99
+ resolution,
77
100
  });
78
101
 
79
102
  const safeJsonParse = value => {
@@ -241,27 +264,41 @@ export const checkGitHubTerminalState = async ({ owner, repo, issueNumber = null
241
264
  if (targetBranchState.terminal) return targetBranchState;
242
265
  }
243
266
 
267
+ // Issue #2144: issue-scoped problems never stop the loop. They are collected
268
+ // as merge blockers so the pull request still gets made mergeable.
269
+ const mergeBlockers = [];
270
+
244
271
  if (issueNumber && String(issueNumber) !== String(prNumber)) {
245
272
  const issueResult = await runCommand(runner, ['gh api repos/', '/', '/issues/', ''], owner, repo, issueNumber);
246
273
  if (commandFailedTerminally(issueResult)) {
247
- return terminal({
248
- reason: 'issue_unavailable',
249
- message: `Issue #${issueNumber} in ${owner}/${repo} is no longer accessible.`,
250
- details: [getTerminalGitHubEntityErrorMessage(issueResult)],
251
- });
274
+ mergeBlockers.push(
275
+ mergeBlocker({
276
+ reason: 'issue_unavailable',
277
+ message: `Issue #${issueNumber} in ${owner}/${repo} is no longer accessible.`,
278
+ details: [getTerminalGitHubEntityErrorMessage(issueResult)],
279
+ resolution: `Restore or re-create issue #${issueNumber}, or merge this pull request manually.`,
280
+ })
281
+ );
282
+ return ok({ repo: repoData }, mergeBlockers);
252
283
  }
253
284
 
254
285
  const issueData = safeJsonParse(issueResult.stdout);
255
286
  if (String(issueData?.state || '').toLowerCase() === 'closed') {
256
- return terminal({
257
- reason: 'issue_closed',
258
- message: `Issue #${issueNumber} has been closed.`,
259
- data: { issue: issueData, repo: repoData },
260
- });
287
+ mergeBlockers.push(
288
+ mergeBlocker({
289
+ reason: 'issue_closed',
290
+ message: `Issue #${issueNumber} has been closed.`,
291
+ details: [],
292
+ resolution: `Reopen issue #${issueNumber} so auto-merge can complete, or merge this pull request manually.`,
293
+ })
294
+ );
295
+ return ok({ issue: issueData, repo: repoData }, mergeBlockers);
261
296
  }
297
+
298
+ return ok({ issue: issueData, repo: repoData }, mergeBlockers);
262
299
  }
263
300
 
264
- return ok({ repo: repoData });
301
+ return ok({ repo: repoData }, mergeBlockers);
265
302
  };
266
303
 
267
304
  export default {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { createCollapsible, createRawJsonSection, createRedactedRawJsonSection, escapeMarkdown, redactImageData, safeJsonStringify, truncateMiddle } from './interactive-mode.shared.lib.mjs';
4
4
  import { INTERACTIVE_SESSION_STARTED_MARKER } from './tool-comments.lib.mjs';
5
+ import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
5
6
 
6
7
  export const createCodexEventHandlers = ({ state, postComment, handleAssistantText, imageRenderer }) => {
7
8
  const handleCodexThreadStarted = async data => {
@@ -142,7 +143,9 @@ ${createRawJsonSection(data)}`);
142
143
  };
143
144
 
144
145
  const handleCodexError = async data => {
145
- const message = data.message || data.error?.message || 'Unknown Codex error';
146
+ // Issue #2141: `data.error` is often an object, so render it as text instead
147
+ // of letting `[object Object]` reach the GitHub comment.
148
+ const message = firstErrorText([data?.message, data?.error, data], { fallback: 'Unknown Codex error' });
146
149
  await postComment(`## ❌ Codex error
147
150
 
148
151
  ${createCollapsible('View error', escapeMarkdown(message), true)}
package/src/lib.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
3
  import { createCredentialStreamSanitizer, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
4
  import { recordLogBytes, resetLogGrowth } from './log-growth.lib.mjs'; // issue #2135: notice a session log that is running away
5
+ import { isPlaceholderErrorText } from './error-text.lib.mjs'; // issue #2141: never publish "[object Object]" as a reason
5
6
 
6
7
  export { maskToken };
7
8
 
@@ -762,6 +763,9 @@ export const isMeaningfulErrorText = value => {
762
763
  if (!value || typeof value !== 'string') return false;
763
764
  const collapsed = value.replace(/\s+/g, ' ').trim();
764
765
  if (!collapsed) return false;
766
+ // Issue #2141: "[object Object]" has letters, but it is the *absence* of an
767
+ // error message — a structured payload that was stringified by mistake.
768
+ if (isPlaceholderErrorText(collapsed)) return false;
765
769
  // Require at least one Unicode letter or number; pure punctuation/brackets
766
770
  // (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
767
771
  return /[\p{L}\p{N}]/u.test(collapsed);
@@ -820,6 +824,13 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
820
824
  // Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
821
825
  if (!isMeaningfulErrorText(rawCore)) return null;
822
826
 
827
+ // Issue #2141: a core that embeds "[object Object]" (e.g. "Agent reported
828
+ // error: [object Object]") carries no diagnosis at all. Publishing the generic
829
+ // phrase is more honest than publishing a stringified object, and it keeps the
830
+ // symptom out of GitHub comments even if a new adapter forgets to render its
831
+ // payload as text.
832
+ if (rawCore.includes('[object Object]')) return null;
833
+
823
834
  // Collapse to a single clean line and strip noise.
824
835
  const core = rawCore.replace(/\s+/g, ' ').trim();
825
836
  return core || null;
package/src/qwen.lib.mjs CHANGED
@@ -27,6 +27,7 @@ import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toToken
27
27
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
28
28
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
29
29
  import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
30
+ import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
30
31
 
31
32
  export const mapModelToId = model => qwenModels[model] || model;
32
33
 
@@ -41,18 +42,6 @@ const isQwenAuthError = output => {
41
42
  return text.includes('401') || text.includes('unauthorized') || text.includes('authentication') || text.includes('auth') || text.includes('login') || text.includes('api key') || text.includes('oauth free tier');
42
43
  };
43
44
 
44
- const stringifyErrorValue = value => {
45
- if (value === null || value === undefined) return '';
46
- if (typeof value === 'string') return value;
47
- if (typeof value?.message === 'string') return value.message;
48
- if (typeof value?.error?.message === 'string') return value.error.message;
49
- try {
50
- return JSON.stringify(value);
51
- } catch {
52
- return String(value);
53
- }
54
- };
55
-
56
45
  const getNestedValue = (object, pathParts) => {
57
46
  let cursor = object;
58
47
  for (const part of pathParts) {
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
+
4
+ /**
5
+ * One-shot `--auto-merge` attempt after a session ends.
6
+ *
7
+ * Extracted from solve.auto-merge.lib.mjs (Issue #2144) to keep both files
8
+ * under the 1500-line limit while the stop-reporting paths were added.
9
+ *
10
+ * Issue #2144 behaviour: a closed or missing linked issue is *not* a terminal
11
+ * state here either. The merge requirements are still evaluated, and only the
12
+ * final merge is held back — with a comment asking the user to reopen the
13
+ * issue or merge manually. Every other stop path reports its exact reason to
14
+ * the pull request.
15
+ *
16
+ * @see https://github.com/link-assistant/hive-mind/issues/2144
17
+ */
18
+
19
+ if (typeof globalThis.use === 'undefined') {
20
+ await ensureUseM();
21
+ }
22
+ const use = globalThis.use;
23
+
24
+ const { $: __rawDollar$ } = await use('command-stream');
25
+ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
26
+ const $ = wrapDollarWithGhRetry(__rawDollar$);
27
+
28
+ const lib = await import('./lib.mjs');
29
+ const { log, formatAligned } = lib;
30
+
31
+ const githubMergeLib = await import('./github-merge.lib.mjs');
32
+ const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI } = githubMergeLib;
33
+
34
+ const terminalStateLib = await import('./github-terminal-state.lib.mjs');
35
+ const { checkGitHubTerminalState } = terminalStateLib;
36
+
37
+ // Issue #2144: these probes answer with a ~33 KB pull request object and a full
38
+ // issue object on every iteration. Issue #2130 made the helper's own default
39
+ // runner quiet, but passing `$` here bypassed it and the payloads were still
40
+ // mirrored into the attached log. Bind the quiet options to the injected `$`.
41
+ const { quietProbe } = await import('./quiet-probe.lib.mjs');
42
+
43
+ const toolComments = await import('./tool-comments.lib.mjs');
44
+ const { AUTO_MERGED_MARKER, postTrackedComment } = toolComments;
45
+
46
+ const stopReporting = await import('./automation-stop-reporting.lib.mjs');
47
+ const { AUTO_MERGE_BLOCKED_MARKER, buildAutoMergeBlockedComment, reportAutomationStop } = stopReporting;
48
+
49
+ const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
50
+
51
+ const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
52
+
53
+ /**
54
+ * Report the merge blockers that prevent an automatic merge of a pull request
55
+ * which otherwise satisfies every merge requirement (Issue #2144).
56
+ *
57
+ * @returns {Promise<{posted: boolean, reason: string, skipped?: string, error?: string}>}
58
+ */
59
+ export const reportAutoMergeBlockedByIssue = async ({ owner, repo, prNumber, issueNumber, mergeBlockers, verbose = false, commandRunner = $ }) => {
60
+ const blockers = (mergeBlockers || []).filter(Boolean);
61
+ if (blockers.length === 0) {
62
+ return { posted: false, reason: 'no_blockers', skipped: 'no_blockers' };
63
+ }
64
+
65
+ await log('');
66
+ await log(formatAligned('⚠️', 'AUTO-MERGE HELD BACK:', blockers.map(b => b.message).join('; '), 2), { level: 'warning' });
67
+ for (const blocker of blockers) {
68
+ if (blocker.resolution) {
69
+ await log(formatAligned('', 'Action:', blocker.resolution, 4), { level: 'warning' });
70
+ }
71
+ }
72
+
73
+ return reportAutomationStop({
74
+ $: commandRunner,
75
+ owner,
76
+ repo,
77
+ targetNumber: prNumber,
78
+ reason: blockers[0].reason,
79
+ mode: 'auto-merge',
80
+ verbose,
81
+ log,
82
+ body: buildAutoMergeBlockedComment({ blockers, issueNumber }),
83
+ signature: AUTO_MERGE_BLOCKED_MARKER,
84
+ });
85
+ };
86
+
87
+ /**
88
+ * Attempt to auto-merge a PR after the session ends.
89
+ * Implements the one-shot `--auto-merge` path.
90
+ */
91
+ export const attemptAutoMerge = async params => {
92
+ const { owner, repo, prNumber, issueNumber = null, argv } = params;
93
+
94
+ await log('');
95
+ await log(formatAligned('🔀', 'AUTO-MERGE:', 'Checking if PR can be merged...'));
96
+
97
+ const terminalState = await checkGitHubTerminalState({
98
+ owner,
99
+ repo,
100
+ issueNumber,
101
+ prNumber,
102
+ commandRunner: quietProbe($),
103
+ });
104
+ if (terminalState.terminal) {
105
+ if (terminalState.success) {
106
+ await log(formatAligned('🎉', 'PR already merged:', `#${prNumber}`, 2));
107
+ return { success: true, reason: 'merged' };
108
+ }
109
+ await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', terminalState.message, 2), { level: 'error' });
110
+ for (const detail of terminalState.details || []) {
111
+ await log(formatAligned('', 'Detail:', detail, 4), { level: 'error' });
112
+ }
113
+ // Issue #2144: never stop silently — publish the exact reason.
114
+ await reportAutomationStop({
115
+ $,
116
+ owner,
117
+ repo,
118
+ targetNumber: prNumber,
119
+ reason: terminalState.reason,
120
+ mode: 'auto-merge',
121
+ message: terminalState.message,
122
+ details: terminalState.details,
123
+ verbose: argv.verbose,
124
+ log,
125
+ });
126
+ return { success: false, reason: terminalState.reason, error: terminalState.message };
127
+ }
128
+
129
+ // Issue #2144: a closed/unavailable linked issue blocks only the merge step.
130
+ const issueMergeBlockers = terminalState.mergeBlockers || [];
131
+
132
+ // Issue #1226: Check merge permissions before attempting
133
+ const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
134
+ if (!canMerge) {
135
+ await log(formatAligned('⚠️', 'Cannot merge:', `Insufficient permissions (${permission || 'unknown'})`, 2));
136
+ return { success: false, reason: 'insufficient_permissions', error: `User has ${permission || 'unknown'} access, needs push/maintain/admin` };
137
+ }
138
+
139
+ // Wait for CI to complete (with timeout)
140
+ const ciWaitResult = await waitForCI(
141
+ owner,
142
+ repo,
143
+ prNumber,
144
+ {
145
+ timeout: argv.autoMergeCiTimeout || 30 * 60 * 1000, // 30 minutes default
146
+ pollInterval: argv.autoMergeCiPollInterval || 30 * 1000, // 30 seconds default
147
+ onStatusUpdate: async status => {
148
+ if (argv.verbose) {
149
+ await log(` CI status: ${status.status}`, { verbose: true });
150
+ }
151
+ },
152
+ },
153
+ argv.verbose
154
+ );
155
+
156
+ if (!ciWaitResult.success) {
157
+ await log(formatAligned('⚠️', 'CI check failed or timed out:', ciWaitResult.error || ciWaitResult.status, 2));
158
+ return { success: false, reason: ciWaitResult.status, error: ciWaitResult.error };
159
+ }
160
+
161
+ await log(formatAligned('✅', 'CI checks passed:', 'Checking mergeability...', 2));
162
+
163
+ // Check if PR is mergeable
164
+ const mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
165
+ if (mergeStatus.terminal) {
166
+ await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', mergeStatus.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible', 2), { level: 'error' });
167
+ await reportAutomationStop({
168
+ $,
169
+ owner,
170
+ repo,
171
+ targetNumber: prNumber,
172
+ reason: 'terminal_github_entity_error',
173
+ mode: 'auto-merge',
174
+ message: mergeStatus.reason,
175
+ verbose: argv.verbose,
176
+ log,
177
+ });
178
+ return { success: false, reason: 'terminal_github_entity_error', error: mergeStatus.reason };
179
+ }
180
+
181
+ if (!mergeStatus.mergeable) {
182
+ await log(formatAligned('⚠️', 'PR not mergeable:', mergeStatus.reason || 'Unknown reason', 2));
183
+ return { success: false, reason: 'not_mergeable', error: mergeStatus.reason };
184
+ }
185
+
186
+ // Issue #2144: the pull request is ready. If the linked issue is closed or
187
+ // gone, do not merge automatically — ask the user to reopen it or merge
188
+ // manually, and say so on the pull request.
189
+ if (issueMergeBlockers.length > 0) {
190
+ await reportAutoMergeBlockedByIssue({ owner, repo, prNumber, issueNumber, mergeBlockers: issueMergeBlockers, verbose: argv.verbose });
191
+ return { success: false, reason: issueMergeBlockers[0].reason, error: issueMergeBlockers[0].message, mergeBlockers: issueMergeBlockers };
192
+ }
193
+
194
+ await log(formatAligned('✅', 'PR is mergeable:', 'Attempting to merge...', 2));
195
+
196
+ // Attempt to merge
197
+ const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
198
+ if (deleteAfterMerge) {
199
+ await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
200
+ }
201
+ const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
202
+
203
+ if (mergeResult.success) {
204
+ await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
205
+
206
+ // Post success comment
207
+ try {
208
+ const commentBody = `## 🎉 ${AUTO_MERGED_MARKER}\n\nThis pull request has been automatically merged by hive-mind after all CI checks passed and the PR became mergeable.\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
209
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
210
+ } catch {
211
+ // Don't fail if comment posting fails
212
+ }
213
+
214
+ // Issue #1895: close linked issue explicitly when GitHub will not (non-default base branch).
215
+ try {
216
+ const closeResult = await ensureLinkedIssueClosedAfterMerge({ $, log, owner, repo, prNumber, issueNumber, verbose: argv.verbose });
217
+ if (!closeResult.closed && !closeResult.skipped) {
218
+ await log(formatAligned('⚠️', 'Issue auto-close:', `could not close linked issue (${closeResult.reason})`, 2), { level: 'warning' });
219
+ }
220
+ } catch (closeError) {
221
+ await log(formatAligned('⚠️', 'Issue auto-close:', `error: ${closeError.message}`, 2), { level: 'warning' });
222
+ }
223
+
224
+ return { success: true, reason: 'merged' };
225
+ } else {
226
+ await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2));
227
+ await reportAutomationStop({
228
+ $,
229
+ owner,
230
+ repo,
231
+ targetNumber: prNumber,
232
+ reason: 'merge_failed',
233
+ mode: 'auto-merge',
234
+ message: mergeResult.error || 'GitHub rejected the merge request.',
235
+ verbose: argv.verbose,
236
+ log,
237
+ });
238
+ return { success: false, reason: 'merge_failed', error: mergeResult.error };
239
+ }
240
+ };
241
+
242
+ export default {
243
+ attemptAutoMerge,
244
+ reportAutoMergeBlockedByIssue,
245
+ };