@link-assistant/hive-mind 1.69.3 → 1.69.5

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Fork-aware diagnostic for the auto-PR fatal error block.
3
+ *
4
+ * Issue #1774: when `gh pr create` fails with "No commits between" or
5
+ * "Head sha can't be blank", the most common cause is base-repo resolution
6
+ * picking the upstream parent of a fork (because `gh repo clone` auto-adds
7
+ * an `upstream` remote for forks). This helper inspects the local remotes
8
+ * and prints a self-explanatory diagnostic.
9
+ *
10
+ * Extracted from solve.auto-pr.lib.mjs to keep that file under the 1500-line
11
+ * CI cap.
12
+ */
13
+
14
+ /**
15
+ * @param {object} params
16
+ * @param {string} params.errorMessage - prError.message from the auto-PR catch.
17
+ * @param {string} params.tempDir
18
+ * @param {string} params.owner
19
+ * @param {string} params.repo
20
+ * @param {string} params.defaultBranch
21
+ * @param {string} params.branchName
22
+ * @param {string|number} params.issueNumber
23
+ * @param {(msg: string, opts?: object) => Promise<void>} params.log
24
+ * @param {Function} params.$ - command-stream tagged function from solve.
25
+ * @param {Function} params.reportError
26
+ */
27
+ export async function emitForkAwareDiagnostic({ errorMessage, tempDir, owner, repo, defaultBranch, branchName, issueNumber, log, $, reportError }) {
28
+ const errMsg = errorMessage || '';
29
+ if (!errMsg.includes('No commits between') && !errMsg.includes("Head sha can't be blank")) {
30
+ return;
31
+ }
32
+
33
+ try {
34
+ const remotesResult = await $({ cwd: tempDir, silent: true })`git remote -v`;
35
+ const remotesText = remotesResult.code === 0 ? remotesResult.stdout.toString().trim() : '';
36
+ const originLine = remotesText.split('\n').find(line => line.startsWith('origin\t') && line.includes('(fetch)'));
37
+ const upstreamLine = remotesText.split('\n').find(line => line.startsWith('upstream\t') && line.includes('(fetch)'));
38
+
39
+ await log(' 🔬 Fork-aware diagnostic (Issue #1774):');
40
+ await log(` Target repository: ${owner}/${repo}`);
41
+ if (originLine) {
42
+ await log(` origin remote: ${originLine.replace(/^origin\t/, '').replace(/\s+\(fetch\)$/, '')}`);
43
+ }
44
+ if (upstreamLine) {
45
+ await log(` upstream remote: ${upstreamLine.replace(/^upstream\t/, '').replace(/\s+\(fetch\)$/, '')}`);
46
+ await log('');
47
+ await log(' `gh repo clone` automatically adds an `upstream` remote when the');
48
+ await log(' cloned repository is a fork. Without --repo, `gh pr create`');
49
+ await log(' resolves the base to that upstream parent instead of the fork');
50
+ await log(' where this branch was pushed, producing the misleading');
51
+ await log(' "No commits between" error. This version already pins --repo');
52
+ await log(' to the explicit target, so a fresh `solve` invocation should');
53
+ await log(' succeed. See docs/case-studies/issue-1774/README.md.');
54
+ } else {
55
+ await log(' (no `upstream` remote found locally)');
56
+ }
57
+ await log('');
58
+ await log(' Manual recovery command:');
59
+ await log(` gh pr create --draft --base ${defaultBranch} --head ${branchName} --repo ${owner}/${repo}`);
60
+ await log('');
61
+ } catch (diagError) {
62
+ reportError(diagError, {
63
+ context: 'auto_pr_fork_diagnostic',
64
+ issueNumber,
65
+ operation: 'collect_fork_diagnostic',
66
+ });
67
+ }
68
+ }
69
+
70
+ export default { emitForkAwareDiagnostic };
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
7
7
  import { buildPushRejectionExplanation, getRemoteBranchDivergenceSnapshot, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
8
+ import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
8
9
 
9
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.
10
11
 
@@ -1112,13 +1113,15 @@ ${prBody}`,
1112
1113
  // Build command with optional assignee and handle forks
1113
1114
  // Note: targetBranch is already defined above
1114
1115
  // IMPORTANT: Use --title-file instead of --title to avoid shell parsing issues with special characters
1116
+ // --repo is always passed (Issue #1774) so a fork-of-fork target
1117
+ // does not silently switch to the upstream parent via `gh repo clone`'s
1118
+ // auto-added `upstream` remote.
1115
1119
  let command;
1116
1120
  if (argv.fork && forkedRepo) {
1117
- // For forks, specify the full head reference
1118
1121
  const forkUser = forkedRepo.split('/')[0];
1119
1122
  command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${forkUser}:${branchName} --repo ${owner}/${repo}`;
1120
1123
  } else {
1121
- command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${branchName}`;
1124
+ command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${branchName} --repo ${owner}/${repo}`;
1122
1125
  }
1123
1126
  // Only add assignee if user has permissions
1124
1127
  if (currentUser && canAssign) {
@@ -1157,12 +1160,12 @@ ${prBody}`,
1157
1160
  });
1158
1161
  await log(' Retrying PR creation without assignee...');
1159
1162
 
1160
- // Rebuild command without --assignee flag
1163
+ // Rebuild command without --assignee flag (Issue #1774: --repo always pinned)
1161
1164
  if (argv.fork && forkedRepo) {
1162
1165
  const forkUser = forkedRepo.split('/')[0];
1163
1166
  command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${forkUser}:${branchName} --repo ${owner}/${repo}`;
1164
1167
  } else {
1165
- command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${branchName}`;
1168
+ command = `cd "${tempDir}" && gh pr create --draft --title "$(cat '${prTitleFile}')" --body-file "${prBodyFile}" --base ${targetBranch} --head ${branchName} --repo ${owner}/${repo}`;
1166
1169
  }
1167
1170
 
1168
1171
  if (argv.verbose) {
@@ -1460,6 +1463,10 @@ ${prBody}`,
1460
1463
  await log(' 🔍 What happened:');
1461
1464
  await log(` ${prError.message}`);
1462
1465
  await log('');
1466
+
1467
+ // Issue #1774: fork-base resolution failure diagnostic.
1468
+ await emitForkAwareDiagnostic({ errorMessage: prError.message, tempDir, owner, repo, defaultBranch, branchName, issueNumber, log, $, reportError });
1469
+
1463
1470
  await log(' 💡 The solve command cannot continue without a pull request.');
1464
1471
  await log('');
1465
1472
  await log(' 🔧 How to fix:');
@@ -1470,14 +1477,14 @@ ${prBody}`,
1470
1477
  await log('');
1471
1478
  await log(' Option 2: Create PR manually first');
1472
1479
  await log(` cd ${tempDir}`);
1473
- await log(` gh pr create --draft --title "Fix issue #${issueNumber}" --body "Fixes #${issueNumber}"`);
1480
+ await log(` gh pr create --draft --title "Fix issue #${issueNumber}" --body "Fixes #${issueNumber}" --repo ${owner}/${repo}`);
1474
1481
  await log(` Then use: ./solve.mjs "${issueUrl}" --continue`);
1475
1482
  await log('');
1476
1483
  await log(' Option 3: Debug the issue');
1477
1484
  await log(` cd ${tempDir}`);
1478
1485
  await log(' git status');
1479
1486
  await log(' git log --oneline -5');
1480
- await log(' gh pr create --draft # Try manually to see detailed error');
1487
+ await log(` gh pr create --draft --repo ${owner}/${repo} # Try manually to see detailed error`);
1481
1488
  await log('');
1482
1489
 
1483
1490
  // Re-throw the error to stop execution - use prError.message directly
@@ -205,6 +205,105 @@ export function validateBranchInArgs(args) {
205
205
  return null;
206
206
  }
207
207
 
208
+ function isMissingOriginBaseRefError(errorOutput, baseBranch) {
209
+ return errorOutput.includes(`origin/${baseBranch}`) && (errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision'));
210
+ }
211
+
212
+ async function hasUpstreamRemote(tempDir, $) {
213
+ const result = await $({ cwd: tempDir })`git remote get-url upstream 2>/dev/null`;
214
+ return result.code === 0;
215
+ }
216
+
217
+ async function originHasBaseBranch(tempDir, baseBranch, $) {
218
+ const result = await $({ cwd: tempDir })`git show-ref --verify --quiet refs/remotes/origin/${baseBranch}`;
219
+ return result.code === 0;
220
+ }
221
+
222
+ /**
223
+ * Ensure the requested base branch exists on the fork (origin) by syncing it from upstream.
224
+ * Returns true if origin/<baseBranch> exists at the end of the call (was already there or was synced).
225
+ * Returns false if syncing was attempted and failed (caller should propagate the original error).
226
+ *
227
+ * Design note (issue #1772 follow-up): when working in fork mode against a public upstream, the user
228
+ * expects their fork to mirror the upstream branch they want to base work on. Before relying on
229
+ * git's create-from-origin path, we proactively copy the upstream branch into the fork so that the
230
+ * branch creation, the later PR comparison, and any ahead/behind checks all see a consistent state.
231
+ */
232
+ async function ensureBaseBranchInFork({ baseBranch, tempDir, log, formatAligned, $, reason = 'proactive' }) {
233
+ if (!(await hasUpstreamRemote(tempDir, $))) {
234
+ return false;
235
+ }
236
+
237
+ if (reason === 'proactive') {
238
+ await log(`${formatAligned('🔄', 'Syncing base branch:', `ensuring origin/${baseBranch} matches upstream`)}`);
239
+ } else {
240
+ await log(`${formatAligned('🔄', 'Base branch not in fork:', `checking upstream/${baseBranch}`)}`);
241
+ }
242
+
243
+ const fetchResult = await $({ cwd: tempDir })`git fetch upstream`;
244
+ if (fetchResult.code !== 0) {
245
+ await log(`${formatAligned('⚠️', 'Warning:', 'Failed to fetch upstream base branch')}`, { level: 'warning' });
246
+ return false;
247
+ }
248
+
249
+ const upstreamRefResult = await $({ cwd: tempDir })`git show-ref --verify --quiet refs/remotes/upstream/${baseBranch}`;
250
+ if (upstreamRefResult.code !== 0) {
251
+ await log(`${formatAligned('⚠️', 'Warning:', `Base branch not found in upstream/${baseBranch}`)}`, { level: 'warning' });
252
+ return false;
253
+ }
254
+
255
+ await log(`${formatAligned('✅', 'Base branch found:', `upstream/${baseBranch}`)}`);
256
+ const checkoutBaseResult = await $({ cwd: tempDir })`git checkout -B ${baseBranch} upstream/${baseBranch}`;
257
+ if (checkoutBaseResult.code !== 0) {
258
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to prepare local ${baseBranch}`)}`, { level: 'warning' });
259
+ return false;
260
+ }
261
+
262
+ await log(`${formatAligned('🔄', 'Pushing to fork:', `${baseBranch} branch`)}`);
263
+ const pushBaseResult = await $({ cwd: tempDir })`git push origin ${baseBranch} 2>&1`;
264
+ if (pushBaseResult.code !== 0) {
265
+ const pushError = (pushBaseResult.stderr || pushBaseResult.stdout || 'Unknown error').toString().trim();
266
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to push ${baseBranch} to fork`)}`, { level: 'warning' });
267
+ if (pushError) await log(`${formatAligned('', 'Push error:', pushError)}`, { level: 'warning' });
268
+ return false;
269
+ }
270
+
271
+ await log(`${formatAligned('✅', 'Fork updated:', `Custom base branch ${baseBranch} pushed to fork`)}`);
272
+ return true;
273
+ }
274
+
275
+ async function proactivelySyncBaseBranchToFork({ baseBranch, defaultBranch, tempDir, log, formatAligned, $ }) {
276
+ // Default branch is already synced by setupUpstreamAndSync; skip to avoid redundant work.
277
+ if (baseBranch === defaultBranch) {
278
+ return;
279
+ }
280
+
281
+ if (!(await hasUpstreamRemote(tempDir, $))) {
282
+ return;
283
+ }
284
+
285
+ if (await originHasBaseBranch(tempDir, baseBranch, $)) {
286
+ return;
287
+ }
288
+
289
+ await ensureBaseBranchInFork({ baseBranch, tempDir, log, formatAligned, $, reason: 'proactive' });
290
+ }
291
+
292
+ async function retryBranchCreationFromUpstreamBase({ checkoutResult, branchName, baseBranch, tempDir, log, formatAligned, $ }) {
293
+ const errorOutput = `${checkoutResult.stderr || ''}${checkoutResult.stdout || ''}`;
294
+ if (!isMissingOriginBaseRefError(errorOutput, baseBranch)) {
295
+ return checkoutResult;
296
+ }
297
+
298
+ const synced = await ensureBaseBranchInFork({ baseBranch, tempDir, log, formatAligned, $, reason: 'reactive' });
299
+ if (!synced) {
300
+ return checkoutResult;
301
+ }
302
+
303
+ await log(`${formatAligned('🌿', 'Retrying branch:', `${branchName} from ${baseBranch}`)}`);
304
+ return await $({ cwd: tempDir })`git checkout -b ${branchName} ${baseBranch}`;
305
+ }
306
+
208
307
  export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNumber, tempDir, defaultBranch, argv, log, formatAligned, $, crypto, owner, repo, prNumber }) {
209
308
  // Create a branch for the issue or checkout existing PR branch
210
309
  let branchName;
@@ -234,10 +333,33 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
234
333
 
235
334
  await log(`\n${formatAligned('🌿', 'Creating branch:', `${branchName} from ${baseBranch} (${branchSource})`)}`);
236
335
 
336
+ // Issue #1772: when a custom base branch is requested in fork mode, proactively copy it from
337
+ // upstream to the fork before branch creation. The fork's `gh repo fork` snapshot may pre-date
338
+ // upstream's custom branches, so we cannot assume origin already has the requested base.
339
+ if (argv.baseBranch) {
340
+ await proactivelySyncBaseBranchToFork({
341
+ baseBranch,
342
+ defaultBranch,
343
+ tempDir,
344
+ log,
345
+ formatAligned,
346
+ $,
347
+ });
348
+ }
349
+
237
350
  // IMPORTANT: Don't use 2>&1 here as it can interfere with exit codes
238
351
  // Git checkout -b outputs to stderr but that's normal
239
352
  // Create branch from the specified base branch (origin/baseBranch)
240
353
  checkoutResult = await $({ cwd: tempDir })`git checkout -b ${branchName} origin/${baseBranch}`;
354
+ checkoutResult = await retryBranchCreationFromUpstreamBase({
355
+ checkoutResult,
356
+ branchName,
357
+ baseBranch,
358
+ tempDir,
359
+ log,
360
+ formatAligned,
361
+ $,
362
+ });
241
363
  }
242
364
 
243
365
  if (checkoutResult.code !== 0) {
@@ -572,8 +572,23 @@ export const SOLVE_OPTION_DEFINITIONS = {
572
572
  description: "Working language passed to the AI tool (Claude/Codex/etc). Used as the tool's preferred language for translations and prompts. Defaults to --language.",
573
573
  choices: ['en', 'ru', 'zh', 'hi'],
574
574
  },
575
+ 'prompt-language': {
576
+ type: 'string',
577
+ description: 'Deprecated alias for --work-language.',
578
+ choices: ['en', 'ru', 'zh', 'hi'],
579
+ hidden: true,
580
+ },
581
+ 'auto-language': {
582
+ type: 'boolean',
583
+ description: 'Experimental and disabled by default. Automatically detect the target issue or pull request language and set the AI work language to English or Russian when one language has more than 51% of all words. Explicit --work-language or --prompt-language takes precedence.',
584
+ default: false,
585
+ },
575
586
  };
576
587
 
588
+ function hasRawOption(rawArgs, optionName) {
589
+ return rawArgs.some(arg => arg === optionName || arg.startsWith(`${optionName}=`));
590
+ }
591
+
577
592
  // Function to create yargs configuration - avoids duplication
578
593
  export const createYargsConfig = yargsInstance => {
579
594
  let config = yargsInstance
@@ -749,6 +764,12 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
749
764
  if (argv.disableIssueAutoCreationOnError) {
750
765
  argv.disableReportIssue = true;
751
766
  }
767
+ const workLanguageExplicit = hasRawOption(rawArgs, '--work-language');
768
+ const promptLanguageExplicit = hasRawOption(rawArgs, '--prompt-language');
769
+ if (argv.promptLanguage && !workLanguageExplicit) {
770
+ argv.workLanguage = argv.promptLanguage;
771
+ }
772
+ argv._workLanguageExplicit = workLanguageExplicit || promptLanguageExplicit;
752
773
  }
753
774
 
754
775
  // --finalize normalization
@@ -175,7 +175,10 @@ export const createUnhandledRejectionHandler = options => {
175
175
  /**
176
176
  * Handles the case where no PR is available when one is required
177
177
  */
178
- export const handleNoPrAvailableError = async ({ isContinueMode, tempDir, issueNumber, issueUrl, log, formatAligned }) => {
178
+ export const handleNoPrAvailableError = async ({ isContinueMode, tempDir, issueNumber, issueUrl, owner, repo, log, formatAligned }) => {
179
+ // Issue #1774: when an explicit target repo is known, surface --repo in the
180
+ // recovery hint so users do not hit the same fork-base resolution trap.
181
+ const repoFlag = owner && repo ? ` --repo ${owner}/${repo}` : '';
179
182
  await log('');
180
183
  await log(formatAligned('❌', 'FATAL ERROR:', 'No pull request available'), { level: 'error' });
181
184
  await log('');
@@ -199,7 +202,7 @@ export const handleNoPrAvailableError = async ({ isContinueMode, tempDir, issueN
199
202
  await log('');
200
203
  await log(' Option 1: Create PR manually and use --continue');
201
204
  await log(` cd ${tempDir}`);
202
- await log(` gh pr create --draft --title "Fix issue #${issueNumber}" --body "Fixes #${issueNumber}"`);
205
+ await log(` gh pr create --draft --title "Fix issue #${issueNumber}" --body "Fixes #${issueNumber}"${repoFlag}`);
203
206
  await log(' # Then use the PR URL with solve.mjs');
204
207
  await log('');
205
208
  await log(' Option 2: Start fresh without continue mode');
package/src/solve.mjs CHANGED
@@ -84,16 +84,6 @@ try {
84
84
  }
85
85
  global.verboseMode = argv.verbose;
86
86
 
87
- // Initialize i18n based on --language / --ui-language / --work-language
88
- // (or detected system locale). --language sets both tracks by default;
89
- // --ui-language and --work-language can override each track independently.
90
- const { initI18n } = await import('./i18n.lib.mjs');
91
- await initI18n({
92
- language: argv.language,
93
- uiLanguage: argv.uiLanguage,
94
- workLanguage: argv.workLanguage,
95
- });
96
-
97
87
  setupVerboseLogInterceptor(); // Issue #1466: capture [VERBOSE] output in log files
98
88
  setupStdioLogInterceptor(); // Issue #1549: capture ALL terminal output in log file
99
89
 
@@ -180,6 +170,18 @@ if (isIssueUrl) {
180
170
  }
181
171
  cleanupContext.owner = owner;
182
172
  cleanupContext.repo = repo;
173
+ if (argv.autoLanguage) {
174
+ const { applyAutoLanguageToArgv } = await import('./auto-language.lib.mjs');
175
+ await applyAutoLanguageToArgv({ argv, githubLib, owner, repo, number: urlNumber, isIssueUrl, isPrUrl, log });
176
+ }
177
+ // Initialize i18n based on --language / --ui-language / --work-language
178
+ // (or detected system locale). --auto-language may set only the work track.
179
+ const { initI18n } = await import('./i18n.lib.mjs');
180
+ await initI18n({
181
+ language: argv.language,
182
+ uiLanguage: argv.uiLanguage,
183
+ workLanguage: argv.workLanguage,
184
+ });
183
185
  // Setup unhandled error handlers to ensure log path is always shown
184
186
  const errorHandlerOptions = {
185
187
  log,
@@ -576,7 +578,7 @@ try {
576
578
  // CRITICAL: Validate that we have a PR number when required
577
579
  // This prevents continuing without a PR when one was supposed to be created
578
580
  if ((isContinueMode || argv.autoPullRequestCreation) && !prNumber) {
579
- await handleNoPrAvailableError({ isContinueMode, tempDir, issueNumber, issueUrl, log, formatAligned });
581
+ await handleNoPrAvailableError({ isContinueMode, tempDir, issueNumber, issueUrl, owner, repo, log, formatAligned });
580
582
  }
581
583
 
582
584
  if (isContinueMode) {