@link-assistant/hive-mind 2.11.12 → 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 +41 -0
- package/package.json +1 -1
- package/src/automation-stop-reporting.lib.mjs +272 -0
- package/src/github-terminal-state.lib.mjs +50 -13
- package/src/solve.auto-merge-attempt.lib.mjs +245 -0
- package/src/solve.auto-merge.lib.mjs +54 -111
- package/src/solve.watch.lib.mjs +35 -1
- package/src/tool-comments.lib.mjs +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 61f20ce: Closed issues no longer stop the mergeable loop, and every stop is now explained
|
|
8
|
+
in a GitHub comment (issue #2144).
|
|
9
|
+
|
|
10
|
+
A `solve …/pull/927 --auto-merge` run stopped after its first monitoring
|
|
11
|
+
iteration with `❌ GITHUB TARGET UNAVAILABLE: Issue #905 has been closed.` — in
|
|
12
|
+
the _same_ probe that reported the pull request as `"mergeable": true,
|
|
13
|
+
"mergeable_state": "clean"`. Nothing was posted to GitHub and the process exited
|
|
14
|
+
`0`, so the run looked successful while the pull request sat unmerged until a
|
|
15
|
+
human merged it manually two hours later.
|
|
16
|
+
|
|
17
|
+
- `src/github-terminal-state.lib.mjs` now distinguishes _terminal_ states from
|
|
18
|
+
_merge blockers_. A closed or deleted **issue** is a merge blocker: the loop
|
|
19
|
+
keeps working to make the pull request mergeable. Only the pull request being
|
|
20
|
+
merged, closed or unreachable — or the repository/branches it needs being gone
|
|
21
|
+
— stops the loop.
|
|
22
|
+
- A closed issue holds back `--auto-merge` only. When it does, the tool comments
|
|
23
|
+
that the pull request is ready and asks the user to reopen the issue or merge
|
|
24
|
+
manually, instead of stopping silently.
|
|
25
|
+
- New `src/automation-stop-reporting.lib.mjs`: a registry of 13 stop reasons,
|
|
26
|
+
each with a title, an explanation and concrete next steps, plus a deduped,
|
|
27
|
+
never-throwing reporter. It is wired into all 11 stop paths of
|
|
28
|
+
`--auto-merge`, `--auto-restart-until-mergeable` and `--watch`. Unknown reason
|
|
29
|
+
codes degrade to a generic comment, so a new stop can never regress to
|
|
30
|
+
silence.
|
|
31
|
+
- `attemptAutoMerge` was extracted into
|
|
32
|
+
`src/solve.auto-merge-attempt.lib.mjs` to stay within the repository's
|
|
33
|
+
file-length policy.
|
|
34
|
+
- Fixed a second defect found in the same log: the quiet GitHub probes from
|
|
35
|
+
issue #2130 were silently defeated because all three callers injected their own
|
|
36
|
+
mirroring `$`, so ~33 KB of pull request JSON plus the full issue payload were
|
|
37
|
+
written to the attached log on every iteration. They now pass
|
|
38
|
+
`quietProbe($)`, with a regression test.
|
|
39
|
+
|
|
40
|
+
Timeline, requirement inventory, root causes, edge cases and the codebase sweep:
|
|
41
|
+
`docs/case-studies/issue-2144/README.md`. Reproduction:
|
|
42
|
+
`experiments/issue-2144/repro-closed-issue-stops-loop.mjs`.
|
|
43
|
+
|
|
3
44
|
## 2.11.12
|
|
4
45
|
|
|
5
46
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Announce on GitHub *why* a long-running automation loop stopped.
|
|
5
|
+
*
|
|
6
|
+
* Issue #2144: `--auto-restart-until-mergeable` and `--watch` used to exit
|
|
7
|
+
* silently on several paths (terminal GitHub entity states, tool execution
|
|
8
|
+
* failures, auto-resume limit). The reported incident stopped the loop on an
|
|
9
|
+
* open, mergeable pull request because its linked issue was closed, and left
|
|
10
|
+
* no GitHub comment at all — from the pull request's point of view the
|
|
11
|
+
* automation simply vanished.
|
|
12
|
+
*
|
|
13
|
+
* Two things live here:
|
|
14
|
+
* 1. A registry that turns an internal stop reason into human-readable text
|
|
15
|
+
* (what happened, what it means, what the user should do next).
|
|
16
|
+
* 2. `reportAutomationStop`, which posts that text as a deduplicated,
|
|
17
|
+
* tracked tool comment. Every stop path calls it, so "we stopped and
|
|
18
|
+
* exactly why" is always published.
|
|
19
|
+
*
|
|
20
|
+
* The module is intentionally free of top-level `command-stream` /`use-m`
|
|
21
|
+
* imports: the comment builders are pure functions and can be unit-tested
|
|
22
|
+
* without a GitHub environment. The `$` helper is passed in by callers.
|
|
23
|
+
*
|
|
24
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2144
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { AUTOMATION_STOPPED_MARKER, AUTO_MERGE_BLOCKED_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
|
|
28
|
+
|
|
29
|
+
export { AUTOMATION_STOPPED_MARKER, AUTO_MERGE_BLOCKED_MARKER };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Human-readable descriptions for every stop reason the solver can return.
|
|
33
|
+
*
|
|
34
|
+
* `canComment: false` marks reasons where the comment target itself is gone
|
|
35
|
+
* (deleted repository / pull request), so posting is skipped instead of
|
|
36
|
+
* producing a guaranteed API failure.
|
|
37
|
+
*/
|
|
38
|
+
export const STOP_REASONS = {
|
|
39
|
+
pull_request_closed: {
|
|
40
|
+
title: 'the pull request was closed without merging',
|
|
41
|
+
detail: 'A closed pull request can never become mergeable, so continuing to work on it would be pointless.',
|
|
42
|
+
nextSteps: ['Reopen the pull request and re-run the command to continue.'],
|
|
43
|
+
},
|
|
44
|
+
pull_request_unavailable: {
|
|
45
|
+
title: 'the pull request is no longer accessible',
|
|
46
|
+
detail: 'GitHub answered with 404/410 for this pull request (deleted, transferred, or access revoked).',
|
|
47
|
+
nextSteps: ['Verify the pull request still exists and that the token has access to it.'],
|
|
48
|
+
canComment: false,
|
|
49
|
+
},
|
|
50
|
+
repository_unavailable: {
|
|
51
|
+
title: 'the repository is no longer accessible',
|
|
52
|
+
detail: 'GitHub answered with 404/410 for the repository (deleted, renamed, made private, or access revoked).',
|
|
53
|
+
nextSteps: ['Verify the repository still exists and that the token has access to it.'],
|
|
54
|
+
canComment: false,
|
|
55
|
+
},
|
|
56
|
+
source_branch_unavailable: {
|
|
57
|
+
title: 'the source branch of the pull request is gone',
|
|
58
|
+
detail: 'The head branch (or its repository) is no longer accessible, so no further commits can be pushed to this pull request.',
|
|
59
|
+
nextSteps: ['Restore the source branch, or open a new pull request from a branch that still exists.'],
|
|
60
|
+
},
|
|
61
|
+
target_branch_unavailable: {
|
|
62
|
+
title: 'the target branch of the pull request is gone',
|
|
63
|
+
detail: 'The base branch (or its repository) is no longer accessible, so this pull request can never be merged as-is.',
|
|
64
|
+
nextSteps: ['Restore the base branch, or retarget this pull request to an existing branch.'],
|
|
65
|
+
},
|
|
66
|
+
terminal_github_entity_error: {
|
|
67
|
+
title: 'a GitHub entity required by this automation is no longer accessible',
|
|
68
|
+
detail: 'A repository, pull request, or branch answered with 404/410 while checking CI status.',
|
|
69
|
+
nextSteps: ['Verify the repository, pull request, and branches still exist and that the token has access to them.'],
|
|
70
|
+
},
|
|
71
|
+
auto_resume_limit_reached: {
|
|
72
|
+
title: 'the usage-limit auto-resume budget was exhausted',
|
|
73
|
+
detail: 'The AI session hit provider usage limits more times than `--auto-resume-max-iterations` allows.',
|
|
74
|
+
nextSteps: ['Re-run the command after the usage limit resets, or raise `--auto-resume-max-iterations`.'],
|
|
75
|
+
},
|
|
76
|
+
tool_failure: {
|
|
77
|
+
title: 'the AI session failed',
|
|
78
|
+
detail: 'The AI tool exited with an error that is not a usage limit, so restarting it automatically would most likely fail the same way.',
|
|
79
|
+
nextSteps: ['Review the attached working session log for the failure, fix the cause, and re-run the command.'],
|
|
80
|
+
},
|
|
81
|
+
tool_failure_after_resume: {
|
|
82
|
+
title: 'the AI session failed after resuming from a usage limit',
|
|
83
|
+
detail: 'The session was resumed once the usage limit reset, but the resumed run exited with an error.',
|
|
84
|
+
nextSteps: ['Review the attached working session log for the failure, fix the cause, and re-run the command.'],
|
|
85
|
+
},
|
|
86
|
+
merge_failed: {
|
|
87
|
+
title: 'GitHub refused the merge',
|
|
88
|
+
detail: 'Every merge requirement was satisfied, but the merge API call itself failed (branch protection, required reviews, or a race with another push).',
|
|
89
|
+
nextSteps: ['Check the branch protection rules and required reviews, then merge manually or re-run the command.'],
|
|
90
|
+
},
|
|
91
|
+
issue_closed: {
|
|
92
|
+
title: 'the linked issue is closed, so auto-merge was held back',
|
|
93
|
+
detail: 'The pull request is ready to merge. A closed issue never stops work on the pull request — it only blocks the automatic merge.',
|
|
94
|
+
nextSteps: ['Reopen the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.'],
|
|
95
|
+
},
|
|
96
|
+
issue_unavailable: {
|
|
97
|
+
title: 'the linked issue is no longer accessible, so auto-merge was held back',
|
|
98
|
+
detail: 'The pull request is ready to merge. A missing issue never stops work on the pull request — it only blocks the automatic merge.',
|
|
99
|
+
nextSteps: ['Restore or re-create the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.'],
|
|
100
|
+
},
|
|
101
|
+
watch_stopped: {
|
|
102
|
+
title: 'watch mode stopped',
|
|
103
|
+
detail: 'The watch loop reached a state where it can no longer make progress.',
|
|
104
|
+
nextSteps: ['Re-run the command once the reported condition is resolved.'],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const MODE_LABELS = {
|
|
109
|
+
'auto-restart-until-mergeable': '`--auto-restart-until-mergeable`',
|
|
110
|
+
'auto-merge': '`--auto-merge`',
|
|
111
|
+
watch: '`--watch`',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a stop reason to its description, with a safe fallback so an unknown
|
|
116
|
+
* or newly added reason is still reported (never silently swallowed).
|
|
117
|
+
*
|
|
118
|
+
* @param {string} reason
|
|
119
|
+
* @returns {{reason: string, title: string, detail: string, nextSteps: string[], canComment: boolean, known: boolean}}
|
|
120
|
+
*/
|
|
121
|
+
export const describeStopReason = reason => {
|
|
122
|
+
const key = String(reason || 'unknown');
|
|
123
|
+
const known = Object.prototype.hasOwnProperty.call(STOP_REASONS, key);
|
|
124
|
+
const entry = known ? STOP_REASONS[key] : null;
|
|
125
|
+
return {
|
|
126
|
+
reason: key,
|
|
127
|
+
title: entry?.title || `the automation stopped with reason \`${key}\``,
|
|
128
|
+
detail: entry?.detail || 'No further automatic progress is possible in this state.',
|
|
129
|
+
nextSteps: entry?.nextSteps || ['Review the working session log, resolve the reported condition, and re-run the command.'],
|
|
130
|
+
canComment: entry?.canComment !== false,
|
|
131
|
+
known,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const bulletList = lines =>
|
|
136
|
+
(lines || [])
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.map(line => `- ${line}`)
|
|
139
|
+
.join('\n');
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the "automation stopped" comment body.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} options
|
|
145
|
+
* @param {string} options.reason internal stop reason
|
|
146
|
+
* @param {string} [options.mode] which loop stopped
|
|
147
|
+
* @param {string} [options.message] concrete message from the detector
|
|
148
|
+
* @param {string[]} [options.details] extra evidence lines
|
|
149
|
+
* @returns {string} markdown comment body
|
|
150
|
+
*/
|
|
151
|
+
export const buildAutomationStopComment = ({ reason, mode = null, message = null, details = [] }) => {
|
|
152
|
+
const description = describeStopReason(reason);
|
|
153
|
+
const modeLabel = MODE_LABELS[mode] || (mode ? `\`${mode}\`` : 'This automation');
|
|
154
|
+
const sections = [`## 🛑 ${AUTOMATION_STOPPED_MARKER}: ${description.title}`, '', `${modeLabel} stopped working on this pull request.`, '', `**Reason code:** \`${description.reason}\``];
|
|
155
|
+
|
|
156
|
+
if (message) {
|
|
157
|
+
sections.push('', `**What happened:** ${message}`);
|
|
158
|
+
}
|
|
159
|
+
sections.push('', description.detail);
|
|
160
|
+
|
|
161
|
+
const evidence = (details || []).filter(Boolean);
|
|
162
|
+
if (evidence.length > 0) {
|
|
163
|
+
sections.push('', '**Details:**', bulletList(evidence));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
sections.push('', '**What to do next:**', bulletList(description.nextSteps));
|
|
167
|
+
sections.push('', '---', `*Reported automatically by hive-mind (${mode || 'automation'}).*`);
|
|
168
|
+
|
|
169
|
+
return sections.join('\n');
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the comment posted when the pull request is ready but `--auto-merge`
|
|
174
|
+
* is blocked by the state of the linked issue.
|
|
175
|
+
*
|
|
176
|
+
* Issue #2144: a closed issue must never stop the loop from making the pull
|
|
177
|
+
* request mergeable — it only blocks the *automatic* merge, and then the user
|
|
178
|
+
* is asked to reopen the issue or merge manually.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} options
|
|
181
|
+
* @param {Array<{reason: string, message: string, resolution?: string, details?: string[]}>} options.blockers
|
|
182
|
+
* @param {number|string|null} [options.issueNumber]
|
|
183
|
+
* @returns {string} markdown comment body
|
|
184
|
+
*/
|
|
185
|
+
export const buildAutoMergeBlockedComment = ({ blockers = [], issueNumber = null }) => {
|
|
186
|
+
const reasons = blockers.filter(Boolean);
|
|
187
|
+
const sections = [`## ⚠️ ${AUTO_MERGE_BLOCKED_MARKER}: this pull request is ready, but it was not merged automatically`, '', 'All merge requirements are satisfied — CI passed, there are no conflicts, and there are no pending changes.', '', 'Auto-merge (`--auto-merge`) was requested but is being held back:'];
|
|
188
|
+
|
|
189
|
+
for (const blocker of reasons) {
|
|
190
|
+
sections.push('', `- **${blocker.message}** (\`${blocker.reason}\`)`);
|
|
191
|
+
for (const detail of blocker.details || []) {
|
|
192
|
+
sections.push(` - ${detail}`);
|
|
193
|
+
}
|
|
194
|
+
if (blocker.resolution) {
|
|
195
|
+
sections.push(` - ➡️ ${blocker.resolution}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
sections.push('', '**What to do next:**');
|
|
200
|
+
sections.push(bulletList([issueNumber ? `Reopen issue #${issueNumber} and re-run the command so auto-merge can complete.` : 'Reopen the linked issue and re-run the command so auto-merge can complete.', 'Or merge this pull request manually — it is ready.']));
|
|
201
|
+
sections.push('', '---', '*Reported automatically by hive-mind with the --auto-merge flag.*');
|
|
202
|
+
|
|
203
|
+
return sections.join('\n');
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Post a stop report to the pull request (or issue), deduplicated per reason.
|
|
208
|
+
*
|
|
209
|
+
* Never throws: a failed comment must not mask the stop itself.
|
|
210
|
+
*
|
|
211
|
+
* @param {Object} options
|
|
212
|
+
* @param {Function} options.$ command-stream tagged template
|
|
213
|
+
* @param {string} options.owner
|
|
214
|
+
* @param {string} options.repo
|
|
215
|
+
* @param {number|string} options.targetNumber pull request (or issue) number
|
|
216
|
+
* @param {string} options.reason
|
|
217
|
+
* @param {string} [options.mode]
|
|
218
|
+
* @param {string} [options.message]
|
|
219
|
+
* @param {string[]} [options.details]
|
|
220
|
+
* @param {boolean} [options.verbose]
|
|
221
|
+
* @param {Function} [options.log]
|
|
222
|
+
* @param {string} [options.body] pre-built body (skips buildAutomationStopComment)
|
|
223
|
+
* @param {string} [options.signature] pre-built dedup signature
|
|
224
|
+
* @returns {Promise<{posted: boolean, reason: string, skipped?: string, error?: string}>}
|
|
225
|
+
*/
|
|
226
|
+
export const reportAutomationStop = async ({ $, owner, repo, targetNumber, reason, mode = null, message = null, details = [], verbose = false, log = null, body = null, signature = null }) => {
|
|
227
|
+
const description = describeStopReason(reason);
|
|
228
|
+
const write = async text => {
|
|
229
|
+
if (typeof log === 'function') await log(text);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (!$ || !owner || !repo || !targetNumber) {
|
|
233
|
+
return { posted: false, reason: description.reason, skipped: 'missing_target' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!description.canComment) {
|
|
237
|
+
await write(` ℹ️ Not posting a stop comment: ${description.title}`);
|
|
238
|
+
return { posted: false, reason: description.reason, skipped: 'target_unavailable' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const commentBody = body || buildAutomationStopComment({ reason, mode, message, details });
|
|
242
|
+
const dedupSignature = signature || `${AUTOMATION_STOPPED_MARKER}: ${description.title}`;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const { checkForExistingComment } = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
246
|
+
const alreadyPosted = await checkForExistingComment(owner, repo, targetNumber, dedupSignature, verbose);
|
|
247
|
+
if (alreadyPosted) {
|
|
248
|
+
await write(` ℹ️ Stop reason already reported on #${targetNumber} (${description.reason})`);
|
|
249
|
+
return { posted: false, reason: description.reason, skipped: 'duplicate' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const result = await postTrackedComment({ $, owner, repo, targetNumber, body: commentBody });
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
await write(` ⚠️ Could not post stop reason comment: ${result.stderr || 'unknown error'}`);
|
|
255
|
+
return { posted: false, reason: description.reason, error: result.stderr || 'post_failed' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await write(` 💬 Posted stop reason to #${targetNumber}: ${description.title}`);
|
|
259
|
+
return { posted: true, reason: description.reason };
|
|
260
|
+
} catch (error) {
|
|
261
|
+
await write(` ⚠️ Could not post stop reason comment: ${error.message}`);
|
|
262
|
+
return { posted: false, reason: description.reason, error: error.message };
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export default {
|
|
267
|
+
STOP_REASONS,
|
|
268
|
+
describeStopReason,
|
|
269
|
+
buildAutomationStopComment,
|
|
270
|
+
buildAutoMergeBlockedComment,
|
|
271
|
+
reportAutomationStop,
|
|
272
|
+
};
|
|
@@ -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 {
|
|
@@ -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
|
+
};
|
|
@@ -35,7 +35,7 @@ const { reportError } = sentryLib;
|
|
|
35
35
|
|
|
36
36
|
// Import GitHub merge functions
|
|
37
37
|
const githubMergeLib = await import('./github-merge.lib.mjs');
|
|
38
|
-
const {
|
|
38
|
+
const { checkMergePermissions, mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
|
|
39
39
|
|
|
40
40
|
// Import GitHub functions for log attachment
|
|
41
41
|
const githubLib = await import('./github.lib.mjs');
|
|
@@ -50,6 +50,18 @@ const { checkForUncommittedChanges, getUncommittedChangesDetails, executeToolIte
|
|
|
50
50
|
const terminalStateLib = await import('./github-terminal-state.lib.mjs');
|
|
51
51
|
const { checkGitHubTerminalState } = terminalStateLib;
|
|
52
52
|
|
|
53
|
+
// Issue #2144: these probes answer with a ~33 KB pull request object and a full
|
|
54
|
+
// issue object on every iteration. Issue #2130 made the helper's own default
|
|
55
|
+
// runner quiet, but passing `$` here bypassed it and the payloads were still
|
|
56
|
+
// mirrored into the attached log. Bind the quiet options to the injected `$`.
|
|
57
|
+
const { quietProbe } = await import('./quiet-probe.lib.mjs');
|
|
58
|
+
|
|
59
|
+
// Issue #2144: a closed linked issue is NOT terminal — it only blocks the final
|
|
60
|
+
// automatic merge. Every stop of this loop is also published as a GitHub comment
|
|
61
|
+
// stating exactly why it stopped.
|
|
62
|
+
const stopReportingLib = await import('./automation-stop-reporting.lib.mjs');
|
|
63
|
+
const { reportAutomationStop } = stopReportingLib;
|
|
64
|
+
|
|
53
65
|
// Import validation functions for time parsing (used for usage limit wait)
|
|
54
66
|
const validation = await import('./solve.validation.lib.mjs');
|
|
55
67
|
const { calculateWaitTime } = validation;
|
|
@@ -184,7 +196,7 @@ export const watchUntilMergeable = async params => {
|
|
|
184
196
|
issueNumber,
|
|
185
197
|
prNumber,
|
|
186
198
|
sourceBranchName: prBranch || branchName,
|
|
187
|
-
commandRunner:
|
|
199
|
+
commandRunner: quietProbe($),
|
|
188
200
|
});
|
|
189
201
|
if (terminalState.terminal && terminalState.success) {
|
|
190
202
|
await log('');
|
|
@@ -201,9 +213,20 @@ export const watchUntilMergeable = async params => {
|
|
|
201
213
|
}
|
|
202
214
|
await log(formatAligned('', 'Action:', 'Stopping auto-restart-until-mergeable mode', 2), { level: 'error' });
|
|
203
215
|
await log('');
|
|
216
|
+
// Issue #2144: report the stop on GitHub instead of exiting silently.
|
|
217
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: terminalState.reason, mode: 'auto-restart-until-mergeable', message: terminalState.message, details: terminalState.details, verbose: argv.verbose, log });
|
|
204
218
|
return { success: false, reason: terminalState.reason, latestSessionId, latestAnthropicCost };
|
|
205
219
|
}
|
|
206
220
|
|
|
221
|
+
// Issue #2144: issue-scoped problems (closed / deleted linked issue) never
|
|
222
|
+
// stop this loop. They are carried to the merge decision below.
|
|
223
|
+
const issueMergeBlockers = terminalState.mergeBlockers || [];
|
|
224
|
+
if (issueMergeBlockers.length > 0 && iteration === 1) {
|
|
225
|
+
for (const blocker of issueMergeBlockers) {
|
|
226
|
+
await log(formatAligned('⚠️', 'Linked issue:', `${blocker.message} Continuing to make the pull request mergeable.`, 2), { level: 'warning' });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
await log(formatAligned('🔍', `Check #${iteration}:`, currentTime.toLocaleTimeString()));
|
|
208
231
|
|
|
209
232
|
try {
|
|
@@ -243,6 +266,7 @@ export const watchUntilMergeable = async params => {
|
|
|
243
266
|
}
|
|
244
267
|
await log(formatAligned('', 'Action:', 'Stopping auto-restart-until-mergeable mode', 2), { level: 'error' });
|
|
245
268
|
await log('');
|
|
269
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'terminal_github_entity_error', mode: 'auto-restart-until-mergeable', message: terminalGitHubBlocker.message, details: terminalGitHubBlocker.details, verbose: argv.verbose, log });
|
|
246
270
|
return { success: false, reason: 'terminal_github_entity_error', latestSessionId, latestAnthropicCost };
|
|
247
271
|
}
|
|
248
272
|
|
|
@@ -356,6 +380,15 @@ export const watchUntilMergeable = async params => {
|
|
|
356
380
|
|
|
357
381
|
await log(formatAligned('✅', 'PR IS MERGEABLE!', ''));
|
|
358
382
|
|
|
383
|
+
// Issue #2144: the pull request is ready. A closed/unavailable linked
|
|
384
|
+
// issue blocks only the *automatic* merge — the loop already did its
|
|
385
|
+
// job of making the pull request mergeable. Ask the user to reopen the
|
|
386
|
+
// issue or merge manually instead of merging behind their back.
|
|
387
|
+
if (isAutoMerge && issueMergeBlockers.length > 0) {
|
|
388
|
+
await reportAutoMergeBlockedByIssue({ owner, repo, prNumber, issueNumber, mergeBlockers: issueMergeBlockers, verbose: argv.verbose });
|
|
389
|
+
return { success: false, reason: issueMergeBlockers[0].reason, mergeBlockers: issueMergeBlockers, latestSessionId, latestAnthropicCost };
|
|
390
|
+
}
|
|
391
|
+
|
|
359
392
|
if (isAutoMerge) {
|
|
360
393
|
// Attempt to merge the PR
|
|
361
394
|
await log(formatAligned('🔀', 'Auto-merging PR...', ''));
|
|
@@ -424,7 +457,17 @@ export const watchUntilMergeable = async params => {
|
|
|
424
457
|
} else {
|
|
425
458
|
// Issue #1345: Differentiate message when no CI is configured
|
|
426
459
|
const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? (workflowRunConclusions ? `- CI workflows completed without executing (${workflowRunConclusions})` : '- CI workflows exist but were not triggered for this commit') : '- All CI checks have passed';
|
|
427
|
-
|
|
460
|
+
// Issue #2144: a closed/unavailable linked issue does not stop this
|
|
461
|
+
// mode, but it is worth stating in the comment so the reader knows
|
|
462
|
+
// why no automatic merge will follow.
|
|
463
|
+
const issueLine =
|
|
464
|
+
issueMergeBlockers.length > 0
|
|
465
|
+
? `\n\nNote: ${issueMergeBlockers.map(b => b.message).join(' ')} ${issueMergeBlockers
|
|
466
|
+
.map(b => b.resolution)
|
|
467
|
+
.filter(Boolean)
|
|
468
|
+
.join(' ')}`
|
|
469
|
+
: '';
|
|
470
|
+
const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\n\nThis pull request is now ready to be merged:\n${ciLine}\n- No merge conflicts\n- No pending changes${issueLine}\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
|
|
428
471
|
// Issue #1625: Track this comment ID so it can't falsely count as an AI-authored comment
|
|
429
472
|
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
|
|
430
473
|
readyToMergeCommentPosted = true;
|
|
@@ -838,6 +881,8 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
838
881
|
await log(formatAligned('⚠️', 'AUTO-RESUME LIMIT REACHED', `Stopping after ${limitResumeCount} limit-reset continuation${limitResumeCount !== 1 ? 's' : ''}`));
|
|
839
882
|
await log(formatAligned('', 'Configured limit:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
|
|
840
883
|
await log('');
|
|
884
|
+
// Issue #2144: publish why the automation stopped.
|
|
885
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'auto_resume_limit_reached', mode: 'auto-restart-until-mergeable', message: `Stopped after ${limitResumeCount} usage-limit continuation${limitResumeCount !== 1 ? 's' : ''} (limit: ${formatAutoIterationLimit(maxAutoResumeIterations)}).`, verbose: argv.verbose, log });
|
|
841
886
|
return { success: false, reason: 'auto_resume_limit_reached', latestSessionId, latestAnthropicCost };
|
|
842
887
|
}
|
|
843
888
|
|
|
@@ -991,6 +1036,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
991
1036
|
await log(formatAligned('', `⚠️ Failure log upload error: ${cleanErrorMessage(logUploadError)}`, '', 2));
|
|
992
1037
|
}
|
|
993
1038
|
}
|
|
1039
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'tool_failure_after_resume', mode: 'auto-restart-until-mergeable', message: extractToolErrorCore({ toolResult: resumeResult }) || formatToolExecutionFailure({ tool: argv.tool, toolResult: resumeResult }), verbose: argv.verbose, log });
|
|
994
1040
|
return { success: false, reason: 'tool_failure_after_resume', latestSessionId, latestAnthropicCost };
|
|
995
1041
|
}
|
|
996
1042
|
} else {
|
|
@@ -1043,6 +1089,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
1043
1089
|
await log(formatAligned('', `⚠️ Failure log upload error: ${cleanErrorMessage(logUploadError)}`, '', 2));
|
|
1044
1090
|
}
|
|
1045
1091
|
}
|
|
1092
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'tool_failure', mode: 'auto-restart-until-mergeable', message: extractToolErrorCore({ toolResult }) || formatToolExecutionFailure({ tool: argv.tool, toolResult }), verbose: argv.verbose, log });
|
|
1046
1093
|
return { success: false, reason: 'tool_failure', latestSessionId, latestAnthropicCost };
|
|
1047
1094
|
} else {
|
|
1048
1095
|
// Success - capture latest session data
|
|
@@ -1279,114 +1326,10 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
|
|
|
1279
1326
|
}
|
|
1280
1327
|
};
|
|
1281
1328
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
export const attemptAutoMerge = async params => {
|
|
1287
|
-
const { owner, repo, prNumber, issueNumber = null, argv } = params;
|
|
1288
|
-
|
|
1289
|
-
await log('');
|
|
1290
|
-
await log(formatAligned('🔀', 'AUTO-MERGE:', 'Checking if PR can be merged...'));
|
|
1291
|
-
|
|
1292
|
-
const terminalState = await checkGitHubTerminalState({
|
|
1293
|
-
owner,
|
|
1294
|
-
repo,
|
|
1295
|
-
issueNumber,
|
|
1296
|
-
prNumber,
|
|
1297
|
-
commandRunner: $,
|
|
1298
|
-
});
|
|
1299
|
-
if (terminalState.terminal) {
|
|
1300
|
-
if (terminalState.success) {
|
|
1301
|
-
await log(formatAligned('🎉', 'PR already merged:', `#${prNumber}`, 2));
|
|
1302
|
-
return { success: true, reason: 'merged' };
|
|
1303
|
-
}
|
|
1304
|
-
await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', terminalState.message, 2), { level: 'error' });
|
|
1305
|
-
for (const detail of terminalState.details || []) {
|
|
1306
|
-
await log(formatAligned('', 'Detail:', detail, 4), { level: 'error' });
|
|
1307
|
-
}
|
|
1308
|
-
return { success: false, reason: terminalState.reason, error: terminalState.message };
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// Issue #1226: Check merge permissions before attempting
|
|
1312
|
-
const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
|
|
1313
|
-
if (!canMerge) {
|
|
1314
|
-
await log(formatAligned('⚠️', 'Cannot merge:', `Insufficient permissions (${permission || 'unknown'})`, 2));
|
|
1315
|
-
return { success: false, reason: 'insufficient_permissions', error: `User has ${permission || 'unknown'} access, needs push/maintain/admin` };
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// Wait for CI to complete (with timeout)
|
|
1319
|
-
const ciWaitResult = await waitForCI(
|
|
1320
|
-
owner,
|
|
1321
|
-
repo,
|
|
1322
|
-
prNumber,
|
|
1323
|
-
{
|
|
1324
|
-
timeout: argv.autoMergeCiTimeout || 30 * 60 * 1000, // 30 minutes default
|
|
1325
|
-
pollInterval: argv.autoMergeCiPollInterval || 30 * 1000, // 30 seconds default
|
|
1326
|
-
onStatusUpdate: async status => {
|
|
1327
|
-
if (argv.verbose) {
|
|
1328
|
-
await log(` CI status: ${status.status}`, { verbose: true });
|
|
1329
|
-
}
|
|
1330
|
-
},
|
|
1331
|
-
},
|
|
1332
|
-
argv.verbose
|
|
1333
|
-
);
|
|
1334
|
-
|
|
1335
|
-
if (!ciWaitResult.success) {
|
|
1336
|
-
await log(formatAligned('⚠️', 'CI check failed or timed out:', ciWaitResult.error || ciWaitResult.status, 2));
|
|
1337
|
-
return { success: false, reason: ciWaitResult.status, error: ciWaitResult.error };
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
await log(formatAligned('✅', 'CI checks passed:', 'Checking mergeability...', 2));
|
|
1341
|
-
|
|
1342
|
-
// Check if PR is mergeable
|
|
1343
|
-
const mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
|
|
1344
|
-
if (mergeStatus.terminal) {
|
|
1345
|
-
await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', mergeStatus.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible', 2), { level: 'error' });
|
|
1346
|
-
return { success: false, reason: 'terminal_github_entity_error', error: mergeStatus.reason };
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
if (!mergeStatus.mergeable) {
|
|
1350
|
-
await log(formatAligned('⚠️', 'PR not mergeable:', mergeStatus.reason || 'Unknown reason', 2));
|
|
1351
|
-
return { success: false, reason: 'not_mergeable', error: mergeStatus.reason };
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
await log(formatAligned('✅', 'PR is mergeable:', 'Attempting to merge...', 2));
|
|
1355
|
-
|
|
1356
|
-
// Attempt to merge
|
|
1357
|
-
const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
|
|
1358
|
-
if (deleteAfterMerge) {
|
|
1359
|
-
await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
|
|
1360
|
-
}
|
|
1361
|
-
const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
|
|
1362
|
-
|
|
1363
|
-
if (mergeResult.success) {
|
|
1364
|
-
await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
|
|
1365
|
-
|
|
1366
|
-
// Post success comment
|
|
1367
|
-
try {
|
|
1368
|
-
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*`;
|
|
1369
|
-
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
|
|
1370
|
-
} catch {
|
|
1371
|
-
// Don't fail if comment posting fails
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
// Issue #1895: close linked issue explicitly when GitHub will not (non-default base branch).
|
|
1375
|
-
try {
|
|
1376
|
-
const closeResult = await ensureLinkedIssueClosedAfterMerge({ $, log, owner, repo, prNumber, issueNumber, verbose: argv.verbose });
|
|
1377
|
-
if (!closeResult.closed && !closeResult.skipped) {
|
|
1378
|
-
await log(formatAligned('⚠️', 'Issue auto-close:', `could not close linked issue (${closeResult.reason})`, 2), { level: 'warning' });
|
|
1379
|
-
}
|
|
1380
|
-
} catch (closeError) {
|
|
1381
|
-
await log(formatAligned('⚠️', 'Issue auto-close:', `error: ${closeError.message}`, 2), { level: 'warning' });
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
return { success: true, reason: 'merged' };
|
|
1385
|
-
} else {
|
|
1386
|
-
await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2));
|
|
1387
|
-
return { success: false, reason: 'merge_failed', error: mergeResult.error };
|
|
1388
|
-
}
|
|
1389
|
-
};
|
|
1329
|
+
// Issue #2144: the one-shot `--auto-merge` attempt moved to its own module so
|
|
1330
|
+
// both files stay under the 1500-line limit. Re-exported for API compatibility.
|
|
1331
|
+
const autoMergeAttempt = await import('./solve.auto-merge-attempt.lib.mjs');
|
|
1332
|
+
export const { attemptAutoMerge, reportAutoMergeBlockedByIssue } = autoMergeAttempt;
|
|
1390
1333
|
|
|
1391
1334
|
/**
|
|
1392
1335
|
* Start auto-restart-until-mergeable mode
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -44,6 +44,18 @@ const { checkPRMerged, checkForUncommittedChanges, getUncommittedChangesDetails,
|
|
|
44
44
|
const terminalStateLib = await import('./github-terminal-state.lib.mjs');
|
|
45
45
|
const { checkGitHubTerminalState } = terminalStateLib;
|
|
46
46
|
|
|
47
|
+
// Issue #2144: these probes answer with a ~33 KB pull request object and a full
|
|
48
|
+
// issue object on every iteration. Issue #2130 made the helper's own default
|
|
49
|
+
// runner quiet, but passing `$` here bypassed it and the payloads were still
|
|
50
|
+
// mirrored into the attached log. Bind the quiet options to the injected `$`.
|
|
51
|
+
const { quietProbe } = await import('./quiet-probe.lib.mjs');
|
|
52
|
+
|
|
53
|
+
// Issue #2144: watch mode must never exit silently — every stop is published as
|
|
54
|
+
// a GitHub comment naming the exact reason. A closed linked issue is not a stop
|
|
55
|
+
// condition here at all.
|
|
56
|
+
const stopReportingLib = await import('./automation-stop-reporting.lib.mjs');
|
|
57
|
+
const { reportAutomationStop } = stopReportingLib;
|
|
58
|
+
|
|
47
59
|
// Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
|
|
48
60
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
49
61
|
// Issue #2119: one auto-restart budget shared with solve.auto-merge.lib.mjs, so
|
|
@@ -142,7 +154,7 @@ export const watchForFeedback = async params => {
|
|
|
142
154
|
issueNumber,
|
|
143
155
|
prNumber,
|
|
144
156
|
sourceBranchName: prBranch || branchName,
|
|
145
|
-
commandRunner:
|
|
157
|
+
commandRunner: quietProbe($),
|
|
146
158
|
});
|
|
147
159
|
if (terminalState.terminal && !terminalState.success) {
|
|
148
160
|
await log('');
|
|
@@ -152,9 +164,19 @@ export const watchForFeedback = async params => {
|
|
|
152
164
|
}
|
|
153
165
|
await log(formatAligned('', 'Action:', 'Stopping watch mode', 2), { level: 'error' });
|
|
154
166
|
await log('');
|
|
167
|
+
// Issue #2144: report the stop on GitHub instead of exiting silently.
|
|
168
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: terminalState.reason, mode: 'watch', message: terminalState.message, details: terminalState.details, verbose: argv.verbose, log });
|
|
155
169
|
break;
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
// Issue #2144: issue-scoped problems (closed / deleted linked issue) are not
|
|
173
|
+
// watch-mode stop conditions; the loop keeps working on the pull request.
|
|
174
|
+
if ((terminalState.mergeBlockers || []).length > 0 && iteration === 1) {
|
|
175
|
+
for (const blocker of terminalState.mergeBlockers) {
|
|
176
|
+
await log(formatAligned('⚠️', 'Linked issue:', `${blocker.message} Watch mode continues.`, 2), { level: 'warning' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
// Check if PR is merged
|
|
159
181
|
const isMerged = terminalState.terminal && terminalState.success ? true : await checkPRMerged(owner, repo, prNumber);
|
|
160
182
|
if (isMerged) {
|
|
@@ -472,6 +494,18 @@ export const watchForFeedback = async params => {
|
|
|
472
494
|
await log(' 2. You have proper authentication configured');
|
|
473
495
|
await log(' 3. The API endpoint is accessible');
|
|
474
496
|
await log('');
|
|
497
|
+
// Issue #2144: say on GitHub why the loop stopped.
|
|
498
|
+
await reportAutomationStop({
|
|
499
|
+
$,
|
|
500
|
+
owner,
|
|
501
|
+
repo,
|
|
502
|
+
targetNumber: prNumber,
|
|
503
|
+
reason: 'tool_failure',
|
|
504
|
+
mode: 'watch',
|
|
505
|
+
message: `${argv.tool.toUpperCase()} failed ${consecutiveApiErrors} times in a row: ${extractToolErrorCore({ toolResult }) || 'unknown API error'}`,
|
|
506
|
+
verbose: argv.verbose,
|
|
507
|
+
log,
|
|
508
|
+
});
|
|
475
509
|
break; // Exit the watch loop
|
|
476
510
|
}
|
|
477
511
|
|
|
@@ -62,6 +62,17 @@ export const BILLING_LIMIT_MARKER = 'GitHub Actions Billing Limit';
|
|
|
62
62
|
// solve.auto-merge.lib.mjs — cancelled/stale CI needs manual review
|
|
63
63
|
export const CANCELLED_CI_REVIEW_MARKER = 'Cancelled CI/CD Requires Review';
|
|
64
64
|
|
|
65
|
+
// automation-stop-reporting.lib.mjs — Issue #2144: every automation stop is
|
|
66
|
+
// announced on GitHub with the exact reason. Before this, watch mode and
|
|
67
|
+
// auto-restart-until-mergeable exited silently on terminal states and tool
|
|
68
|
+
// failures, leaving the pull request with no explanation at all.
|
|
69
|
+
export const AUTOMATION_STOPPED_MARKER = 'Automation stopped';
|
|
70
|
+
|
|
71
|
+
// automation-stop-reporting.lib.mjs — Issue #2144: the pull request is
|
|
72
|
+
// mergeable but `--auto-merge` cannot complete because the linked issue is
|
|
73
|
+
// closed or unavailable. The user is asked to reopen it or merge manually.
|
|
74
|
+
export const AUTO_MERGE_BLOCKED_MARKER = 'Auto-merge blocked';
|
|
75
|
+
|
|
65
76
|
// solve.results.lib.mjs — working session summary comments posted by
|
|
66
77
|
// --attach-solution-summary / --auto-attach-solution-summary at the end of
|
|
67
78
|
// every working session (top-level solve, auto-restart-until-mergeable
|
|
@@ -111,7 +122,7 @@ export const USAGE_LIMIT_REACHED_MARKER = 'Usage Limit Reached';
|
|
|
111
122
|
* named constants above so that adding a new marker only requires adding
|
|
112
123
|
* the constant and appending it here.
|
|
113
124
|
*/
|
|
114
|
-
export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, CANCELLED_CI_REVIEW_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_AUTOMATION_MARKER];
|
|
125
|
+
export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, READY_FOR_REVIEW_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, CANCELLED_CI_REVIEW_MARKER, AUTOMATION_STOPPED_MARKER, AUTO_MERGE_BLOCKED_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, WORKING_SESSION_SUMMARY_AUTOMATION_MARKER];
|
|
115
126
|
|
|
116
127
|
/**
|
|
117
128
|
* Markers that indicate the end of a working session. Used by
|