@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.
@@ -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
- const mergeStatus = await checkPRMergeable(owner, repo, prNumber, argv.verbose);
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
- const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
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
- await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2));
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 };