@link-assistant/hive-mind 2.15.0 → 2.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/src/git-auth-transport.lib.mjs +309 -0
- package/src/github-merge.lib.mjs +24 -28
- package/src/lib.mjs +7 -0
- package/src/merge-error-classification.lib.mjs +134 -0
- package/src/option-suggestions.lib.mjs +1 -0
- package/src/pr-draft-state.lib.mjs +96 -0
- package/src/review.mjs +6 -0
- package/src/solve.auto-merge-attempt.lib.mjs +42 -3
- package/src/solve.auto-merge-guards.lib.mjs +152 -0
- package/src/solve.auto-merge-helpers.lib.mjs +20 -1
- package/src/solve.auto-merge-preflight.lib.mjs +122 -0
- package/src/solve.auto-merge.lib.mjs +70 -84
- package/src/solve.config.lib.mjs +8 -0
- package/src/solve.interrupt.lib.mjs +18 -1
- package/src/solve.mjs +19 -0
- package/src/solve.repo-setup.lib.mjs +10 -0
- package/src/solve.repository.lib.mjs +26 -0
- package/src/solve.restart-shared.lib.mjs +302 -278
- package/src/solve.results.lib.mjs +6 -12
- package/src/solve.session.lib.mjs +15 -4
- package/src/telegram-merge-queue.lib.mjs +3 -1
- package/src/telegram-merge-wait.lib.mjs +7 -0
- package/src/transient-errors.lib.mjs +34 -3
|
@@ -11,7 +11,17 @@
|
|
|
11
11
|
* keep-working, auto-ensure, PR-placeholder restart) kept the PR marked as
|
|
12
12
|
* "ready for review" while the AI was actively working on it.
|
|
13
13
|
*
|
|
14
|
+
* Issue #2182: the draft transition was code-driven and unconditional while the
|
|
15
|
+
* matching ready transition was delegated to the AI tool (the prompt asks it to run
|
|
16
|
+
* `gh pr ready <n>`) and to `endWorkSession()`, which only runs in continue mode and
|
|
17
|
+
* only after the auto-merge watch loop returns. When the AI simply did not run the
|
|
18
|
+
* command, the pull request stayed a draft forever. This module therefore *tracks*
|
|
19
|
+
* every draft it hands out, so the matching ready transition can be guaranteed by
|
|
20
|
+
* code — including on the interrupt and fatal-error exit paths.
|
|
21
|
+
*
|
|
14
22
|
* @see https://github.com/link-assistant/hive-mind/issues/2123
|
|
23
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2182
|
|
24
|
+
* @see docs/case-studies/issue-2182/README.md for the full timeline and evidence
|
|
15
25
|
*/
|
|
16
26
|
|
|
17
27
|
// rate-limit marker (#1726): callers pass in a `$` already wrapped by wrapDollarWithGhRetry.
|
|
@@ -19,6 +29,75 @@ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-l
|
|
|
19
29
|
|
|
20
30
|
const noopLog = async () => {};
|
|
21
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Issue #2182: pull requests this process put into draft for a working session that
|
|
34
|
+
* has not been converted back to "ready for review" yet.
|
|
35
|
+
*
|
|
36
|
+
* Keyed by `owner/repo#number`, module-level on purpose: like working-session.lib.mjs
|
|
37
|
+
* this is a per-process singleton, and the safety nets that drain it (interrupt
|
|
38
|
+
* handler, fatal-error handler) have no access to the call site that drafted the PR.
|
|
39
|
+
*
|
|
40
|
+
* @type {Map<string, {owner: string, repo: string, prNumber: (number|string), reason: (string|null), since: string}>}
|
|
41
|
+
*/
|
|
42
|
+
const outstandingWorkingSessionDrafts = new Map();
|
|
43
|
+
|
|
44
|
+
const draftKey = (owner, repo, prNumber) => `${owner}/${repo}#${prNumber}`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record that a working session of this process is holding `prNumber` in draft.
|
|
48
|
+
* Also called when the pull request already was a draft: what matters for the
|
|
49
|
+
* invariant is that a session is now responsible for converting it back.
|
|
50
|
+
*/
|
|
51
|
+
const trackWorkingSessionDraft = ({ owner, repo, prNumber, reason }) => {
|
|
52
|
+
outstandingWorkingSessionDrafts.set(draftKey(owner, repo, prNumber), { owner, repo, prNumber, reason: reason || null, since: new Date().toISOString() });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Record that `prNumber` is no longer held in draft by this process. */
|
|
56
|
+
const untrackWorkingSessionDraft = ({ owner, repo, prNumber }) => {
|
|
57
|
+
outstandingWorkingSessionDrafts.delete(draftKey(owner, repo, prNumber));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pull requests currently held in draft by this process on behalf of a working session.
|
|
62
|
+
* @returns {Array<{owner: string, repo: string, prNumber: (number|string), reason: (string|null), since: string}>}
|
|
63
|
+
*/
|
|
64
|
+
export const getOutstandingWorkingSessionDrafts = () => Array.from(outstandingWorkingSessionDrafts.values());
|
|
65
|
+
|
|
66
|
+
/** Forget every tracked draft (used by tests and by a clean process restart). */
|
|
67
|
+
export const resetWorkingSessionDrafts = () => {
|
|
68
|
+
outstandingWorkingSessionDrafts.clear();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Issue #2182 safety net: convert back to "ready for review" every pull request this
|
|
73
|
+
* process left in draft for a working session that is now over.
|
|
74
|
+
*
|
|
75
|
+
* Called from the interrupt handler and the fatal-error handler, so an aborted session
|
|
76
|
+
* cannot leave a pull request permanently unmergeable. A no-op when nothing is
|
|
77
|
+
* outstanding, so it is safe to call on every exit path.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} options
|
|
80
|
+
* @param {Function} options.$ - command-stream style tagged template executor
|
|
81
|
+
* @param {Function} [options.log]
|
|
82
|
+
* @param {Function} [options.formatAligned]
|
|
83
|
+
* @param {string} [options.reason]
|
|
84
|
+
* @param {Function} [options.reportError]
|
|
85
|
+
* @returns {Promise<Array<Object>>} one result per restored pull request
|
|
86
|
+
*/
|
|
87
|
+
export const restorePullRequestsLeftInDraft = async ({ $, log = noopLog, formatAligned = null, reason = 'working session ended', reportError = null } = {}) => {
|
|
88
|
+
const pending = getOutstandingWorkingSessionDrafts();
|
|
89
|
+
if (pending.length === 0) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await log(`🩹 Restoring ${pending.length} pull request(s) left in draft by this working session...`);
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const entry of pending) {
|
|
96
|
+
results.push(await ensurePullRequestIsReady({ owner: entry.owner, repo: entry.repo, prNumber: entry.prNumber, $, log, formatAligned, reason, reportError }));
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
};
|
|
100
|
+
|
|
22
101
|
/**
|
|
23
102
|
* Fetch the draft/open state of a pull request.
|
|
24
103
|
*
|
|
@@ -83,11 +162,20 @@ const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log
|
|
|
83
162
|
|
|
84
163
|
// A merged or closed pull request cannot change its draft state; GitHub rejects it.
|
|
85
164
|
if (status.state && status.state !== 'OPEN') {
|
|
165
|
+
// A merged/closed PR can no longer block anything, so stop tracking it (#2182).
|
|
166
|
+
untrackWorkingSessionDraft({ owner, repo, prNumber });
|
|
86
167
|
await write('ℹ️', 'PR status:', `${status.state.toLowerCase()} - skipping ${label} conversion`);
|
|
87
168
|
return { ok: true, changed: false, skipped: true, reason: `pr_${status.state.toLowerCase()}`, error: null };
|
|
88
169
|
}
|
|
89
170
|
|
|
90
171
|
if (status.isDraft === wantDraft) {
|
|
172
|
+
// Issue #2182: even when the PR already is a draft, this session now owns the
|
|
173
|
+
// obligation to convert it back, so it must be tracked like any other draft.
|
|
174
|
+
if (wantDraft) {
|
|
175
|
+
trackWorkingSessionDraft({ owner, repo, prNumber, reason });
|
|
176
|
+
} else {
|
|
177
|
+
untrackWorkingSessionDraft({ owner, repo, prNumber });
|
|
178
|
+
}
|
|
91
179
|
await write('✅', 'PR status:', `Already in ${label}`);
|
|
92
180
|
return { ok: true, changed: false, skipped: true, reason: 'already_in_target_state', error: null };
|
|
93
181
|
}
|
|
@@ -96,6 +184,11 @@ const setPullRequestDraftState = async ({ target, owner, repo, prNumber, $, log
|
|
|
96
184
|
const convertResult = wantDraft ? await $`gh pr ready ${prNumber} --repo ${owner}/${repo} --undo` : await $`gh pr ready ${prNumber} --repo ${owner}/${repo}`;
|
|
97
185
|
|
|
98
186
|
if (convertResult.code === 0) {
|
|
187
|
+
if (wantDraft) {
|
|
188
|
+
trackWorkingSessionDraft({ owner, repo, prNumber, reason });
|
|
189
|
+
} else {
|
|
190
|
+
untrackWorkingSessionDraft({ owner, repo, prNumber });
|
|
191
|
+
}
|
|
99
192
|
await write('✅', 'PR converted:', `Now in ${label}`);
|
|
100
193
|
return { ok: true, changed: true, skipped: false, reason: null, error: null };
|
|
101
194
|
}
|
|
@@ -134,4 +227,7 @@ export default {
|
|
|
134
227
|
getPullRequestDraftState,
|
|
135
228
|
ensurePullRequestIsDraft,
|
|
136
229
|
ensurePullRequestIsReady,
|
|
230
|
+
getOutstandingWorkingSessionDrafts,
|
|
231
|
+
restorePullRequestsLeftInDraft,
|
|
232
|
+
resetWorkingSessionDrafts,
|
|
137
233
|
};
|
package/src/review.mjs
CHANGED
|
@@ -50,6 +50,7 @@ const fs = (await use('fs')).promises;
|
|
|
50
50
|
import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
|
|
51
51
|
import { QUIET_PROBE } from './quiet-probe.lib.mjs';
|
|
52
52
|
import { reportError } from './sentry.lib.mjs';
|
|
53
|
+
import { ensureAuthenticatedGitTransport } from './git-auth-transport.lib.mjs'; // issue #2192
|
|
53
54
|
import * as memoryCheck from './memory-check.mjs';
|
|
54
55
|
|
|
55
56
|
// Import Claude execution functions
|
|
@@ -244,6 +245,11 @@ try {
|
|
|
244
245
|
await log(`📝 Files changed: ${prDetails.files.length}`);
|
|
245
246
|
|
|
246
247
|
// Clone the repository using gh tool with authentication
|
|
248
|
+
// Issue #2192: authenticate the git transport first. `gh repo clone` of a
|
|
249
|
+
// public repository sends no Authorization header (the credential helper is
|
|
250
|
+
// only consulted after a 401, and github.com answers 200), so without this the
|
|
251
|
+
// clone is anonymous and can be refused by GitHub's unauthenticated-download limiter.
|
|
252
|
+
await ensureAuthenticatedGitTransport({ $, log, reason: 'review clone' });
|
|
247
253
|
await log(`\nCloning repository ${owner}/${repo} using gh tool...\n`);
|
|
248
254
|
const cloneResult = await $`gh repo clone ${owner}/${repo} ${tempDir}`;
|
|
249
255
|
|
|
@@ -48,6 +48,23 @@ const { AUTO_MERGE_BLOCKED_MARKER, buildAutoMergeBlockedComment, reportAutomatio
|
|
|
48
48
|
|
|
49
49
|
const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
|
|
50
50
|
|
|
51
|
+
// Issue #2182: a pull request left in draft state by a restart iteration reports
|
|
52
|
+
// mergeable=MERGEABLE/CLEAN, but `gh pr merge` refuses it with "Pull Request is
|
|
53
|
+
// still a draft". Restore "ready for review" once and retry instead of failing
|
|
54
|
+
// (or, in the watch loop, retrying forever).
|
|
55
|
+
const { classifyMergeError, MERGE_ERROR_CATEGORIES } = await import('./merge-error-classification.lib.mjs');
|
|
56
|
+
const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
|
|
57
|
+
const { reportError } = await import('./sentry.lib.mjs');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mark the pull request ready for review because its draft state is what blocks
|
|
61
|
+
* the merge. Returns true when the state was restored (or already correct).
|
|
62
|
+
*/
|
|
63
|
+
const restoreReadyForReview = async ({ owner, repo, prNumber, reason }) => {
|
|
64
|
+
const result = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason, reportError });
|
|
65
|
+
return result?.ok === true;
|
|
66
|
+
};
|
|
67
|
+
|
|
51
68
|
const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
|
|
52
69
|
|
|
53
70
|
/**
|
|
@@ -161,7 +178,7 @@ export const attemptAutoMerge = async params => {
|
|
|
161
178
|
await log(formatAligned('✅', 'CI checks passed:', 'Checking mergeability...', 2));
|
|
162
179
|
|
|
163
180
|
// Check if PR is mergeable
|
|
164
|
-
|
|
181
|
+
let mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
|
|
165
182
|
if (mergeStatus.terminal) {
|
|
166
183
|
await log(formatAligned('❌', 'GITHUB TARGET UNAVAILABLE:', mergeStatus.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible', 2), { level: 'error' });
|
|
167
184
|
await reportAutomationStop({
|
|
@@ -178,6 +195,15 @@ export const attemptAutoMerge = async params => {
|
|
|
178
195
|
return { success: false, reason: 'terminal_github_entity_error', error: mergeStatus.reason };
|
|
179
196
|
}
|
|
180
197
|
|
|
198
|
+
if (!mergeStatus.mergeable && mergeStatus.isDraft) {
|
|
199
|
+
// Issue #2182: the only blocker is the draft state this tool set itself.
|
|
200
|
+
await log(formatAligned('📝', 'PR is a draft:', 'restoring "ready for review" before merging', 2), { level: 'warning' });
|
|
201
|
+
const restored = await restoreReadyForReview({ owner, repo, prNumber, reason: 'auto-merge: draft blocks the merge' });
|
|
202
|
+
if (restored) {
|
|
203
|
+
mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
181
207
|
if (!mergeStatus.mergeable) {
|
|
182
208
|
await log(formatAligned('⚠️', 'PR not mergeable:', mergeStatus.reason || 'Unknown reason', 2));
|
|
183
209
|
return { success: false, reason: 'not_mergeable', error: mergeStatus.reason };
|
|
@@ -198,7 +224,17 @@ export const attemptAutoMerge = async params => {
|
|
|
198
224
|
if (deleteAfterMerge) {
|
|
199
225
|
await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
|
|
200
226
|
}
|
|
201
|
-
|
|
227
|
+
let mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
|
|
228
|
+
|
|
229
|
+
// Issue #2182: GitHub can still refuse the merge because of the draft state
|
|
230
|
+
// even when `gh pr view` already answered CLEAN/MERGEABLE (stale read).
|
|
231
|
+
// Restore "ready for review" once and retry the merge exactly once.
|
|
232
|
+
if (!mergeResult.success && classifyMergeError(mergeResult.error).category === MERGE_ERROR_CATEGORIES.DRAFT) {
|
|
233
|
+
await log(formatAligned('🔧', 'Self-healing:', 'GitHub refused the merge because the PR is a draft - marking it ready', 2), { level: 'warning' });
|
|
234
|
+
if (await restoreReadyForReview({ owner, repo, prNumber, reason: 'auto-merge: GitHub rejected the merge because the PR is a draft' })) {
|
|
235
|
+
mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
202
238
|
|
|
203
239
|
if (mergeResult.success) {
|
|
204
240
|
await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
|
|
@@ -223,7 +259,9 @@ export const attemptAutoMerge = async params => {
|
|
|
223
259
|
|
|
224
260
|
return { success: true, reason: 'merged' };
|
|
225
261
|
} else {
|
|
226
|
-
|
|
262
|
+
const classification = classifyMergeError(mergeResult.error);
|
|
263
|
+
await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2), { level: 'warning' });
|
|
264
|
+
await log(formatAligned('', 'Failure category:', classification.category, 2), { level: 'warning' });
|
|
227
265
|
await reportAutomationStop({
|
|
228
266
|
$,
|
|
229
267
|
owner,
|
|
@@ -232,6 +270,7 @@ export const attemptAutoMerge = async params => {
|
|
|
232
270
|
reason: 'merge_failed',
|
|
233
271
|
mode: 'auto-merge',
|
|
234
272
|
message: mergeResult.error || 'GitHub rejected the merge request.',
|
|
273
|
+
details: [classification.resolution, `Failure category: ${classification.category}`].filter(Boolean),
|
|
235
274
|
verbose: argv.verbose,
|
|
236
275
|
log,
|
|
237
276
|
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard rails for the --auto-merge / --auto-restart-until-mergeable watch loop.
|
|
3
|
+
*
|
|
4
|
+
* Issue #2182: a single task stayed in the "Processing" state for 4d 12h 13m 35s
|
|
5
|
+
* because the watch loop had no answer for a pull request it could never merge:
|
|
6
|
+
* no wall-clock ceiling, no classification of the merge failure and no reaction to
|
|
7
|
+
* a pull request left in draft. Those three decisions live here so that
|
|
8
|
+
* `watchUntilMergeable()` only keeps the control flow (`continue` / `return`) and
|
|
9
|
+
* the loop state.
|
|
10
|
+
*
|
|
11
|
+
* These are *defense in depth*. The root cause of #2182 is that hive-mind did not
|
|
12
|
+
* put the pull request back to "ready for review" when the working session ended;
|
|
13
|
+
* that is fixed in pr-draft-state.lib.mjs and its callers. The guards below make
|
|
14
|
+
* sure that a pull request which is a draft anyway - for instance because a human
|
|
15
|
+
* drafted it, or because the process was SIGKILLed before the safety nets ran -
|
|
16
|
+
* costs a few seconds instead of several days.
|
|
17
|
+
*
|
|
18
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2182
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { classifyMergeError, MAX_CONSECUTIVE_MERGE_FAILURES } = await import('./merge-error-classification.lib.mjs');
|
|
22
|
+
const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
|
|
23
|
+
|
|
24
|
+
export { MAX_CONSECUTIVE_MERGE_FAILURES };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How long to wait after restoring "ready for review" before the next
|
|
28
|
+
* mergeability check. Short on purpose: nothing is running, we only need GitHub
|
|
29
|
+
* to reflect the new state.
|
|
30
|
+
*/
|
|
31
|
+
export const DRAFT_RECHECK_DELAY_MS = 5000;
|
|
32
|
+
|
|
33
|
+
/** Default wall-clock ceiling for the auto-restart-until-mergeable loop. */
|
|
34
|
+
export const DEFAULT_WATCH_TIMEOUT_HOURS = 24;
|
|
35
|
+
|
|
36
|
+
/** How many times the loop may restore "ready for review" before giving up. */
|
|
37
|
+
export const MAX_DRAFT_SELF_HEALS = 3;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize the `--auto-restart-until-mergeable-timeout-hours` value. `0` (and
|
|
41
|
+
* any negative input) means "no wall-clock limit"; when the flag is absent the
|
|
42
|
+
* default keeps a run from silently occupying a queue slot for days, as happened
|
|
43
|
+
* in the reported 4d 12h run.
|
|
44
|
+
*
|
|
45
|
+
* @param {number|string|null|undefined} raw
|
|
46
|
+
* @returns {number} hours, 0 = unlimited
|
|
47
|
+
*/
|
|
48
|
+
export const normalizeWatchTimeoutHours = raw => {
|
|
49
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULT_WATCH_TIMEOUT_HOURS;
|
|
50
|
+
const parsed = Number(raw);
|
|
51
|
+
// A non-numeric value is a typo, not a request for an unlimited run: fall back
|
|
52
|
+
// to the default instead of silently disabling the timeout again (issue #2182).
|
|
53
|
+
if (!Number.isFinite(parsed)) return DEFAULT_WATCH_TIMEOUT_HOURS;
|
|
54
|
+
if (parsed <= 0) return 0;
|
|
55
|
+
return parsed;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether the watch loop has exceeded its wall-clock ceiling.
|
|
60
|
+
*
|
|
61
|
+
* @returns {{message: string, details: string[]}|null} null while within budget
|
|
62
|
+
*/
|
|
63
|
+
export const evaluateWatchTimeout = ({ watchTimeoutHours, watchStartedAt, now, checksCompleted }) => {
|
|
64
|
+
if (!(watchTimeoutHours > 0)) return null;
|
|
65
|
+
const elapsedHours = (now - watchStartedAt) / 3600000;
|
|
66
|
+
if (elapsedHours < watchTimeoutHours) return null;
|
|
67
|
+
return {
|
|
68
|
+
message: `Auto-restart-until-mergeable stopped after ${elapsedHours.toFixed(1)}h (limit: ${watchTimeoutHours}h, ${checksCompleted} checks). The pull request never became mergeable.`,
|
|
69
|
+
details: ['Raise or disable the limit with --auto-restart-until-mergeable-timeout-hours (0 = unlimited).'],
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* React to a pull request that is still a draft while no AI session is running.
|
|
75
|
+
*
|
|
76
|
+
* GitHub answers mergeable=MERGEABLE / mergeStateStatus=CLEAN for such a pull
|
|
77
|
+
* request, so nothing else in the loop notices - the merge then fails with
|
|
78
|
+
* "Pull Request is still a draft" on every single check, which is exactly the
|
|
79
|
+
* 2692-iteration loop reported in #2182.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} options
|
|
82
|
+
* @param {{draftSelfHealCount: number}} options.state - mutable loop state
|
|
83
|
+
* @returns {Promise<{action: 'stop'|'retry'|'continue', reason?: string}>}
|
|
84
|
+
*/
|
|
85
|
+
export const resolveDraftBlocker = async ({ owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose, state }) => {
|
|
86
|
+
await log(formatAligned('📝', 'PR is a draft:', 'no AI session is running - restoring "ready for review"', 2), { level: 'warning' });
|
|
87
|
+
|
|
88
|
+
if (state.draftSelfHealCount >= MAX_DRAFT_SELF_HEALS) {
|
|
89
|
+
const message = `Pull request #${prNumber} keeps returning to draft state (${state.draftSelfHealCount} restore attempts). A draft pull request cannot be merged.`;
|
|
90
|
+
await log(formatAligned('❌', 'DRAFT STATE UNRECOVERABLE:', message, 2), { level: 'error' });
|
|
91
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'draft_pull_request', mode: 'auto-restart-until-mergeable', message, details: ['Mark the pull request as ready for review manually (gh pr ready), then rerun.'], verbose, log });
|
|
92
|
+
return { action: 'stop', reason: 'draft_pull_request' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
state.draftSelfHealCount++;
|
|
96
|
+
const readyResult = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'auto-restart-until-mergeable: draft blocks the merge', reportError });
|
|
97
|
+
if (readyResult?.ok) {
|
|
98
|
+
await log(formatAligned('✅', 'Draft restored:', `PR #${prNumber} is ready for review again (attempt ${state.draftSelfHealCount}/${MAX_DRAFT_SELF_HEALS})`, 2));
|
|
99
|
+
return { action: 'retry' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await log(formatAligned('⚠️', 'Draft restore failed:', readyResult?.error || 'unknown error', 2), { level: 'warning' });
|
|
103
|
+
return { action: 'continue' };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Classify a failed `gh pr merge` and decide what the watch loop should do next.
|
|
108
|
+
*
|
|
109
|
+
* Before #2182 every failure - terminal or not - printed "Will continue
|
|
110
|
+
* monitoring..." and was retried every 120 seconds forever (5384 identical
|
|
111
|
+
* failures in the reported run).
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} options
|
|
114
|
+
* @param {{draftSelfHealCount: number, consecutiveMergeFailures: number}} options.state - mutable loop state
|
|
115
|
+
* @returns {Promise<{action: 'stop'|'retry'|'continue', reason?: string}>}
|
|
116
|
+
*/
|
|
117
|
+
export const resolveMergeFailure = async ({ error, owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose, state }) => {
|
|
118
|
+
state.consecutiveMergeFailures++;
|
|
119
|
+
const classification = classifyMergeError(error);
|
|
120
|
+
await log(formatAligned('⚠️', 'Auto-merge failed:', error || 'Unknown error', 2), { level: 'warning' });
|
|
121
|
+
await log(formatAligned('', 'Failure category:', `${classification.category} (attempt ${state.consecutiveMergeFailures}/${MAX_CONSECUTIVE_MERGE_FAILURES})`, 2), { level: 'warning' });
|
|
122
|
+
if (classification.resolution) {
|
|
123
|
+
await log(formatAligned('', 'Resolution:', classification.resolution, 2), { level: 'warning' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (classification.recoverable && classification.category === 'draft' && state.draftSelfHealCount < MAX_DRAFT_SELF_HEALS) {
|
|
127
|
+
state.draftSelfHealCount++;
|
|
128
|
+
await log(formatAligned('🔧', 'Self-healing:', `marking PR #${prNumber} as ready for review (attempt ${state.draftSelfHealCount}/${MAX_DRAFT_SELF_HEALS})`, 2));
|
|
129
|
+
const readyResult = await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'auto-merge: GitHub rejected the merge because the PR is a draft', reportError });
|
|
130
|
+
if (readyResult?.ok) return { action: 'retry' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (classification.terminal || state.consecutiveMergeFailures >= MAX_CONSECUTIVE_MERGE_FAILURES) {
|
|
134
|
+
const message = `GitHub refused to merge pull request #${prNumber}: ${error || 'Unknown error'}`;
|
|
135
|
+
await log(formatAligned('❌', 'AUTO-MERGE STOPPED:', classification.terminal ? 'the failure is terminal - retrying cannot succeed' : `${state.consecutiveMergeFailures} consecutive merge failures`, 2), { level: 'error' });
|
|
136
|
+
await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'merge_failed', mode: 'auto-merge', message, details: [classification.resolution || 'Merge the pull request manually after resolving the cause.', `Failure category: ${classification.category}`], verbose, log });
|
|
137
|
+
return { action: 'stop', reason: 'merge_failed' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { action: 'continue' };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default {
|
|
144
|
+
DRAFT_RECHECK_DELAY_MS,
|
|
145
|
+
DEFAULT_WATCH_TIMEOUT_HOURS,
|
|
146
|
+
MAX_DRAFT_SELF_HEALS,
|
|
147
|
+
MAX_CONSECUTIVE_MERGE_FAILURES,
|
|
148
|
+
normalizeWatchTimeoutHours,
|
|
149
|
+
evaluateWatchTimeout,
|
|
150
|
+
resolveDraftBlocker,
|
|
151
|
+
resolveMergeFailure,
|
|
152
|
+
};
|
|
@@ -504,6 +504,19 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
504
504
|
// then no CI is required and we should not block indefinitely.
|
|
505
505
|
// Otherwise (e.g. mergeStateStatus === 'BLOCKED'), treat as pending race condition.
|
|
506
506
|
const earlyMergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
|
|
507
|
+
// Issue #2182: a draft pull request reports mergeable=false now, which would
|
|
508
|
+
// otherwise fall into the "checks have not started yet" race-condition branch
|
|
509
|
+
// below and hide the real reason behind a ci_pending blocker forever. The
|
|
510
|
+
// `no_checks` branch owns several early returns, so the draft blocker has to
|
|
511
|
+
// be emitted here to reach the caller through every one of them.
|
|
512
|
+
if (earlyMergeStatus.isDraft) {
|
|
513
|
+
blockers.push({
|
|
514
|
+
type: 'draft',
|
|
515
|
+
message: earlyMergeStatus.reason || 'PR is a draft',
|
|
516
|
+
details: [],
|
|
517
|
+
});
|
|
518
|
+
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false, noWorkflowRunsForCommit };
|
|
519
|
+
}
|
|
507
520
|
if (earlyMergeStatus.mergeable) {
|
|
508
521
|
// Issue #1363: Before concluding "no CI configured", verify the repo actually
|
|
509
522
|
// has no active GitHub Actions workflows. If workflows exist but no checks have
|
|
@@ -972,8 +985,14 @@ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, c
|
|
|
972
985
|
}
|
|
973
986
|
|
|
974
987
|
if (!mergeStatus.mergeable) {
|
|
988
|
+
// Issue #2182: a draft pull request gets its own blocker type. GitHub keeps
|
|
989
|
+
// reporting mergeable=MERGEABLE/CLEAN for drafts, so before this the loop
|
|
990
|
+
// saw no blocker at all, declared "PR IS MERGEABLE!" and then failed the
|
|
991
|
+
// actual merge with "Pull Request is still a draft" on every check for
|
|
992
|
+
// 4d 12h. The dedicated type also lets the caller self-heal (mark ready)
|
|
993
|
+
// instead of burning an AI restart iteration on it.
|
|
975
994
|
blockers.push({
|
|
976
|
-
type: 'not_mergeable',
|
|
995
|
+
type: mergeStatus.isDraft ? 'draft' : 'not_mergeable',
|
|
977
996
|
message: mergeStatus.reason || 'PR is not mergeable',
|
|
978
997
|
details: [],
|
|
979
998
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pre-flight checks that run before the --auto-merge / --auto-restart-until-mergeable
|
|
6
|
+
* watch loop is entered: mode detection, base-branch guard, fork detection and
|
|
7
|
+
* merge-permission verification.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from solve.auto-merge.lib.mjs (issue #1593) to keep that file under the
|
|
10
|
+
* 1350-line advisory threshold while the issue #2182 guard rails were added. Same
|
|
11
|
+
* pattern as solve.auto-merge-attempt.lib.mjs (issue #2144).
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1593
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
if (typeof globalThis.use === 'undefined') {
|
|
17
|
+
await ensureUseM();
|
|
18
|
+
}
|
|
19
|
+
const use = globalThis.use;
|
|
20
|
+
|
|
21
|
+
const { $: __rawDollar$ } = await use('command-stream');
|
|
22
|
+
const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
|
|
23
|
+
const $ = wrapDollarWithGhRetry(__rawDollar$);
|
|
24
|
+
|
|
25
|
+
const lib = await import('./lib.mjs');
|
|
26
|
+
const { log, formatAligned } = lib;
|
|
27
|
+
|
|
28
|
+
const { checkMergePermissions } = await import('./github-merge.lib.mjs');
|
|
29
|
+
const { checkForExistingComment } = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
30
|
+
const { READY_TO_MERGE_MARKER, postTrackedComment } = await import('./tool-comments.lib.mjs');
|
|
31
|
+
const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Issue #1323: notify the maintainer on the pull request that auto-merge was
|
|
35
|
+
* requested but cannot be performed, without posting the same comment twice.
|
|
36
|
+
*/
|
|
37
|
+
const postManualMergeNotice = async ({ owner, repo, prNumber, reason, verbose, footer }) => {
|
|
38
|
+
try {
|
|
39
|
+
const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
|
|
40
|
+
if (await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, verbose)) {
|
|
41
|
+
await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const commentBody = `${readyToMergeSignature}\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because ${reason}\n\nPlease merge manually.\n\n---\n*${footer}*`;
|
|
45
|
+
// Issue #1625: Track so this doesn't falsely count as AI-authored.
|
|
46
|
+
await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
|
|
47
|
+
await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
|
|
48
|
+
} catch {
|
|
49
|
+
// Don't fail if comment posting fails
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run every check that can stop auto-merge before the watch loop starts.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} params - the same params object `startAutoRestartUntilMergeable` receives
|
|
57
|
+
* @returns {Promise<{stop: boolean, result?: (Object|null)}>} `stop: true` means the
|
|
58
|
+
* caller must return `result` immediately instead of entering the watch loop.
|
|
59
|
+
*/
|
|
60
|
+
export const runAutoMergePreflight = async params => {
|
|
61
|
+
const { argv, owner, repo, prNumber } = params;
|
|
62
|
+
const isAutoMerge = argv.autoMerge || false;
|
|
63
|
+
const isAutoRestartUntilMergeable = argv.autoRestartUntilMergeable || false;
|
|
64
|
+
|
|
65
|
+
if (!isAutoMerge && !isAutoRestartUntilMergeable) {
|
|
66
|
+
return { stop: true, result: null }; // Neither mode enabled
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!prNumber) {
|
|
70
|
+
await log('');
|
|
71
|
+
await log(formatAligned('⚠️', 'Auto-restart-until-mergeable:', 'Requires a pull request'));
|
|
72
|
+
await log(formatAligned('', 'Note:', 'This mode only works with existing PRs', 2));
|
|
73
|
+
return { stop: true, result: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await ensurePullRequestBaseBranch({
|
|
77
|
+
owner,
|
|
78
|
+
repo,
|
|
79
|
+
prNumber,
|
|
80
|
+
argv,
|
|
81
|
+
log,
|
|
82
|
+
formatAligned,
|
|
83
|
+
$,
|
|
84
|
+
onMismatch: isAutoMerge ? 'throw' : 'restore',
|
|
85
|
+
operation: isAutoMerge ? 'auto-merge' : 'auto-restart-until-mergeable',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Issue #1226: Check if running in fork mode — auto-merge cannot work without write access
|
|
89
|
+
if (argv.fork && isAutoMerge) {
|
|
90
|
+
await log('');
|
|
91
|
+
await log(formatAligned('⚠️', 'Auto-merge:', 'Cannot auto-merge fork PRs'));
|
|
92
|
+
await log(formatAligned('', 'Reason:', 'Fork contributors do not have write access to merge PRs to upstream repositories', 2));
|
|
93
|
+
await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
|
|
94
|
+
await log('');
|
|
95
|
+
await postManualMergeNotice({ owner, repo, prNumber, verbose: argv.verbose, reason: 'this PR was created from a fork (no write access to the target repository).', footer: 'hive-mind with --auto-merge flag (fork mode)' });
|
|
96
|
+
return { stop: true, result: { success: false, reason: 'fork_no_write_access' } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Issue #1226: Verify merge permissions before entering the auto-merge/restart loop
|
|
100
|
+
if (isAutoMerge && owner && repo) {
|
|
101
|
+
const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
|
|
102
|
+
if (!canMerge) {
|
|
103
|
+
await log('');
|
|
104
|
+
await log(formatAligned('⚠️', 'Auto-merge:', 'Insufficient permissions to merge'));
|
|
105
|
+
await log(formatAligned('', 'Permission level:', permission || 'unknown', 2));
|
|
106
|
+
await log(formatAligned('', 'Required:', 'push, maintain, or admin access', 2));
|
|
107
|
+
await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
|
|
108
|
+
await log('');
|
|
109
|
+
await postManualMergeNotice({ owner, repo, prNumber, verbose: argv.verbose, reason: `the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).`, footer: 'hive-mind with --auto-merge flag' });
|
|
110
|
+
return { stop: true, result: { success: false, reason: 'insufficient_permissions' } };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --auto-merge implies --auto-restart-until-mergeable
|
|
115
|
+
if (isAutoMerge) {
|
|
116
|
+
argv.autoRestartUntilMergeable = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { stop: false };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export default { runAutoMergePreflight };
|