@link-assistant/hive-mind 1.59.2 → 1.59.3

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 CHANGED
@@ -1,5 +1,54 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.59.3
4
+
5
+ ### Patch Changes
6
+
7
+ - b0bffdc: Fix `solve` to skip fork mode when the upstream repository is private and the
8
+ user has direct write access — even when the existing PR was created from a
9
+ fork (issue #1716).
10
+
11
+ Previously, when a PR was originally created from a fork (e.g. the upstream
12
+ repo was public and the user without write access used `--auto-fork`), but
13
+ the upstream is now private and the user has direct write access, `solve`
14
+ still tried to clone the fork. If the fork had been renamed, deleted, or was
15
+ otherwise inaccessible (which is common after a public→private flip), repo
16
+ setup failed with `Fork not accessible`.
17
+
18
+ The auto-fork path already handled this correctly (logging
19
+ _"Auto-fork: Write access detected to private repository, working directly on
20
+ repository"_ and leaving `forkOwner = null`). The bug was that **continue
21
+ mode** — both the auto-continue path and the direct PR-URL path — re-set
22
+ `forkOwner` from the existing PR's head repository unconditionally,
23
+ overriding the auto-fork bypass.
24
+
25
+ Fix: in [`src/solve.mjs`](./src/solve.mjs):
26
+ - Hoist `detectRepositoryVisibility(owner, repo)` out of the
27
+ `if (argv.autoCleanup === undefined)` block so `isRepoPublic` is
28
+ unconditionally available.
29
+ - Compute one bypass flag,
30
+ `skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess`.
31
+ - Gate both fork-from-PR-data branches behind it. When set, log
32
+ _"Issue #1716: Working directly on the private upstream repository"_ and
33
+ leave `forkOwner = null` so the regular non-fork code path runs.
34
+ - Gate the maintainer-modify auto-toggle on `forkOwner` being non-null so it
35
+ doesn't fire when the bypass triggered.
36
+
37
+ Explicit `--fork` still wins (the bypass requires `!argv.fork`), and users
38
+ with no write access on a private repo still hit the existing auto-fork
39
+ private-repo guard (the bypass requires `hasWriteAccess`).
40
+
41
+ Tests: [`tests/test-issue-1716-private-repo-skip-fork.mjs`](./tests/test-issue-1716-private-repo-skip-fork.mjs)
42
+ locks the flag declaration, the exact condition formula, both
43
+ fork-detection paths, and four scenario simulations
44
+ (private+writeAccess → bypass; public → no bypass; explicit `--fork` → no
45
+ bypass; no writeAccess → no bypass).
46
+
47
+ Documentation: [`docs/case-studies/issue-1716/`](./docs/case-studies/issue-1716/README.md)
48
+ contains the timeline reconstructed from the user's failure log, the
49
+ distilled facts, the per-symptom root-cause analysis, and the implementation
50
+ plan.
51
+
3
52
  ## 1.59.2
4
53
 
5
54
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.59.2",
3
+ "version": "1.59.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fork-detection helpers for solve.mjs
5
+ *
6
+ * Extracted from solve.mjs to keep the file under the 1500-line CI limit.
7
+ * - handleAutoForkOption: implements the --auto-fork detection branch.
8
+ * - handleMaintainerForkAccess: handles the
9
+ * --allow-to-push-to-contributors-pull-requests-as-maintainer follow-up
10
+ * that runs after a fork PR has been detected.
11
+ *
12
+ * Tests for Issue #1716 grep solve.mjs textually, so the *call sites* there
13
+ * still hold the canonical condition checks; only the bodies live here.
14
+ */
15
+
16
+ if (typeof globalThis.use === 'undefined') {
17
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
18
+ }
19
+ const use = globalThis.use;
20
+ const { $ } = await use('command-stream');
21
+
22
+ const lib = await import('./lib.mjs');
23
+ const { log, ghCmdRetry } = lib;
24
+ const githubLib = await import('./github.lib.mjs');
25
+
26
+ /**
27
+ * Handle the --auto-fork option: when the user lacks write access to a public
28
+ * repository, automatically enable fork mode; when the repository is private,
29
+ * fail with an actionable error.
30
+ *
31
+ * Mutates argv.fork in place when fork mode is enabled.
32
+ *
33
+ * @param {object} params
34
+ * @param {string} params.owner
35
+ * @param {string} params.repo
36
+ * @param {object} params.argv - CLI arguments (mutated: argv.fork may be set)
37
+ * @param {(code: number, reason?: string) => Promise<void>} params.safeExit
38
+ */
39
+ export async function handleAutoForkOption({ owner, repo, argv, safeExit }) {
40
+ if (!argv.autoFork || argv.fork) return;
41
+
42
+ const { detectRepositoryVisibility } = githubLib;
43
+ await log('🔍 Checking repository access for auto-fork...');
44
+ const permResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
45
+
46
+ if (permResult.code === 0) {
47
+ const permissions = JSON.parse(permResult.stdout.toString().trim());
48
+ const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
49
+
50
+ if (!hasWriteAccess) {
51
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
52
+
53
+ if (!isPublic) {
54
+ await log('');
55
+ await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
56
+ await log('');
57
+ await log(' 🔍 What happened:', { level: 'error' });
58
+ await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
59
+ await log(" You don't have write access to this repository", { level: 'error' });
60
+ await log(' --auto-fork cannot create a fork of a private repository you cannot access', { level: 'error' });
61
+ await log('');
62
+ await log(' 💡 Solution:', { level: 'error' });
63
+ await log(' • Request collaborator access from the repository owner', { level: 'error' });
64
+ await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
65
+ await log('');
66
+ await safeExit(1, 'Auto-fork failed - private repository without access');
67
+ return;
68
+ }
69
+
70
+ await log('✅ Auto-fork: No write access detected, enabling fork mode');
71
+ argv.fork = true;
72
+ } else {
73
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
74
+ await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
75
+ }
76
+ } else {
77
+ const { isPublic } = await detectRepositoryVisibility(owner, repo);
78
+
79
+ if (!isPublic) {
80
+ await log('');
81
+ await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
82
+ await log('');
83
+ await log(' 🔍 What happened:', { level: 'error' });
84
+ await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
85
+ await log(' Could not check your permissions to this repository', { level: 'error' });
86
+ await log('');
87
+ await log(' 💡 Solutions:', { level: 'error' });
88
+ await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
89
+ await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
90
+ await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
91
+ await log('');
92
+ await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
93
+ return;
94
+ }
95
+
96
+ await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
97
+ argv.fork = true;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * After a fork PR is detected, optionally check whether the maintainer can
103
+ * push directly to the contributor's fork. If not, request access.
104
+ *
105
+ * @param {object} params
106
+ * @param {string} params.owner
107
+ * @param {string} params.repo
108
+ * @param {string|number} params.prNumber
109
+ */
110
+ export async function handleMaintainerForkAccess({ owner, repo, prNumber }) {
111
+ const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
112
+ const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
113
+
114
+ if (canModify) {
115
+ await log('✅ Maintainer can push to fork: Enabled by contributor');
116
+ await log(" Will push changes directly to contributor's fork instead of creating own fork");
117
+ return;
118
+ }
119
+
120
+ await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', { level: 'warning' });
121
+ await log(' Posting comment to request access...', { level: 'warning' });
122
+ await requestMaintainerAccess(owner, repo, prNumber);
123
+ await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
124
+ }
125
+
126
+ export default { handleAutoForkOption, handleMaintainerForkAccess };
package/src/solve.mjs CHANGED
@@ -58,6 +58,7 @@ const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-
58
58
  const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
59
59
  const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
60
60
  const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
61
+ const { handleAutoForkOption, handleMaintainerForkAccess } = await import('./solve.fork-detection.lib.mjs');
61
62
  // Initialize log file early (before argument parsing) to capture all output
62
63
  const logFile = await initializeLogFile(null);
63
64
  // Log version and raw command IMMEDIATELY after log file initialization
@@ -209,73 +210,7 @@ if (argv.autoAcceptInvite) {
209
210
  await autoAcceptInviteForRepo(owner, repo, log, argv.verbose);
210
211
  }
211
212
  // Handle --auto-fork option: automatically fork public repositories without write access
212
- if (argv.autoFork && !argv.fork) {
213
- const { detectRepositoryVisibility } = githubLib;
214
- // Check if we have write access first (issue #1536: retry on transient network errors)
215
- await log('🔍 Checking repository access for auto-fork...');
216
- const permResult = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
217
-
218
- if (permResult.code === 0) {
219
- const permissions = JSON.parse(permResult.stdout.toString().trim());
220
- const hasWriteAccess = permissions.push === true || permissions.admin === true || permissions.maintain === true;
221
-
222
- if (!hasWriteAccess) {
223
- // No write access - check if repository is public before enabling fork mode
224
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
225
-
226
- if (!isPublic) {
227
- // Private repository without write access - cannot fork
228
- await log('');
229
- await log("❌ --auto-fork failed: Repository is private and you don't have write access", { level: 'error' });
230
- await log('');
231
- await log(' 🔍 What happened:', { level: 'error' });
232
- await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
233
- await log(" You don't have write access to this repository", { level: 'error' });
234
- await log(' --auto-fork cannot create a fork of a private repository you cannot access', {
235
- level: 'error',
236
- });
237
- await log('');
238
- await log(' 💡 Solution:', { level: 'error' });
239
- await log(' • Request collaborator access from the repository owner', { level: 'error' });
240
- await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
241
- await log('');
242
- await safeExit(1, 'Auto-fork failed - private repository without access');
243
- }
244
-
245
- // Public repository without write access - automatically enable fork mode
246
- await log('✅ Auto-fork: No write access detected, enabling fork mode');
247
- argv.fork = true;
248
- } else {
249
- // Has write access - work directly on the repo (works for both public and private repos)
250
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
251
- await log(`✅ Auto-fork: Write access detected to ${isPublic ? 'public' : 'private'} repository, working directly on repository`);
252
- }
253
- } else {
254
- // Could not check permissions - assume no access and try to fork if public
255
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
256
-
257
- if (!isPublic) {
258
- // Cannot determine permissions for private repo - fail safely
259
- await log('');
260
- await log('❌ --auto-fork failed: Could not verify permissions for private repository', { level: 'error' });
261
- await log('');
262
- await log(' 🔍 What happened:', { level: 'error' });
263
- await log(` Repository ${owner}/${repo} is private`, { level: 'error' });
264
- await log(' Could not check your permissions to this repository', { level: 'error' });
265
- await log('');
266
- await log(' 💡 Solutions:', { level: 'error' });
267
- await log(' • Check your GitHub CLI authentication: gh auth status', { level: 'error' });
268
- await log(" • Request collaborator access if you don't have it yet", { level: 'error' });
269
- await log(` https://github.com/${owner}/${repo}/settings/access`, { level: 'error' });
270
- await log('');
271
- await safeExit(1, 'Auto-fork failed - cannot verify private repository permissions');
272
- }
273
-
274
- // Public repository but couldn't check permissions - assume no access and fork
275
- await log('⚠️ Auto-fork: Could not check permissions, enabling fork mode for public repository');
276
- argv.fork = true;
277
- }
278
- }
213
+ await handleAutoForkOption({ owner, repo, argv, safeExit });
279
214
  // Permission check BEFORE entity validation (#1552): avoids false 404 on private repos without access
280
215
  const { checkRepositoryWritePermission } = githubLib;
281
216
  const hasWriteAccess = await checkRepositoryWritePermission(owner, repo, {
@@ -296,19 +231,26 @@ if (!entityCheck.valid) {
296
231
  await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
297
232
  }
298
233
 
299
- // Detect repository visibility and set auto-cleanup default if not explicitly set
234
+ // Detect repository visibility once and reuse for downstream decisions
235
+ // (auto-cleanup default + Issue #1716 private-repo fork bypass)
236
+ const { detectRepositoryVisibility } = githubLib;
237
+ const { isPublic: isRepoPublic } = await detectRepositoryVisibility(owner, repo);
300
238
  if (argv.autoCleanup === undefined) {
301
- const { detectRepositoryVisibility } = githubLib;
302
- const { isPublic } = await detectRepositoryVisibility(owner, repo);
303
239
  // For public repos: keep temp directories (default false)
304
240
  // For private repos: clean up temp directories (default true)
305
- argv.autoCleanup = !isPublic;
241
+ argv.autoCleanup = !isRepoPublic;
306
242
  if (argv.verbose) {
307
- await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isPublic ? 'public' : 'private'})`, {
243
+ await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isRepoPublic ? 'public' : 'private'})`, {
308
244
  verbose: true,
309
245
  });
310
246
  }
311
247
  }
248
+ // Issue #1716: When the upstream repository is private and the user has direct
249
+ // write access, fork-based workflows should be skipped — even if the existing
250
+ // PR was originally created from a fork. Forks of private repositories often
251
+ // become inaccessible (renamed, deleted, parent re-private'd) and there's no
252
+ // reason to use them when we can push branches and PRs to the upstream repo.
253
+ const skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess;
312
254
  // Determine mode and get issue details
313
255
  let issueNumber;
314
256
  let prNumber;
@@ -345,32 +287,26 @@ if (autoContinueResult.isContinueMode) {
345
287
  await log(` Merge status: ${mergeStateStatus || 'UNKNOWN'}`, { verbose: true });
346
288
  }
347
289
  if (prCheckData.headRepositoryOwner && prCheckData.headRepositoryOwner.login !== owner) {
348
- forkOwner = prCheckData.headRepositoryOwner.login;
349
- // Get actual fork repository name (may be prefixed) and store for use in setupRepository
350
- forkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
351
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
352
- if (argv.verbose) {
353
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
354
- await log(' Will clone fork repository for continue mode', { verbose: true });
290
+ const detectedForkOwner = prCheckData.headRepositoryOwner.login;
291
+ const detectedForkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
292
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
293
+ if (skipForkForPrivateUpstream) {
294
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
295
+ await log(' Working directly on the private upstream repository (Issue #1716).');
296
+ } else {
297
+ forkOwner = detectedForkOwner;
298
+ // Get actual fork repository name (may be prefixed) and store for use in setupRepository
299
+ forkRepoName = detectedForkRepoName;
300
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
301
+ if (argv.verbose) {
302
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
303
+ await log(' Will clone fork repository for continue mode', { verbose: true });
304
+ }
355
305
  }
356
306
 
357
307
  // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
358
- if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
359
- const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
360
- const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
361
-
362
- if (canModify) {
363
- await log('✅ Maintainer can push to fork: Enabled by contributor');
364
- await log(" Will push changes directly to contributor's fork instead of creating own fork");
365
- // Don't disable fork mode, but we'll use the contributor's fork
366
- } else {
367
- await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
368
- level: 'warning',
369
- });
370
- await log(' Posting comment to request access...', { level: 'warning' });
371
- await requestMaintainerAccess(owner, repo, prNumber);
372
- await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
373
- }
308
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
309
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
374
310
  }
375
311
  }
376
312
  }
@@ -425,32 +361,26 @@ if (isPrUrl) {
425
361
  prState = prData.state;
426
362
  // Check if this is a fork PR
427
363
  if (prData.headRepositoryOwner && prData.headRepositoryOwner.login !== owner) {
428
- forkOwner = prData.headRepositoryOwner.login;
429
- // Get actual fork repository name and store for use in setupRepository
430
- forkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
431
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
432
- if (argv.verbose) {
433
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
434
- await log(' Will clone fork repository for continue mode', { verbose: true });
364
+ const detectedForkOwner = prData.headRepositoryOwner.login;
365
+ const detectedForkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
366
+ // Issue #1716: Skip fork mode for private upstream repos with write access.
367
+ if (skipForkForPrivateUpstream) {
368
+ await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
369
+ await log(' Working directly on the private upstream repository (Issue #1716).');
370
+ } else {
371
+ forkOwner = detectedForkOwner;
372
+ // Get actual fork repository name and store for use in setupRepository
373
+ forkRepoName = detectedForkRepoName;
374
+ await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
375
+ if (argv.verbose) {
376
+ await log(` Fork owner: ${forkOwner}`, { verbose: true });
377
+ await log(' Will clone fork repository for continue mode', { verbose: true });
378
+ }
435
379
  }
436
380
 
437
381
  // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
438
- if (argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
439
- const { checkMaintainerCanModifyPR, requestMaintainerAccess } = githubLib;
440
- const { canModify } = await checkMaintainerCanModifyPR(owner, repo, prNumber);
441
-
442
- if (canModify) {
443
- await log('✅ Maintainer can push to fork: Enabled by contributor');
444
- await log(" Will push changes directly to contributor's fork instead of creating own fork");
445
- // Don't disable fork mode, but we'll use the contributor's fork
446
- } else {
447
- await log('⚠️ Maintainer cannot push to fork: "Allow edits by maintainers" is not enabled', {
448
- level: 'warning',
449
- });
450
- await log(' Posting comment to request access...', { level: 'warning' });
451
- await requestMaintainerAccess(owner, repo, prNumber);
452
- await log(' Comment posted. Proceeding with own fork instead.', { level: 'warning' });
453
- }
382
+ if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
383
+ await handleMaintainerForkAccess({ owner, repo, prNumber });
454
384
  }
455
385
  }
456
386
  await log(`📝 PR branch: ${prBranch}`);