@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.
- package/CHANGELOG.md +80 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +119 -20
- package/src/automation-stop-reporting.lib.mjs +272 -0
- package/src/cancelled-ci-rerun.lib.mjs +3 -1
- package/src/claude.lib.mjs +3 -2
- package/src/codex.lib.mjs +13 -12
- package/src/error-text.lib.mjs +130 -0
- package/src/gemini.lib.mjs +3 -1
- package/src/github-terminal-state.lib.mjs +50 -13
- package/src/interactive-codex-events.lib.mjs +4 -1
- package/src/lib.mjs +11 -0
- package/src/qwen.lib.mjs +1 -12
- package/src/solve.auto-merge-attempt.lib.mjs +245 -0
- package/src/solve.auto-merge.lib.mjs +54 -111
- package/src/solve.pre-pr-failure-notifier.lib.mjs +10 -2
- package/src/solve.watch.lib.mjs +35 -1
- package/src/tool-comments.lib.mjs +12 -1
|
@@ -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
|
+
};
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
+
};
|