@link-assistant/hive-mind 2.13.4 → 2.13.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,176 @@
1
+ /**
2
+ * Post-push GitHub synchronization for auto-PR creation.
3
+ *
4
+ * Extracted from src/solve.auto-pr.lib.mjs (issue #2175) so that file stays
5
+ * under the 1350-line early-warning threshold that protects concurrent merges
6
+ * (#1593). Behaviour is unchanged; the collaborators that were closure bindings
7
+ * are now parameters.
8
+ *
9
+ * A push is accepted by git receive long before GitHub's compare/PR API can see
10
+ * the new commits, so `gh pr create` run immediately after a push fails with
11
+ * "No commits between branches". Both helpers here close that gap: the first
12
+ * polls the compare API until it reports commits ahead, the second confirms the
13
+ * branch itself is visible and re-pushes it (never forced) when it is not.
14
+ */
15
+
16
+ /**
17
+ * Poll GitHub's compare API until it reports commits ahead of the base branch.
18
+ *
19
+ * @param {object} deps
20
+ * @returns {Promise<{compareReady: boolean, targetBranchForCompare: string}>}
21
+ */
22
+ export async function waitForCompareApiReady({ argv, defaultBranch, branchName, forkedRepo, owner, repo, issueNumber, log, formatAligned, $, isTransientCompareApiError, handleCompareApiNotReady }) {
23
+ // CRITICAL: Wait for GitHub to process the push before creating PR
24
+ // This prevents "No commits between branches" error
25
+ await log(' Waiting for GitHub to sync...');
26
+
27
+ // Use exponential backoff to wait for GitHub's compare API to see the commits
28
+ // This is essential because GitHub has multiple backend systems:
29
+ // - Git receive: Accepts push immediately
30
+ // - Branch API: Returns quickly from cache
31
+ // - Compare/PR API: May take longer to index commits
32
+ let compareReady = false;
33
+ let compareAttempts = 0;
34
+ const maxCompareAttempts = 5;
35
+ const targetBranchForCompare = argv.baseBranch || defaultBranch;
36
+ let compareResult; // Declare outside loop so it's accessible for error checking
37
+
38
+ while (!compareReady && compareAttempts < maxCompareAttempts) {
39
+ compareAttempts++;
40
+ const waitTime = Math.min(2000 * compareAttempts, 10000); // 2s, 4s, 6s, 8s, 10s
41
+
42
+ if (compareAttempts > 1) {
43
+ await log(` Retry ${compareAttempts}/${maxCompareAttempts}: Waiting ${waitTime}ms for GitHub to index commits...`);
44
+ }
45
+
46
+ await new Promise(resolve => setTimeout(resolve, waitTime));
47
+
48
+ // Check if GitHub's compare API can see commits between base and head
49
+ // This is the SAME API that gh pr create uses internally, so if this works,
50
+ // PR creation should work too
51
+ // For fork mode, we need to use forkUser:branchName format for the head
52
+ let headRef;
53
+ if (argv.fork && forkedRepo) {
54
+ const forkUser = forkedRepo.split('/')[0];
55
+ headRef = `${forkUser}:${branchName}`;
56
+ } else {
57
+ headRef = branchName;
58
+ }
59
+ compareResult = await $({
60
+ silent: true,
61
+ })`gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${headRef} --paginate --jq '.ahead_by' 2>&1`;
62
+
63
+ if (compareResult.code === 0) {
64
+ const aheadBy = parseInt(compareResult.stdout.toString().trim(), 10);
65
+ if (argv.verbose) {
66
+ await log(` Compare API check: ${aheadBy} commit(s) ahead of ${targetBranchForCompare}`);
67
+ }
68
+
69
+ if (aheadBy > 0) {
70
+ compareReady = true;
71
+ await log(` GitHub compare API ready: ${aheadBy} commit(s) found`);
72
+ } else {
73
+ await log(` ⚠️ GitHub compare API shows 0 commits ahead (attempt ${compareAttempts}/${maxCompareAttempts})`, { level: 'warning' });
74
+ }
75
+ } else {
76
+ // Issue #1829: surface compare-API failures in normal output (not
77
+ // only verbose) so the degraded-mode decision below is explainable
78
+ // from the logs. Build the text as a STRING — the command-stream
79
+ // result exposes stdout/stderr as Buffers, and the transient
80
+ // detectors call String.prototype.toLowerCase().
81
+ const errorText = `${compareResult.stdout?.toString?.() ?? ''}${compareResult.stderr?.toString?.() ?? ''}`.trim();
82
+ const firstLine =
83
+ errorText
84
+ .split('\n')
85
+ .map(s => s.trim())
86
+ .filter(Boolean)[0] || 'unknown';
87
+ const transientNote = isTransientCompareApiError(errorText) ? ' (transient server error)' : '';
88
+ await log(` ⚠️ GitHub compare API error${transientNote} (attempt ${compareAttempts}/${maxCompareAttempts}): ${firstLine}`, { level: 'warning' });
89
+ if (argv.verbose && errorText) {
90
+ await log(` Compare API full output: ${errorText}`, { verbose: true });
91
+ }
92
+ }
93
+ }
94
+
95
+ if (!compareReady) {
96
+ compareReady = await handleCompareApiNotReady({
97
+ argv,
98
+ forkedRepo,
99
+ owner,
100
+ repo,
101
+ issueNumber,
102
+ branchName,
103
+ targetBranchForCompare,
104
+ maxCompareAttempts,
105
+ compareResult,
106
+ log,
107
+ formatAligned,
108
+ $,
109
+ });
110
+ }
111
+
112
+ return { compareReady, targetBranchForCompare };
113
+ }
114
+
115
+ /**
116
+ * Confirm the pushed branch is visible on GitHub, re-pushing it (never forced)
117
+ * when it is not.
118
+ *
119
+ * @param {object} deps
120
+ * @returns {Promise<void>}
121
+ */
122
+ export async function verifyBranchOnGitHub({ argv, tempDir, branchName, forkedRepo, owner, repo, log, $ }) {
123
+ // Verify the push actually worked by checking GitHub API
124
+ // When using fork mode, check the fork repository; otherwise check the original repository
125
+ const repoToCheck = argv.fork && forkedRepo ? forkedRepo : `${owner}/${repo}`;
126
+ const branchCheckResult = await $({
127
+ silent: true,
128
+ })`gh api repos/${repoToCheck}/branches/${branchName} --jq .name 2>&1`;
129
+ if (branchCheckResult.code === 0 && branchCheckResult.stdout.toString().trim() === branchName) {
130
+ await log(` Branch verified on GitHub: ${branchName}`);
131
+
132
+ // Get the commit SHA from GitHub
133
+ const shaCheckResult = await $({
134
+ silent: true,
135
+ })`gh api repos/${repoToCheck}/branches/${branchName} --jq .commit.sha 2>&1`;
136
+ if (shaCheckResult.code === 0) {
137
+ const remoteSha = shaCheckResult.stdout.toString().trim();
138
+ await log(` Remote commit SHA: ${remoteSha.substring(0, 7)}...`);
139
+ }
140
+ } else {
141
+ await log(' Warning: Branch not found on GitHub!');
142
+ await log(' This will cause PR creation to fail.');
143
+
144
+ if (argv.verbose) {
145
+ await log(` Branch check result: ${branchCheckResult.stdout || branchCheckResult.stderr || 'empty'}`);
146
+
147
+ // Show all branches on GitHub
148
+ const allBranchesResult = await $({
149
+ silent: true,
150
+ })`gh api repos/${repoToCheck}/branches --paginate --jq '.[].name' 2>&1`;
151
+ if (allBranchesResult.code === 0) {
152
+ await log(` All GitHub branches: ${allBranchesResult.stdout.toString().split('\n').slice(0, 5).join(', ')}...`);
153
+ }
154
+ }
155
+
156
+ // Try one more push with explicit ref (without force)
157
+ await log(' Attempting explicit push...');
158
+ const explicitPushCmd = `git push origin HEAD:refs/heads/${branchName}`;
159
+ if (argv.verbose) {
160
+ await log(` Command: ${explicitPushCmd}`);
161
+ }
162
+ const explicitPushResult = await $`cd ${tempDir} && ${explicitPushCmd} 2>&1`;
163
+ if (explicitPushResult.code === 0) {
164
+ await log(' Explicit push completed');
165
+ if (argv.verbose && explicitPushResult.stdout) {
166
+ await log(` Output: ${explicitPushResult.stdout.toString().trim()}`);
167
+ }
168
+ // Wait a bit more for GitHub to process
169
+ await new Promise(resolve => setTimeout(resolve, 3000));
170
+ } else {
171
+ await log(' ERROR: Cannot push to GitHub!');
172
+ await log(` Error: ${explicitPushResult.stderr || explicitPushResult.stdout || 'Unknown'}`);
173
+ await log(' Force push is not allowed to preserve history');
174
+ }
175
+ }
176
+ }
@@ -8,11 +8,13 @@ import { classifyIssueLinkStatus, buildNonDefaultBranchExplanation } from './git
8
8
  import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
9
9
  import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
10
10
  import { handleCompareApiNotReady } from './solve.auto-pr-compare-readiness.lib.mjs'; // Issue #1829: decides whether a failed compare-API readiness poll is fatal (fork mismatch / 0 commits) or a transient diff-render failure to degrade past.
11
+ import { waitForCompareApiReady, verifyBranchOnGitHub } from './solve.auto-pr-push-sync.lib.mjs'; // Issue #2175: extracted to keep this file under the 1350-line warning threshold.
11
12
 
12
13
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry, isTransientCompareApiError } 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. Issue #1829: isTransientCompareApiError lets the compare-API readiness gate degrade gracefully on transient diff-render failures.
13
14
  import { quietProbe } from './quiet-probe.lib.mjs'; // issue #2130: keep read-only probe payloads out of the attached log
14
15
  import { stagePlaceholderFileOrExplain, explainNothingStagedAndThrow } from './solve.auto-pr-placeholder.lib.mjs'; // Issue #1825: handles the seed placeholder when the target repo gitignores it.
15
16
  import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
17
+ import { isPullRequestAlreadyExistsError, findExistingPullRequestUrl } from './github-pr-idempotency.lib.mjs'; // Issue #2168: a retried `gh pr create` must not fail because the first (5xx'd) attempt already created the PR.
16
18
 
17
19
  export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
18
20
  // Skip auto-PR creation if:
@@ -547,148 +549,11 @@ Proceed.
547
549
  await log(` Push output: ${pushResult.stdout.toString().trim()}`, { verbose: true });
548
550
  }
549
551
 
550
- // CRITICAL: Wait for GitHub to process the push before creating PR
551
- // This prevents "No commits between branches" error
552
- await log(' Waiting for GitHub to sync...');
553
-
554
- // Use exponential backoff to wait for GitHub's compare API to see the commits
555
- // This is essential because GitHub has multiple backend systems:
556
- // - Git receive: Accepts push immediately
557
- // - Branch API: Returns quickly from cache
558
- // - Compare/PR API: May take longer to index commits
559
- let compareReady = false;
560
- let compareAttempts = 0;
561
- const maxCompareAttempts = 5;
562
- const targetBranchForCompare = argv.baseBranch || defaultBranch;
563
- let compareResult; // Declare outside loop so it's accessible for error checking
564
-
565
- while (!compareReady && compareAttempts < maxCompareAttempts) {
566
- compareAttempts++;
567
- const waitTime = Math.min(2000 * compareAttempts, 10000); // 2s, 4s, 6s, 8s, 10s
568
-
569
- if (compareAttempts > 1) {
570
- await log(` Retry ${compareAttempts}/${maxCompareAttempts}: Waiting ${waitTime}ms for GitHub to index commits...`);
571
- }
572
-
573
- await new Promise(resolve => setTimeout(resolve, waitTime));
574
-
575
- // Check if GitHub's compare API can see commits between base and head
576
- // This is the SAME API that gh pr create uses internally, so if this works,
577
- // PR creation should work too
578
- // For fork mode, we need to use forkUser:branchName format for the head
579
- let headRef;
580
- if (argv.fork && forkedRepo) {
581
- const forkUser = forkedRepo.split('/')[0];
582
- headRef = `${forkUser}:${branchName}`;
583
- } else {
584
- headRef = branchName;
585
- }
586
- compareResult = await $({
587
- silent: true,
588
- })`gh api repos/${owner}/${repo}/compare/${targetBranchForCompare}...${headRef} --paginate --jq '.ahead_by' 2>&1`;
589
-
590
- if (compareResult.code === 0) {
591
- const aheadBy = parseInt(compareResult.stdout.toString().trim(), 10);
592
- if (argv.verbose) {
593
- await log(` Compare API check: ${aheadBy} commit(s) ahead of ${targetBranchForCompare}`);
594
- }
595
-
596
- if (aheadBy > 0) {
597
- compareReady = true;
598
- await log(` GitHub compare API ready: ${aheadBy} commit(s) found`);
599
- } else {
600
- await log(` ⚠️ GitHub compare API shows 0 commits ahead (attempt ${compareAttempts}/${maxCompareAttempts})`, { level: 'warning' });
601
- }
602
- } else {
603
- // Issue #1829: surface compare-API failures in normal output (not
604
- // only verbose) so the degraded-mode decision below is explainable
605
- // from the logs. Build the text as a STRING — the command-stream
606
- // result exposes stdout/stderr as Buffers, and the transient
607
- // detectors call String.prototype.toLowerCase().
608
- const errorText = `${compareResult.stdout?.toString?.() ?? ''}${compareResult.stderr?.toString?.() ?? ''}`.trim();
609
- const firstLine =
610
- errorText
611
- .split('\n')
612
- .map(s => s.trim())
613
- .filter(Boolean)[0] || 'unknown';
614
- const transientNote = isTransientCompareApiError(errorText) ? ' (transient server error)' : '';
615
- await log(` ⚠️ GitHub compare API error${transientNote} (attempt ${compareAttempts}/${maxCompareAttempts}): ${firstLine}`, { level: 'warning' });
616
- if (argv.verbose && errorText) {
617
- await log(` Compare API full output: ${errorText}`, { verbose: true });
618
- }
619
- }
620
- }
621
-
622
- if (!compareReady) {
623
- compareReady = await handleCompareApiNotReady({
624
- argv,
625
- forkedRepo,
626
- owner,
627
- repo,
628
- issueNumber,
629
- branchName,
630
- targetBranchForCompare,
631
- maxCompareAttempts,
632
- compareResult,
633
- log,
634
- formatAligned,
635
- $,
636
- });
637
- }
638
-
639
- // Verify the push actually worked by checking GitHub API
640
- // When using fork mode, check the fork repository; otherwise check the original repository
641
- const repoToCheck = argv.fork && forkedRepo ? forkedRepo : `${owner}/${repo}`;
642
- const branchCheckResult = await $({
643
- silent: true,
644
- })`gh api repos/${repoToCheck}/branches/${branchName} --jq .name 2>&1`;
645
- if (branchCheckResult.code === 0 && branchCheckResult.stdout.toString().trim() === branchName) {
646
- await log(` Branch verified on GitHub: ${branchName}`);
647
-
648
- // Get the commit SHA from GitHub
649
- const shaCheckResult = await $({
650
- silent: true,
651
- })`gh api repos/${repoToCheck}/branches/${branchName} --jq .commit.sha 2>&1`;
652
- if (shaCheckResult.code === 0) {
653
- const remoteSha = shaCheckResult.stdout.toString().trim();
654
- await log(` Remote commit SHA: ${remoteSha.substring(0, 7)}...`);
655
- }
656
- } else {
657
- await log(' Warning: Branch not found on GitHub!');
658
- await log(' This will cause PR creation to fail.');
659
-
660
- if (argv.verbose) {
661
- await log(` Branch check result: ${branchCheckResult.stdout || branchCheckResult.stderr || 'empty'}`);
662
-
663
- // Show all branches on GitHub
664
- const allBranchesResult = await $({
665
- silent: true,
666
- })`gh api repos/${repoToCheck}/branches --paginate --jq '.[].name' 2>&1`;
667
- if (allBranchesResult.code === 0) {
668
- await log(` All GitHub branches: ${allBranchesResult.stdout.toString().split('\n').slice(0, 5).join(', ')}...`);
669
- }
670
- }
671
-
672
- // Try one more push with explicit ref (without force)
673
- await log(' Attempting explicit push...');
674
- const explicitPushCmd = `git push origin HEAD:refs/heads/${branchName}`;
675
- if (argv.verbose) {
676
- await log(` Command: ${explicitPushCmd}`);
677
- }
678
- const explicitPushResult = await $`cd ${tempDir} && ${explicitPushCmd} 2>&1`;
679
- if (explicitPushResult.code === 0) {
680
- await log(' Explicit push completed');
681
- if (argv.verbose && explicitPushResult.stdout) {
682
- await log(` Output: ${explicitPushResult.stdout.toString().trim()}`);
683
- }
684
- // Wait a bit more for GitHub to process
685
- await new Promise(resolve => setTimeout(resolve, 3000));
686
- } else {
687
- await log(' ERROR: Cannot push to GitHub!');
688
- await log(` Error: ${explicitPushResult.stderr || explicitPushResult.stdout || 'Unknown'}`);
689
- await log(' Force push is not allowed to preserve history');
690
- }
691
- }
552
+ // Issue #2175: the post-push GitHub synchronization (compare-API readiness
553
+ // poll + branch visibility check) lives in solve.auto-pr-push-sync.lib.mjs
554
+ // to keep this file under the 1350-line warning threshold.
555
+ await waitForCompareApiReady({ argv, defaultBranch, branchName, forkedRepo, owner, repo, issueNumber, log, formatAligned, $, isTransientCompareApiError, handleCompareApiNotReady });
556
+ await verifyBranchOnGitHub({ argv, tempDir, branchName, forkedRepo, owner, repo, log, $ });
692
557
 
693
558
  // Get issue title for PR title
694
559
  await log(formatAligned('📋', 'Getting issue:', 'Title from GitHub...'), { verbose: true });
@@ -950,16 +815,41 @@ ${prBody}`,
950
815
 
951
816
  const prCreateExecOptions = { encoding: 'utf8', cwd: tempDir, env: process.env };
952
817
  const prCreateRetryLogger = msg => log(msg, { level: 'warn' });
818
+ const prHeadRef = argv.fork && forkedRepo ? `${forkedRepo.split('/')[0]}:${branchName}` : branchName;
819
+
820
+ // Issue #2168: `execGhWithRetry` now retries GitHub 5xx / GraphQL
821
+ // internal errors. A mutation that GitHub committed but failed to
822
+ // acknowledge would make the retry report "a pull request already
823
+ // exists" — which is success, not failure. Resolve the existing PR
824
+ // instead of aborting the session.
825
+ const runPrCreate = async (cmd, label) => {
826
+ try {
827
+ const result = await execGhWithRetry(cmd, {
828
+ execOptions: prCreateExecOptions,
829
+ label,
830
+ log: prCreateRetryLogger,
831
+ });
832
+ return { stdout: result.stdout, stderr: result.stderr || '' };
833
+ } catch (error) {
834
+ if (!isPullRequestAlreadyExistsError(error)) throw error;
835
+ await log(` ${label}: GitHub reports a pull request already exists for ${prHeadRef} — treating the retried creation as already applied.`, { level: 'warn' });
836
+ const existingUrl = await findExistingPullRequestUrl({
837
+ owner,
838
+ repo,
839
+ headRef: prHeadRef,
840
+ execGh: (lookupCommand, lookupOptions) => execGhWithRetry(lookupCommand, { execOptions: prCreateExecOptions, log: prCreateRetryLogger, ...lookupOptions }),
841
+ log: msg => log(msg),
842
+ });
843
+ if (!existingUrl) throw error;
844
+ return { stdout: existingUrl, stderr: '' };
845
+ }
846
+ };
953
847
 
954
848
  // Try to create PR with assignee first (if specified)
955
849
  try {
956
- const result = await execGhWithRetry(command, {
957
- execOptions: prCreateExecOptions,
958
- label: 'gh pr create',
959
- log: prCreateRetryLogger,
960
- });
850
+ const result = await runPrCreate(command, 'gh pr create');
961
851
  output = result.stdout;
962
- prCreateStderr = result.stderr || '';
852
+ prCreateStderr = result.stderr;
963
853
  } catch (firstError) {
964
854
  // Check if the error is specifically about assignee validation
965
855
  const errorMsg = firstError.message || '';
@@ -985,13 +875,9 @@ ${prBody}`,
985
875
  }
986
876
 
987
877
  // Retry without assignee - if this fails, let the error propagate to outer catch
988
- const retryResult = await execGhWithRetry(command, {
989
- execOptions: prCreateExecOptions,
990
- label: 'gh pr create (no assignee)',
991
- log: prCreateRetryLogger,
992
- });
878
+ const retryResult = await runPrCreate(command, 'gh pr create (no assignee)');
993
879
  output = retryResult.stdout;
994
- prCreateStderr = retryResult.stderr || '';
880
+ prCreateStderr = retryResult.stderr;
995
881
  } else {
996
882
  // Not an assignee error, re-throw the original error
997
883
  throw firstError;
package/src/solve.mjs CHANGED
@@ -291,164 +291,14 @@ if (argv.autoCleanup === undefined) {
291
291
  }
292
292
  // Issue #1716: When the upstream repository is private and the user has direct write access, fork-based workflows should be skipped — even if the existing PR was originally created from a fork. Forks of private repositories often become inaccessible (renamed, deleted, parent re-private'd) and there's no reason to use them when we can push branches and PRs to the upstream repo.
293
293
  const skipForkForPrivateUpstream = !isRepoPublic && !argv.fork && hasWriteAccess;
294
- // Determine mode and get issue details
295
- let issueNumber;
296
- let prNumber;
297
- let prBranch;
298
- let mergeStateStatus;
299
- let prState;
300
- let forkOwner = null;
301
- let forkRepoName = null;
302
- let isContinueMode = false;
303
- // Auto-continue logic: check for existing PRs if --auto-continue is enabled
304
- const autoContinueResult = await processAutoContinueForIssue(argv, isIssueUrl, urlNumber, owner, repo);
305
- if (autoContinueResult.isContinueMode) {
306
- isContinueMode = true;
307
- prNumber = autoContinueResult.prNumber;
308
- prBranch = autoContinueResult.prBranch;
309
- issueNumber = autoContinueResult.issueNumber;
310
- // Only check PR details if we have a PR number
311
- if (prNumber) {
312
- // Store PR info globally for error handlers
313
- global.createdPR = { number: prNumber };
314
- // Check if PR is from a fork and get fork owner, merge status, and PR state
315
- if (argv.verbose) {
316
- await log(' Checking if PR is from a fork...', { verbose: true });
317
- }
318
- try {
319
- const prCheckResult = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRepositoryOwner,headRepository,mergeStateStatus,state`;
320
- if (prCheckResult.code === 0) {
321
- const prCheckData = JSON.parse(prCheckResult.stdout.toString());
322
- // Extract merge status and PR state
323
- mergeStateStatus = prCheckData.mergeStateStatus;
324
- prState = prCheckData.state;
325
- if (argv.verbose) {
326
- await log(` PR state: ${prState || 'UNKNOWN'}`, { verbose: true });
327
- await log(` Merge status: ${mergeStateStatus || 'UNKNOWN'}`, { verbose: true });
328
- }
329
- if (prCheckData.headRepositoryOwner && prCheckData.headRepositoryOwner.login !== owner) {
330
- const detectedForkOwner = prCheckData.headRepositoryOwner.login;
331
- const detectedForkRepoName = prCheckData.headRepository && prCheckData.headRepository.name ? prCheckData.headRepository.name : null;
332
- // Issue #1716: Skip fork mode for private upstream repos with write access.
333
- if (skipForkForPrivateUpstream) {
334
- await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
335
- await log(' Working directly on the private upstream repository (Issue #1716).');
336
- } else {
337
- forkOwner = detectedForkOwner;
338
- // Get actual fork repository name (may be prefixed) and store for use in setupRepository
339
- forkRepoName = detectedForkRepoName;
340
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
341
- if (argv.verbose) {
342
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
343
- await log(' Will clone fork repository for continue mode', { verbose: true });
344
- }
345
- }
346
- // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
347
- if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
348
- await handleMaintainerForkAccess({ owner, repo, prNumber });
349
- }
350
- }
351
- }
352
- } catch (forkCheckError) {
353
- if (argv.verbose) {
354
- await log(` Warning: Could not check fork status: ${forkCheckError.message}`, { verbose: true });
355
- }
356
- }
357
- } else {
358
- // We have a branch but no PR - we'll use the existing branch and create a PR later
359
- await log(`🔄 Using existing branch: ${prBranch} (no PR yet - will create one)`);
360
- await log(' This branch was created by an earlier run; this run is reusing it rather than creating a fresh branch.');
361
- if (argv.verbose) {
362
- await log(' Branch will be checked out and PR will be created during auto-PR creation phase', {
363
- verbose: true,
364
- });
365
- }
366
- }
367
- } else if (isIssueUrl) {
368
- issueNumber = autoContinueResult.issueNumber || urlNumber;
369
- }
370
- if (isPrUrl) {
371
- isContinueMode = true;
372
- prNumber = urlNumber;
373
- // Store PR info globally for error handlers
374
- global.createdPR = { number: prNumber, url: issueUrl };
375
- await log(`🔄 Continue mode: Working with PR #${prNumber}`);
376
- if (argv.verbose) {
377
- await log(' Continue mode activated: PR URL provided directly', { verbose: true });
378
- await log(` PR Number set to: ${prNumber}`, { verbose: true });
379
- await log(' Will fetch PR details and linked issue', { verbose: true });
380
- }
381
- // Get PR details to find the linked issue and branch
382
- try {
383
- const prResult = await githubLib.ghPrView({
384
- prNumber,
385
- owner,
386
- repo,
387
- jsonFields: 'headRefName,body,number,mergeStateStatus,state,headRepositoryOwner,headRepository',
388
- });
389
- if (prResult.code !== 0 || !prResult.data) {
390
- await log('Error: Failed to get PR details', { level: 'error' });
391
- if (prResult.output.includes('Could not resolve to a PullRequest')) {
392
- await githubLib.handlePRNotFoundError({ prNumber, owner, repo, argv, shouldAttachLogs });
393
- } else {
394
- await log(`Error: ${prResult.stderr || 'Unknown error'}`, { level: 'error' });
395
- }
396
- await safeExit(1, 'Failed to get PR details');
397
- }
398
- const prData = prResult.data;
399
- prBranch = prData.headRefName;
400
- mergeStateStatus = prData.mergeStateStatus;
401
- prState = prData.state;
402
- // Check if this is a fork PR
403
- if (prData.headRepositoryOwner && prData.headRepositoryOwner.login !== owner) {
404
- const detectedForkOwner = prData.headRepositoryOwner.login;
405
- const detectedForkRepoName = prData.headRepository && prData.headRepository.name ? prData.headRepository.name : null;
406
- // Issue #1716: Skip fork mode for private upstream repos with write access.
407
- if (skipForkForPrivateUpstream) {
408
- await log(`🔒 Detected fork PR from ${detectedForkOwner}/${detectedForkRepoName || repo}, but upstream ${owner}/${repo} is private and you have write access.`);
409
- await log(' Working directly on the private upstream repository (Issue #1716).');
410
- } else {
411
- forkOwner = detectedForkOwner;
412
- // Get actual fork repository name and store for use in setupRepository
413
- forkRepoName = detectedForkRepoName;
414
- await log(`🍴 Detected fork PR from ${forkOwner}/${forkRepoName || repo}`);
415
- if (argv.verbose) {
416
- await log(` Fork owner: ${forkOwner}`, { verbose: true });
417
- await log(' Will clone fork repository for continue mode', { verbose: true });
418
- }
419
- }
420
- // Check if maintainer can push to the fork when --allow-to-push-to-contributors-pull-requests-as-maintainer is enabled
421
- if (forkOwner && argv.allowToPushToContributorsPullRequestsAsMaintainer && argv.autoFork) {
422
- await handleMaintainerForkAccess({ owner, repo, prNumber });
423
- }
424
- }
425
- await log(`📝 PR branch: ${prBranch}`);
426
- const prBody = prData.body || '';
427
- const extractedIssueNumber = extractLinkedIssueNumber(prBody);
428
- if (extractedIssueNumber) {
429
- issueNumber = extractedIssueNumber;
430
- await log(`🔗 Found linked issue #${issueNumber}`);
431
- } else {
432
- // If no linked issue found, we can still continue but warn
433
- await log('⚠️ Warning: No linked issue found in PR body', { level: 'warning' });
434
- await log(' The PR should contain "Fixes #123" or similar to link an issue', { level: 'warning' });
435
- // Set issueNumber to PR number as fallback
436
- issueNumber = prNumber;
437
- }
438
- } catch (error) {
439
- reportError(error, {
440
- context: 'pr_processing',
441
- prNumber,
442
- operation: 'process_pull_request',
443
- });
444
- await log(`Error: Failed to process PR: ${cleanErrorMessage(error)}`, { level: 'error' });
445
- await safeExit(1, 'Failed to process PR');
446
- }
447
- } else {
448
- // Traditional issue mode
449
- issueNumber = urlNumber;
450
- await log(`📝 Issue mode: Working with issue #${issueNumber}`);
451
- }
294
+ // Determine mode and get issue details.
295
+ // Issue #2175: the mode/fork/linked-issue resolution lives in solve.mode.lib.mjs
296
+ // so this file stays under the 1350-line early-warning threshold (issue #1593).
297
+ const solveMode = await import('./solve.mode.lib.mjs');
298
+ const resolvedMode = await solveMode.resolveSolveMode({ argv, owner, repo, urlNumber, issueUrl, isIssueUrl, isPrUrl, skipForkForPrivateUpstream, shouldAttachLogs, log, safeExit, githubLib, processAutoContinueForIssue, handleMaintainerForkAccess, extractLinkedIssueNumber, reportError, cleanErrorMessage });
299
+ const { issueNumber, prBranch, mergeStateStatus, prState, forkOwner, forkRepoName, isContinueMode } = resolvedMode;
300
+ // `prNumber` is reassigned below when auto-PR creation opens the pull request.
301
+ let prNumber = resolvedMode.prNumber;
452
302
  // Issues #1212, #1462: Store issueNumber globally for error handlers (attach failure logs to issue when no PR exists)
453
303
  global.issueNumber = issueNumber;
454
304
  // Issues #1595 and #1596: detect the issue type so analysis and logging prompts use bug vs feature/task wording.