@link-assistant/hive-mind 1.72.4 → 1.72.6
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 +12 -0
- package/package.json +1 -1
- package/src/solve.auto-merge-helpers.lib.mjs +44 -14
- package/src/solve.auto-merge.lib.mjs +6 -2
- package/src/solve.auto-pr.lib.mjs +40 -40
- package/src/solve.branch-divergence.lib.mjs +240 -1
- package/src/solve.error-handlers.lib.mjs +4 -1
- package/src/solve.pre-pr-failure-notifier.lib.mjs +22 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.72.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 57f15ec: Detect same-account human feedback in auto-restart comment monitoring only when the AI tool is idle, while still filtering hive-mind tool-generated comments by marker and tracked ID.
|
|
8
|
+
|
|
9
|
+
## 1.72.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- c3a89a3: Recover auto-PR creation when a rejected push leaves the remote branch matching local HEAD, and improve push rejection diagnostics with exact branch and compare links.
|
|
14
|
+
|
|
3
15
|
## 1.72.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -76,7 +76,7 @@ const formatRunLine = run => {
|
|
|
76
76
|
// search scope for checkForExistingComment() stays in lock-step with the
|
|
77
77
|
// markers actually embedded in tool-posted comments.
|
|
78
78
|
const toolComments = await import('./tool-comments.lib.mjs');
|
|
79
|
-
const { SESSION_ENDING_MARKERS } = toolComments;
|
|
79
|
+
const { SESSION_ENDING_MARKERS, isToolGeneratedComment, isToolTrackedCommentId } = toolComments;
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Issue #1323: Check if a comment with specific content already exists on the PR
|
|
@@ -168,14 +168,25 @@ export const checkForExistingComment = async (owner, repo, prNumber, commentSign
|
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Check for new comments from non-bot users since last commit
|
|
171
|
+
*
|
|
172
|
+
* Same-account comments are only considered feedback when
|
|
173
|
+
* `trustAuthenticatedUserComments` is true. Keep the default false for callers
|
|
174
|
+
* that may run while an AI tool is still active: those tools can post through
|
|
175
|
+
* the authenticated GitHub account.
|
|
176
|
+
*
|
|
177
|
+
* @param {Function} commandRunner - Tagged-template command runner, injectable for tests
|
|
178
|
+
* @param {Object} options - Comment classification options
|
|
179
|
+
* @param {boolean} options.trustAuthenticatedUserComments - True only when the caller knows the AI tool is not running
|
|
171
180
|
* @returns {Promise<{hasNewComments: boolean, comments: Array}>}
|
|
172
181
|
*/
|
|
173
|
-
export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false) => {
|
|
182
|
+
export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false, commandRunner = $, options = {}) => {
|
|
174
183
|
try {
|
|
184
|
+
const { trustAuthenticatedUserComments = false } = options;
|
|
185
|
+
|
|
175
186
|
// Get current GitHub user to identify which comments are from the bot/hive-mind
|
|
176
187
|
let currentUser = null;
|
|
177
188
|
try {
|
|
178
|
-
const userResult = await
|
|
189
|
+
const userResult = await commandRunner`gh api user --jq .login`;
|
|
179
190
|
if (userResult.code === 0) {
|
|
180
191
|
currentUser = userResult.stdout.toString().trim();
|
|
181
192
|
}
|
|
@@ -183,7 +194,12 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
|
|
|
183
194
|
// If we can't get the current user, continue without filtering
|
|
184
195
|
}
|
|
185
196
|
|
|
186
|
-
// Common bot usernames and patterns to filter out
|
|
197
|
+
// Common bot usernames and patterns to filter out.
|
|
198
|
+
// Issue #1821: In same-account operation, humans and AI tools can both
|
|
199
|
+
// post through the authenticated account. The safe default treats that
|
|
200
|
+
// account as tool-owned; auto-restart-until-mergeable opts in to trusting
|
|
201
|
+
// same-account comments only while no AI tool execution is active, and
|
|
202
|
+
// still filters tool-generated comments by tracked IDs and marker strings.
|
|
187
203
|
// Note: Patterns use word boundaries or end-of-string to avoid false positives
|
|
188
204
|
// (e.g., "claudeuser" should NOT match as a bot)
|
|
189
205
|
const botPatterns = [
|
|
@@ -201,21 +217,21 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
|
|
|
201
217
|
|
|
202
218
|
const isBot = login => {
|
|
203
219
|
if (!login) return false;
|
|
204
|
-
// Check if it's the current user (the bot running hive-mind)
|
|
205
|
-
if (currentUser && login === currentUser) return true;
|
|
206
220
|
// Check against known bot patterns
|
|
207
221
|
return botPatterns.some(pattern => pattern.test(login));
|
|
208
222
|
};
|
|
209
223
|
|
|
224
|
+
const isToolComment = comment => isToolTrackedCommentId(comment.id) || isToolGeneratedComment(comment.body);
|
|
225
|
+
|
|
210
226
|
// Fetch PR conversation comments
|
|
211
|
-
const prCommentsResult = await
|
|
227
|
+
const prCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
|
|
212
228
|
let prComments = [];
|
|
213
229
|
if (prCommentsResult.code === 0 && prCommentsResult.stdout) {
|
|
214
230
|
prComments = JSON.parse(prCommentsResult.stdout.toString() || '[]');
|
|
215
231
|
}
|
|
216
232
|
|
|
217
233
|
// Fetch PR review comments (inline code comments)
|
|
218
|
-
const prReviewCommentsResult = await
|
|
234
|
+
const prReviewCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
|
|
219
235
|
let prReviewComments = [];
|
|
220
236
|
if (prReviewCommentsResult.code === 0 && prReviewCommentsResult.stdout) {
|
|
221
237
|
prReviewComments = JSON.parse(prReviewCommentsResult.stdout.toString() || '[]');
|
|
@@ -224,7 +240,7 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
|
|
|
224
240
|
// Fetch issue comments if we have an issue number
|
|
225
241
|
let issueComments = [];
|
|
226
242
|
if (issueNumber && issueNumber !== prNumber) {
|
|
227
|
-
const issueCommentsResult = await
|
|
243
|
+
const issueCommentsResult = await commandRunner`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
|
|
228
244
|
if (issueCommentsResult.code === 0 && issueCommentsResult.stdout) {
|
|
229
245
|
issueComments = JSON.parse(issueCommentsResult.stdout.toString() || '[]');
|
|
230
246
|
}
|
|
@@ -233,14 +249,28 @@ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber,
|
|
|
233
249
|
// Combine all comments
|
|
234
250
|
const allComments = [...prComments, ...prReviewComments, ...issueComments];
|
|
235
251
|
|
|
236
|
-
// Filter for new comments from non-bot users
|
|
252
|
+
// Filter for new comments from non-bot users. Automated hive-mind/tool
|
|
253
|
+
// comments are excluded by marker/ID, including comments posted by the
|
|
254
|
+
// authenticated user during the current or a previous process.
|
|
237
255
|
const newNonBotComments = allComments.filter(comment => {
|
|
238
256
|
const commentTime = new Date(comment.created_at);
|
|
239
257
|
const isAfterLastCheck = commentTime > lastCheckTime;
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
258
|
+
const login = comment.user?.login;
|
|
259
|
+
const isFromAuthenticatedUser = Boolean(currentUser && login === currentUser);
|
|
260
|
+
const isFromTool = isToolComment(comment);
|
|
261
|
+
const isFromAuthenticatedUserToolContext = isFromAuthenticatedUser && !trustAuthenticatedUserComments;
|
|
262
|
+
const isFromBot = isBot(login) || isFromAuthenticatedUserToolContext;
|
|
263
|
+
const isFromNonBot = !isFromBot && !isFromTool;
|
|
264
|
+
|
|
265
|
+
if (verbose && isAfterLastCheck && isFromTool) {
|
|
266
|
+
console.log(`[VERBOSE] Skipping tool-generated comment from ${login} at ${comment.created_at}`);
|
|
267
|
+
} else if (verbose && isAfterLastCheck && isFromAuthenticatedUserToolContext) {
|
|
268
|
+
console.log(`[VERBOSE] Skipping authenticated-user comment from ${login} at ${comment.created_at} because same-account feedback is not trusted in this context`);
|
|
269
|
+
} else if (verbose && isAfterLastCheck && isFromBot) {
|
|
270
|
+
console.log(`[VERBOSE] Skipping bot comment from ${login} at ${comment.created_at}`);
|
|
271
|
+
} else if (verbose && isAfterLastCheck && isFromNonBot) {
|
|
272
|
+
const sameAccountSuffix = currentUser && login === currentUser ? ' (authenticated user)' : '';
|
|
273
|
+
console.log(`[VERBOSE] New non-bot comment from ${login}${sameAccountSuffix} at ${comment.created_at}`);
|
|
244
274
|
}
|
|
245
275
|
|
|
246
276
|
return isAfterLastCheck && isFromNonBot;
|
|
@@ -206,8 +206,12 @@ export const watchUntilMergeable = async params => {
|
|
|
206
206
|
// Keep the counter as-is (it reached the safety valve or wasn't needed).
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
// Check for new comments from non-bot users
|
|
210
|
-
|
|
209
|
+
// Check for new comments from non-bot users. At this point the AI tool
|
|
210
|
+
// is not executing, so same-account non-tool comments can be trusted as
|
|
211
|
+
// human feedback while known tool comments remain filtered by markers/IDs.
|
|
212
|
+
const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose, $, {
|
|
213
|
+
trustAuthenticatedUserComments: true,
|
|
214
|
+
});
|
|
211
215
|
|
|
212
216
|
// Check for uncommitted changes using shared utility
|
|
213
217
|
const hasUncommittedChanges = await checkForUncommittedChanges(tempDir, argv);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
|
|
8
8
|
import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
|
|
9
9
|
|
|
10
10
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
|
|
@@ -409,6 +409,9 @@ Proceed.
|
|
|
409
409
|
}
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
let branchReadyForPrCreation = pushResult.code === 0;
|
|
413
|
+
let recoveredFromPushRejection = false;
|
|
414
|
+
|
|
412
415
|
if (pushResult.code !== 0) {
|
|
413
416
|
const errorOutput = pushResult.stderr ? pushResult.stderr.toString() : pushResult.stdout ? pushResult.stdout.toString() : 'Unknown error';
|
|
414
417
|
|
|
@@ -539,48 +542,41 @@ Proceed.
|
|
|
539
542
|
}
|
|
540
543
|
await log('');
|
|
541
544
|
throw new Error('Permission denied - need fork or collaborator access');
|
|
542
|
-
} else
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
545
|
+
} else {
|
|
546
|
+
const rejectedPush = await handleRejectedPushForAutoPr({
|
|
547
|
+
errorOutput,
|
|
548
|
+
$,
|
|
549
|
+
tempDir,
|
|
550
|
+
log,
|
|
551
|
+
formatAligned,
|
|
552
|
+
branchName,
|
|
553
|
+
isContinueMode,
|
|
554
|
+
prNumber,
|
|
555
|
+
owner,
|
|
556
|
+
repo,
|
|
557
|
+
defaultBranch,
|
|
558
|
+
forkedRepo,
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
if (rejectedPush.handled) {
|
|
562
|
+
branchReadyForPrCreation = rejectedPush.branchReadyForPrCreation;
|
|
563
|
+
recoveredFromPushRejection = rejectedPush.recoveredFromPushRejection;
|
|
564
|
+
} else {
|
|
565
|
+
// Other push errors
|
|
566
|
+
await log(`${formatAligned('❌', 'Failed to push:', 'See error below')}`, { level: 'error' });
|
|
567
|
+
await log(` Error: ${errorOutput}`, { level: 'error' });
|
|
568
|
+
throw new Error('Failed to push branch');
|
|
553
569
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
await log('');
|
|
560
|
-
await log(' 🔧 How to fix:');
|
|
561
|
-
await log(' 1. Clone the repository and checkout the branch:');
|
|
562
|
-
await log(` git clone https://github.com/${owner}/${repo}.git`);
|
|
563
|
-
await log(` cd ${repo}`);
|
|
564
|
-
await log(` git checkout ${branchName}`);
|
|
565
|
-
await log('');
|
|
566
|
-
await log(' 2. Pull and merge the remote changes:');
|
|
567
|
-
await log(` git pull origin ${branchName}`);
|
|
568
|
-
await log('');
|
|
569
|
-
await log(' 3. Resolve any conflicts manually, then:');
|
|
570
|
-
await log(` git push origin ${branchName}`);
|
|
571
|
-
await log('');
|
|
572
|
-
await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
573
|
-
await log('');
|
|
574
|
-
throw new Error('Push rejected - branch has diverged, manual resolution required');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (branchReadyForPrCreation) {
|
|
574
|
+
if (recoveredFromPushRejection) {
|
|
575
|
+
await log(`${formatAligned('✅', 'Branch available:', 'Remote branch matches local HEAD')}`);
|
|
575
576
|
} else {
|
|
576
|
-
|
|
577
|
-
await log(`${formatAligned('❌', 'Failed to push:', 'See error below')}`, { level: 'error' });
|
|
578
|
-
await log(` Error: ${errorOutput}`, { level: 'error' });
|
|
579
|
-
throw new Error('Failed to push branch');
|
|
577
|
+
await log(`${formatAligned('✅', 'Branch pushed:', 'Successfully to remote')}`);
|
|
580
578
|
}
|
|
581
|
-
|
|
582
|
-
await log(`${formatAligned('✅', 'Branch pushed:', 'Successfully to remote')}`);
|
|
583
|
-
if (argv.verbose) {
|
|
579
|
+
if (argv.verbose && pushResult.code === 0) {
|
|
584
580
|
await log(` Push output: ${pushResult.stdout.toString().trim()}`, { verbose: true });
|
|
585
581
|
}
|
|
586
582
|
|
|
@@ -1454,6 +1450,10 @@ ${prBody}`,
|
|
|
1454
1450
|
operation: 'handle_auto_pr',
|
|
1455
1451
|
});
|
|
1456
1452
|
|
|
1453
|
+
if (prError?.hiveMindUserFacingLogged) {
|
|
1454
|
+
throw prError;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
1457
|
// Issue #1462: Single consolidated error message for PR creation failure.
|
|
1458
1458
|
// Previously this was the third of three error blocks, causing confusing output.
|
|
1459
1459
|
// Now this is the ONLY error block shown for PR creation failures.
|
|
@@ -9,18 +9,117 @@ const outputOf = result => {
|
|
|
9
9
|
return stdout || stderr;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const shortSha = sha => (sha ? String(sha).slice(0, 12) : null);
|
|
13
|
+
|
|
14
|
+
const encodeRefForGitHubUrl = ref =>
|
|
15
|
+
encodeURI(String(ref || ''))
|
|
16
|
+
.replaceAll('#', '%23')
|
|
17
|
+
.replaceAll('?', '%3F');
|
|
18
|
+
|
|
19
|
+
export function classifyPushRejection(errorOutput = '') {
|
|
20
|
+
const normalized = String(errorOutput || '').toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (normalized.includes('cannot lock ref') && normalized.includes('reference already exists')) {
|
|
23
|
+
return 'remote-ref-already-exists';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (normalized.includes('non-fast-forward') || normalized.includes('not fast-forward') || normalized.includes('fetch first') || normalized.includes('stale info') || normalized.includes('tip of your current branch is behind') || normalized.includes('updates were rejected')) {
|
|
27
|
+
return 'non-fast-forward';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (normalized.includes('remote rejected') || normalized.includes('[remote rejected]')) {
|
|
31
|
+
return 'remote-rejected';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (normalized.includes('rejected') || normalized.includes('failed to push some refs')) {
|
|
35
|
+
return 'rejected';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return 'unknown';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function shouldTreatPushRejectionAsRemoteSynchronized(divergence = null) {
|
|
42
|
+
if (!divergence?.remoteExists || divergence.ahead !== 0 || divergence.behind !== 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (divergence.localSha && divergence.remoteSha) {
|
|
47
|
+
return divergence.localSha === divergence.remoteSha;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo = null }) {
|
|
54
|
+
const repository = `${owner}/${repo}`;
|
|
55
|
+
const headRepository = forkedRepo || repository;
|
|
56
|
+
const headOwner = headRepository.split('/')[0];
|
|
57
|
+
const baseBranch = defaultBranch || 'main';
|
|
58
|
+
const compareHead = forkedRepo ? `${headOwner}:${branchName}` : branchName;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
repository,
|
|
62
|
+
headRepository,
|
|
63
|
+
baseBranchRef: `${repository}:${baseBranch}`,
|
|
64
|
+
headBranchRef: `${headRepository}:${branchName}`,
|
|
65
|
+
remoteBranchRef: `origin/${branchName}`,
|
|
66
|
+
repositoryUrl: `https://github.com/${repository}`,
|
|
67
|
+
branchUrl: `https://github.com/${headRepository}/tree/${encodeRefForGitHubUrl(branchName)}`,
|
|
68
|
+
compareUrl: `https://github.com/${repository}/compare/${encodeRefForGitHubUrl(baseBranch)}...${encodeRefForGitHubUrl(compareHead)}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildPushRejectionFailureActionSection({ owner, repo, branchName, defaultBranch, forkedRepo = null }) {
|
|
73
|
+
if (!owner || !repo || !branchName) {
|
|
74
|
+
return `### What you can do
|
|
75
|
+
- Inspect the remote branch and compare it with the local branch before retrying.
|
|
76
|
+
- If the remote branch already contains the intended commit, rerun the solver.
|
|
77
|
+
- If the histories differ, merge or resolve the branch manually, then rerun the solver.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
|
|
81
|
+
|
|
82
|
+
return `### What you can do
|
|
83
|
+
- Inspect the remote branch: ${links.branchUrl}
|
|
84
|
+
- Compare the base and head branches: ${links.compareUrl}
|
|
85
|
+
- If the remote branch already contains the intended commit, rerun the solver. Matching remote branches are treated as usable after this fix.
|
|
86
|
+
- If the histories differ, merge or resolve \`${links.headBranchRef}\` against \`${links.baseBranchRef}\`, then rerun the solver.
|
|
87
|
+
|
|
88
|
+
Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null, owner = null, repo = null, defaultBranch = null, forkedRepo = null, classification = 'unknown' }) {
|
|
13
92
|
const lines = [];
|
|
14
93
|
|
|
15
94
|
if (isContinueMode && !prNumber) {
|
|
16
95
|
lines.push(' This run reused an existing issue branch because auto-continue found a matching branch with no PR.');
|
|
17
96
|
lines.push(' It is not a fresh branch created by this run, even though auto-PR creation is running now.');
|
|
97
|
+
} else if (classification === 'remote-ref-already-exists') {
|
|
98
|
+
lines.push(' GitHub rejected the push while creating or updating the remote ref because that ref already exists.');
|
|
18
99
|
} else {
|
|
19
100
|
lines.push(' The remote branch changed after the local branch state used for this push.');
|
|
20
101
|
}
|
|
21
102
|
|
|
103
|
+
if (owner && repo) {
|
|
104
|
+
const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
|
|
105
|
+
lines.push(` Repository: ${links.repositoryUrl}`);
|
|
106
|
+
lines.push(` Base branch: ${links.baseBranchRef}`);
|
|
107
|
+
lines.push(` Remote branch: ${links.headBranchRef}`);
|
|
108
|
+
lines.push(` Branch URL: ${links.branchUrl}`);
|
|
109
|
+
lines.push(` Compare URL: ${links.compareUrl}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
22
112
|
if (divergence?.remoteExists && divergence.ahead !== null && divergence.behind !== null) {
|
|
23
113
|
lines.push(` Current branch state for ${branchName}: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}.`);
|
|
114
|
+
if (divergence.localSha) {
|
|
115
|
+
lines.push(` Local HEAD: ${shortSha(divergence.localSha)}`);
|
|
116
|
+
}
|
|
117
|
+
if (divergence.remoteSha) {
|
|
118
|
+
lines.push(` Remote HEAD: ${shortSha(divergence.remoteSha)}`);
|
|
119
|
+
}
|
|
120
|
+
if (shouldTreatPushRejectionAsRemoteSynchronized(divergence)) {
|
|
121
|
+
lines.push(' The remote branch currently matches local HEAD, so this is not a branch divergence.');
|
|
122
|
+
}
|
|
24
123
|
} else if (divergence?.fetchError) {
|
|
25
124
|
lines.push(` Could not inspect origin/${branchName}: ${divergence.fetchError}`);
|
|
26
125
|
}
|
|
@@ -28,6 +127,142 @@ export function buildPushRejectionExplanation({ branchName, isContinueMode, prNu
|
|
|
28
127
|
return lines;
|
|
29
128
|
}
|
|
30
129
|
|
|
130
|
+
export async function logRecoverablePushRejection({ log, formatAligned, branchName, isContinueMode, prNumber, divergence, owner, repo, defaultBranch, forkedRepo, classification }) {
|
|
131
|
+
const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
|
|
132
|
+
|
|
133
|
+
await log('');
|
|
134
|
+
await log(formatAligned('⚠️', 'PUSH REPORTED FAILURE:', 'Remote branch already matches local HEAD'), { level: 'warning' });
|
|
135
|
+
await log('');
|
|
136
|
+
await log(' 🔍 What happened:');
|
|
137
|
+
for (const line of buildPushRejectionExplanation({
|
|
138
|
+
branchName,
|
|
139
|
+
isContinueMode,
|
|
140
|
+
prNumber,
|
|
141
|
+
divergence,
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
defaultBranch,
|
|
145
|
+
forkedRepo,
|
|
146
|
+
classification,
|
|
147
|
+
})) {
|
|
148
|
+
await log(line);
|
|
149
|
+
}
|
|
150
|
+
await log('');
|
|
151
|
+
await log(' ✅ Recovery:');
|
|
152
|
+
await log(` The branch is available at ${links.branchUrl}.`);
|
|
153
|
+
await log(' Continuing with PR creation because no local commit would be lost.');
|
|
154
|
+
await log('');
|
|
155
|
+
|
|
156
|
+
return links;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function logBlockingPushRejection({ log, formatAligned, branchName, isContinueMode, prNumber, divergence, owner, repo, defaultBranch, forkedRepo, classification }) {
|
|
160
|
+
const links = buildBranchSubjectLinks({ owner, repo, branchName, defaultBranch, forkedRepo });
|
|
161
|
+
const isRefCollision = classification === 'remote-ref-already-exists';
|
|
162
|
+
|
|
163
|
+
await log('');
|
|
164
|
+
await log(formatAligned('❌', isRefCollision ? 'REMOTE BRANCH COLLISION:' : 'PUSH REJECTED:', isRefCollision ? 'Remote ref already exists and differs from local branch' : 'Local and remote branch histories differ'), { level: 'error' });
|
|
165
|
+
await log('');
|
|
166
|
+
await log(' 🔍 What happened:');
|
|
167
|
+
if (isRefCollision) {
|
|
168
|
+
await log(` GitHub rejected creation or update of ${links.headBranchRef} because that remote ref already exists.`);
|
|
169
|
+
await log(' The existing remote branch does not match this local branch, so hive-mind cannot assume it is safe to continue.');
|
|
170
|
+
} else {
|
|
171
|
+
await log(` Git rejected updating ${links.headBranchRef} from this local branch.`);
|
|
172
|
+
await log(' The local and remote histories are not in a state that a normal push can update safely.');
|
|
173
|
+
}
|
|
174
|
+
for (const line of buildPushRejectionExplanation({
|
|
175
|
+
branchName,
|
|
176
|
+
isContinueMode,
|
|
177
|
+
prNumber,
|
|
178
|
+
divergence,
|
|
179
|
+
owner,
|
|
180
|
+
repo,
|
|
181
|
+
defaultBranch,
|
|
182
|
+
forkedRepo,
|
|
183
|
+
classification,
|
|
184
|
+
})) {
|
|
185
|
+
await log(line);
|
|
186
|
+
}
|
|
187
|
+
await log('');
|
|
188
|
+
await log(' 💡 Why we cannot fix this automatically:');
|
|
189
|
+
await log(' • We never use force push to preserve history');
|
|
190
|
+
await log(' • We never use rebase or reset to avoid altering git history');
|
|
191
|
+
await log(` • Manual review is required before changing ${links.headBranchRef}`);
|
|
192
|
+
await log('');
|
|
193
|
+
await log(' 🔧 How to fix:');
|
|
194
|
+
await log(` 1. Inspect the remote branch: ${links.branchUrl}`);
|
|
195
|
+
await log(` 2. Compare base and head: ${links.compareUrl}`);
|
|
196
|
+
await log(' 3. Clone the repository and checkout the branch:');
|
|
197
|
+
await log(` git clone https://github.com/${links.headRepository}.git`);
|
|
198
|
+
await log(` cd ${links.headRepository.split('/')[1]}`);
|
|
199
|
+
await log(` git checkout ${branchName}`);
|
|
200
|
+
await log('');
|
|
201
|
+
await log(' 4. Merge the remote branch state, resolve conflicts if any, then push:');
|
|
202
|
+
await log(` git pull origin ${branchName}`);
|
|
203
|
+
await log(` git push origin ${branchName}`);
|
|
204
|
+
await log('');
|
|
205
|
+
await log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
206
|
+
await log('');
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
links,
|
|
210
|
+
failureActionSection: buildPushRejectionFailureActionSection({ owner, repo, branchName, defaultBranch, forkedRepo }),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function handleRejectedPushForAutoPr({ errorOutput, $, tempDir, log, formatAligned, branchName, isContinueMode, prNumber, owner, repo, defaultBranch, forkedRepo }) {
|
|
215
|
+
const classification = classifyPushRejection(errorOutput);
|
|
216
|
+
if (classification === 'unknown') {
|
|
217
|
+
return {
|
|
218
|
+
handled: false,
|
|
219
|
+
branchReadyForPrCreation: false,
|
|
220
|
+
recoveredFromPushRejection: false,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
|
|
225
|
+
|
|
226
|
+
if (shouldTreatPushRejectionAsRemoteSynchronized(divergence)) {
|
|
227
|
+
await logRecoverablePushRejection({
|
|
228
|
+
log,
|
|
229
|
+
formatAligned,
|
|
230
|
+
branchName,
|
|
231
|
+
isContinueMode,
|
|
232
|
+
prNumber,
|
|
233
|
+
divergence,
|
|
234
|
+
owner,
|
|
235
|
+
repo,
|
|
236
|
+
defaultBranch,
|
|
237
|
+
forkedRepo,
|
|
238
|
+
classification,
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
handled: true,
|
|
242
|
+
branchReadyForPrCreation: true,
|
|
243
|
+
recoveredFromPushRejection: true,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { links, failureActionSection } = await logBlockingPushRejection({
|
|
248
|
+
log,
|
|
249
|
+
formatAligned,
|
|
250
|
+
branchName,
|
|
251
|
+
isContinueMode,
|
|
252
|
+
prNumber,
|
|
253
|
+
divergence,
|
|
254
|
+
owner,
|
|
255
|
+
repo,
|
|
256
|
+
defaultBranch,
|
|
257
|
+
forkedRepo,
|
|
258
|
+
classification,
|
|
259
|
+
});
|
|
260
|
+
const error = new Error(`Push rejected for ${links.headBranchRef}; compare ${links.compareUrl} and inspect ${links.branchUrl}`);
|
|
261
|
+
error.hiveMindUserFacingLogged = true;
|
|
262
|
+
error.failureActionSection = failureActionSection;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
|
|
31
266
|
export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName }) {
|
|
32
267
|
const fetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
|
|
33
268
|
if (fetchResult.code !== 0) {
|
|
@@ -41,11 +276,15 @@ export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName
|
|
|
41
276
|
|
|
42
277
|
const aheadResult = await $({ cwd: tempDir, silent: true })`git rev-list --count origin/${branchName}..HEAD 2>&1`;
|
|
43
278
|
const behindResult = await $({ cwd: tempDir, silent: true })`git rev-list --count HEAD..origin/${branchName} 2>&1`;
|
|
279
|
+
const localShaResult = await $({ cwd: tempDir, silent: true })`git rev-parse HEAD 2>&1`;
|
|
280
|
+
const remoteShaResult = await $({ cwd: tempDir, silent: true })`git rev-parse origin/${branchName} 2>&1`;
|
|
44
281
|
|
|
45
282
|
return {
|
|
46
283
|
remoteExists: aheadResult.code === 0 && behindResult.code === 0,
|
|
47
284
|
ahead: aheadResult.code === 0 ? toCount(aheadResult.stdout) : null,
|
|
48
285
|
behind: behindResult.code === 0 ? toCount(behindResult.stdout) : null,
|
|
286
|
+
localSha: localShaResult.code === 0 ? outputOf(localShaResult) : null,
|
|
287
|
+
remoteSha: remoteShaResult.code === 0 ? outputOf(remoteShaResult) : null,
|
|
49
288
|
fetchError: aheadResult.code === 0 && behindResult.code === 0 ? null : outputOf(aheadResult) || outputOf(behindResult) || 'could not compare local and remote branch',
|
|
50
289
|
};
|
|
51
290
|
}
|
|
@@ -68,6 +68,7 @@ export const handleFailure = async options => {
|
|
|
68
68
|
sanitizeLogContent,
|
|
69
69
|
verbose: argv.verbose,
|
|
70
70
|
errorMessage: cleanErrorMessage(error),
|
|
71
|
+
failureActionSection: error?.failureActionSection || null,
|
|
71
72
|
// Issue #1225: Pass model and tool info for PR comments
|
|
72
73
|
requestedModel: argv.originalModel || argv.model,
|
|
73
74
|
tool: argv.tool || 'claude',
|
|
@@ -236,7 +237,9 @@ export const handleMainExecutionError = async options => {
|
|
|
236
237
|
return;
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
if (!error?.hiveMindUserFacingLogged) {
|
|
241
|
+
await log('Error executing command:', cleanErrorMessage(error));
|
|
242
|
+
}
|
|
240
243
|
await log(`Stack trace: ${error.stack}`, { verbose: true });
|
|
241
244
|
await log(` 📁 Full log file: ${absoluteLogPath}`, { level: 'error' });
|
|
242
245
|
|
|
@@ -15,6 +15,16 @@ const isForkDivergenceFailure = reason => {
|
|
|
15
15
|
return normalizedReason.includes('fork divergence') || (normalizedReason.includes('fork') && normalizedReason.includes('non-fast-forward')) || normalizedReason.includes('force-with-lease');
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const extractUrlAfter = (reason, label) => {
|
|
19
|
+
const match = String(reason || '').match(new RegExp(`${label}\\s+(https://github\\.com/[^\\s;]+)`, 'i'));
|
|
20
|
+
return match ? match[1] : null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isPushRejectionFailure = reason => {
|
|
24
|
+
const normalizedReason = String(reason || '').toLowerCase();
|
|
25
|
+
return normalizedReason.includes('push rejected for ') || (normalizedReason.includes('failed to push') && normalizedReason.includes('github.com'));
|
|
26
|
+
};
|
|
27
|
+
|
|
18
28
|
export function buildPrePullRequestFailureActionSection(reason = '') {
|
|
19
29
|
const normalizedReason = String(reason || '').toLowerCase();
|
|
20
30
|
const isForkOrRecoveryFailure = normalizedReason.includes('fork') || normalizedReason.includes('auto-recovery') || normalizedReason.includes('repository setup');
|
|
@@ -25,6 +35,18 @@ export function buildPrePullRequestFailureActionSection(reason = '') {
|
|
|
25
35
|
- If the fork has commits you need to preserve, resolve the divergence manually, then rerun the solver.
|
|
26
36
|
- If this requires elevated Hive Mind access, ask a Hive Mind administrator to handle the affected fork or repository.
|
|
27
37
|
|
|
38
|
+
Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (isPushRejectionFailure(reason)) {
|
|
42
|
+
const branchUrl = extractUrlAfter(reason, 'inspect');
|
|
43
|
+
const compareUrl = extractUrlAfter(reason, 'compare');
|
|
44
|
+
return `### What you can do
|
|
45
|
+
- Inspect the remote branch${branchUrl ? `: ${branchUrl}` : ' named in the failure'}.
|
|
46
|
+
- Compare the base and head histories${compareUrl ? `: ${compareUrl}` : ' using the compare link named in the failure'}.
|
|
47
|
+
- If the branch belongs to you, merge the remote branch state or choose a new branch name, then rerun the solver.
|
|
48
|
+
- Do not force-push unless you have manually confirmed that overwriting the remote branch is safe.
|
|
49
|
+
|
|
28
50
|
Administrator-only CLI details, if any, are printed in the solver terminal log rather than in this GitHub comment.`;
|
|
29
51
|
}
|
|
30
52
|
|