@link-assistant/hive-mind 1.23.9 → 1.23.11

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,26 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.23.11
4
+
5
+ ### Patch Changes
6
+
7
+ - f1ba29d: Comprehensive CI/CD status handling for --auto-restart-until-mergeable mode
8
+ - Detect when CI failures are caused by billing/spending limits via check run annotations
9
+ - For private repositories: Post an explanatory comment and stop (requires human intervention)
10
+ - For public repositories: Apply exponential backoff and wait (unusual case)
11
+ - Distinguish between CI failure, cancelled, pending, queued, and billing limit states
12
+ - Automatically re-trigger cancelled CI/CD workflow runs instead of restarting AI
13
+ - Only restart AI when genuine code failures occur (not for cancelled/pending/billing)
14
+ - Wait for all CI/CD checks to complete before deciding on AI restart
15
+ - New functions: getDetailedCIStatus(), rerunWorkflowRun(), rerunFailedJobs(), getWorkflowRunsForSha()
16
+ - Expanded test coverage: 45 tests covering all CI/CD status scenarios and decision logic
17
+
18
+ ## 1.23.10
19
+
20
+ ### Patch Changes
21
+
22
+ - cc57624: Add retry logic for fork validation network errors (Issue #1311). The validateForkParent function now retries up to 3 times with exponential backoff for transient network errors like TCP timeouts. Network errors now show a distinct error message with helpful retry suggestions instead of incorrectly reporting a fork parent mismatch.
23
+
3
24
  ## 1.23.9
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.23.9",
3
+ "version": "1.23.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -790,6 +790,411 @@ export async function getDefaultBranch(owner, repo, verbose = false) {
790
790
  }
791
791
  }
792
792
 
793
+ /**
794
+ * Get annotations for a check run
795
+ * Issue #1314: Used to detect billing limit errors
796
+ * @param {string} owner - Repository owner
797
+ * @param {string} repo - Repository name
798
+ * @param {number} checkRunId - Check run ID
799
+ * @param {boolean} verbose - Whether to log verbose output
800
+ * @returns {Promise<Array<Object>>} Array of annotation objects
801
+ */
802
+ export async function getCheckRunAnnotations(owner, repo, checkRunId, verbose = false) {
803
+ try {
804
+ const { stdout } = await exec(`gh api repos/${owner}/${repo}/check-runs/${checkRunId}/annotations 2>/dev/null || echo "[]"`);
805
+ const annotations = JSON.parse(stdout.trim() || '[]');
806
+
807
+ if (verbose) {
808
+ console.log(`[VERBOSE] /merge: Check run ${checkRunId} has ${annotations.length} annotations`);
809
+ }
810
+
811
+ return annotations;
812
+ } catch (error) {
813
+ if (verbose) {
814
+ console.log(`[VERBOSE] /merge: Error fetching annotations for check run ${checkRunId}: ${error.message}`);
815
+ }
816
+ return [];
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Check if repository is private
822
+ * Issue #1314: Used to determine behavior when billing limits are reached
823
+ * @param {string} owner - Repository owner
824
+ * @param {string} repo - Repository name
825
+ * @param {boolean} verbose - Whether to log verbose output
826
+ * @returns {Promise<{isPrivate: boolean, visibility: string|null}>}
827
+ */
828
+ export async function getRepoVisibility(owner, repo, verbose = false) {
829
+ try {
830
+ const { stdout } = await exec(`gh api repos/${owner}/${repo} --jq '{isPrivate: .private, visibility: .visibility}'`);
831
+ const info = JSON.parse(stdout.trim());
832
+
833
+ if (verbose) {
834
+ console.log(`[VERBOSE] /merge: Repository ${owner}/${repo} visibility: ${info.visibility}, private: ${info.isPrivate}`);
835
+ }
836
+
837
+ return {
838
+ isPrivate: info.isPrivate === true,
839
+ visibility: info.visibility || null,
840
+ };
841
+ } catch (error) {
842
+ if (verbose) {
843
+ console.log(`[VERBOSE] /merge: Error checking repository visibility: ${error.message}`);
844
+ }
845
+ // Assume private if we can't determine (safer default)
846
+ return { isPrivate: true, visibility: null };
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Known billing limit error message pattern
852
+ * Issue #1314: This is the exact message GitHub uses for billing/spending limit errors
853
+ */
854
+ export const BILLING_LIMIT_ERROR_PATTERN = 'The job was not started because recent account payments have failed or your spending limit needs to be increased';
855
+
856
+ /**
857
+ * Check if CI failure is due to billing/spending limits
858
+ * Issue #1314: Detects when GitHub Actions jobs fail due to billing issues rather than code problems
859
+ *
860
+ * Detection criteria:
861
+ * 1. Job has conclusion='failure'
862
+ * 2. Job has empty steps array (no steps were executed)
863
+ * 3. Job has runner_id=0 or null (no runner was assigned)
864
+ * 4. Annotation contains the billing limit error message
865
+ *
866
+ * @param {string} owner - Repository owner
867
+ * @param {string} repo - Repository name
868
+ * @param {number} prNumber - Pull request number
869
+ * @param {boolean} verbose - Whether to log verbose output
870
+ * @returns {Promise<{isBillingLimitError: boolean, message: string|null, affectedJobs: string[], allJobsAffected: boolean}>}
871
+ */
872
+ export async function checkForBillingLimitError(owner, repo, prNumber, verbose = false) {
873
+ try {
874
+ // Get the PR's head SHA
875
+ const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid`);
876
+ const prData = JSON.parse(prJson.trim());
877
+ const sha = prData.headRefOid;
878
+
879
+ // Get workflow runs for this SHA
880
+ const { stdout: runsJson } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=10" --jq '.workflow_runs[].id'`);
881
+ const runIds = runsJson.trim().split('\n').filter(Boolean);
882
+
883
+ if (verbose) {
884
+ console.log(`[VERBOSE] /merge: Found ${runIds.length} workflow runs for PR #${prNumber} at SHA ${sha.substring(0, 7)}`);
885
+ }
886
+
887
+ const affectedJobs = [];
888
+ let totalJobs = 0;
889
+
890
+ // Check each workflow run's jobs
891
+ for (const runId of runIds) {
892
+ try {
893
+ const { stdout: jobsJson } = await exec(`gh api repos/${owner}/${repo}/actions/runs/${runId}/jobs --jq '.jobs'`);
894
+ const jobs = JSON.parse(jobsJson.trim() || '[]');
895
+
896
+ for (const job of jobs) {
897
+ totalJobs++;
898
+
899
+ // Check for billing limit indicators:
900
+ // 1. Conclusion is failure
901
+ // 2. Steps array is empty (no steps were executed)
902
+ // 3. Runner ID is 0 or null (no runner was assigned)
903
+ const hasNoSteps = !job.steps || job.steps.length === 0;
904
+ const hasNoRunner = job.runner_id === 0 || job.runner_id === null;
905
+
906
+ if (job.conclusion === 'failure' && hasNoSteps && hasNoRunner) {
907
+ // Fetch annotations to confirm billing limit error
908
+ const annotations = await getCheckRunAnnotations(owner, repo, job.id, verbose);
909
+
910
+ const billingAnnotation = annotations.find(a => a.message?.includes(BILLING_LIMIT_ERROR_PATTERN));
911
+
912
+ if (billingAnnotation) {
913
+ affectedJobs.push(job.name);
914
+
915
+ if (verbose) {
916
+ console.log(`[VERBOSE] /merge: Job "${job.name}" (ID: ${job.id}) failed due to billing limits`);
917
+ }
918
+ }
919
+ }
920
+ }
921
+ } catch (error) {
922
+ if (verbose) {
923
+ console.log(`[VERBOSE] /merge: Error checking jobs for run ${runId}: ${error.message}`);
924
+ }
925
+ }
926
+ }
927
+
928
+ const isBillingLimitError = affectedJobs.length > 0;
929
+ const allJobsAffected = totalJobs > 0 && affectedJobs.length === totalJobs;
930
+
931
+ if (verbose && isBillingLimitError) {
932
+ console.log(`[VERBOSE] /merge: Billing limit detected - ${affectedJobs.length}/${totalJobs} jobs affected`);
933
+ }
934
+
935
+ return {
936
+ isBillingLimitError,
937
+ message: isBillingLimitError ? BILLING_LIMIT_ERROR_PATTERN : null,
938
+ affectedJobs,
939
+ allJobsAffected,
940
+ };
941
+ } catch (error) {
942
+ if (verbose) {
943
+ console.log(`[VERBOSE] /merge: Error checking for billing limit: ${error.message}`);
944
+ }
945
+ return {
946
+ isBillingLimitError: false,
947
+ message: null,
948
+ affectedJobs: [],
949
+ allJobsAffected: false,
950
+ };
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Re-run all jobs in a workflow run
956
+ * Issue #1314: Used to re-trigger CI jobs that were cancelled or not started
957
+ * @param {string} owner - Repository owner
958
+ * @param {string} repo - Repository name
959
+ * @param {number} runId - Workflow run ID
960
+ * @param {boolean} verbose - Whether to log verbose output
961
+ * @returns {Promise<{success: boolean, error: string|null}>}
962
+ */
963
+ export async function rerunWorkflowRun(owner, repo, runId, verbose = false) {
964
+ try {
965
+ await exec(`gh api repos/${owner}/${repo}/actions/runs/${runId}/rerun -X POST`);
966
+ // GitHub returns 201 on success
967
+ if (verbose) {
968
+ console.log(`[VERBOSE] /merge: Successfully triggered re-run for workflow ${runId}`);
969
+ }
970
+ return { success: true, error: null };
971
+ } catch (error) {
972
+ // exec throws when command exits non-zero (e.g., 404 Not Found)
973
+ const errorMessage = error.stderr?.trim() || error.stdout?.trim() || error.message;
974
+ if (verbose) {
975
+ console.log(`[VERBOSE] /merge: Failed to re-run workflow ${runId}: ${errorMessage}`);
976
+ }
977
+ return { success: false, error: errorMessage };
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Re-run only failed jobs in a workflow run
983
+ * Issue #1314: More targeted than full re-run, only retries failed jobs
984
+ * @param {string} owner - Repository owner
985
+ * @param {string} repo - Repository name
986
+ * @param {number} runId - Workflow run ID
987
+ * @param {boolean} verbose - Whether to log verbose output
988
+ * @returns {Promise<{success: boolean, error: string|null}>}
989
+ */
990
+ export async function rerunFailedJobs(owner, repo, runId, verbose = false) {
991
+ try {
992
+ await exec(`gh api repos/${owner}/${repo}/actions/runs/${runId}/rerun-failed-jobs -X POST`);
993
+ // GitHub returns 201 on success
994
+ if (verbose) {
995
+ console.log(`[VERBOSE] /merge: Successfully triggered re-run of failed jobs for workflow ${runId}`);
996
+ }
997
+ return { success: true, error: null };
998
+ } catch (error) {
999
+ const errorMessage = error.stderr?.trim() || error.stdout?.trim() || error.message;
1000
+ if (verbose) {
1001
+ console.log(`[VERBOSE] /merge: Failed to re-run failed jobs for workflow ${runId}: ${errorMessage}`);
1002
+ }
1003
+ return { success: false, error: errorMessage };
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Get detailed CI status for a PR, distinguishing between different non-success states
1009
+ * Issue #1314: Enhanced version that separates cancelled, queued, and billing-limited states
1010
+ *
1011
+ * Possible returned statuses:
1012
+ * - 'success': All checks passed
1013
+ * - 'failure': Some checks failed (genuine code failures, timed_out, or action_required)
1014
+ * - 'cancelled': Some checks were cancelled or stale (need re-triggering)
1015
+ * - 'pending': Some checks are still running, queued, waiting, or requested
1016
+ * - 'billing_limit': Failures are due to billing/spending limits (determined by caller)
1017
+ * - 'no_checks': No CI checks found yet (race condition after push)
1018
+ * - 'unknown': Unable to determine status
1019
+ *
1020
+ * @param {string} owner - Repository owner
1021
+ * @param {string} repo - Repository name
1022
+ * @param {number} prNumber - Pull request number
1023
+ * @param {boolean} verbose - Whether to log verbose output
1024
+ * @returns {Promise<Object>} Detailed CI status object
1025
+ */
1026
+ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false) {
1027
+ try {
1028
+ // Get the PR's head SHA
1029
+ const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid`);
1030
+ const prData = JSON.parse(prJson.trim());
1031
+ const sha = prData.headRefOid;
1032
+
1033
+ // Get check runs for this SHA
1034
+ const { stdout: checksJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/check-runs --paginate --jq '.check_runs'`);
1035
+ const checkRuns = JSON.parse(checksJson.trim() || '[]');
1036
+
1037
+ // Get commit statuses
1038
+ const { stdout: statusJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/status --jq '.statuses'`);
1039
+ const statuses = JSON.parse(statusJson.trim() || '[]');
1040
+
1041
+ // Build detailed checks list
1042
+ const allChecks = [
1043
+ ...checkRuns.map(check => ({
1044
+ name: check.name,
1045
+ status: check.status, // queued, in_progress, completed
1046
+ conclusion: check.conclusion, // success, failure, cancelled, timed_out, skipped, neutral, action_required, stale, null
1047
+ type: 'check_run',
1048
+ id: check.id,
1049
+ })),
1050
+ ...statuses.map(status => ({
1051
+ name: status.context,
1052
+ status: status.state === 'pending' ? 'in_progress' : 'completed',
1053
+ conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
1054
+ type: 'status',
1055
+ id: null,
1056
+ })),
1057
+ ];
1058
+
1059
+ // No checks yet
1060
+ if (allChecks.length === 0) {
1061
+ if (verbose) {
1062
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks yet - treating as no_checks`);
1063
+ }
1064
+ return {
1065
+ status: 'no_checks',
1066
+ checks: [],
1067
+ sha,
1068
+ hasFailures: false,
1069
+ hasCancelled: false,
1070
+ hasStale: false,
1071
+ hasPending: false,
1072
+ hasQueued: false,
1073
+ allPassed: false,
1074
+ failedChecks: [],
1075
+ cancelledChecks: [],
1076
+ staleChecks: [],
1077
+ pendingChecks: [],
1078
+ queuedChecks: [],
1079
+ passedChecks: [],
1080
+ };
1081
+ }
1082
+
1083
+ // Categorize checks
1084
+ // Note: GitHub check run conclusions include: success, failure, cancelled, timed_out, skipped,
1085
+ // neutral, action_required, stale, null (not yet completed)
1086
+ // GitHub check run statuses include: queued, in_progress, completed, waiting, requested, pending
1087
+ const passedChecks = allChecks.filter(c => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral');
1088
+ const failedChecks = allChecks.filter(c => c.conclusion === 'failure' || c.conclusion === 'timed_out' || c.conclusion === 'action_required');
1089
+ const cancelledChecks = allChecks.filter(c => c.conclusion === 'cancelled');
1090
+ const staleChecks = allChecks.filter(c => c.conclusion === 'stale');
1091
+ const pendingChecks = allChecks.filter(c => (c.status === 'in_progress' || c.status === 'waiting' || c.status === 'requested' || c.status === 'pending') && c.conclusion === null);
1092
+ const queuedChecks = allChecks.filter(c => c.status === 'queued' && c.conclusion === null);
1093
+
1094
+ const hasFailures = failedChecks.length > 0;
1095
+ const hasCancelled = cancelledChecks.length > 0;
1096
+ const hasStale = staleChecks.length > 0;
1097
+ const hasPending = pendingChecks.length > 0;
1098
+ const hasQueued = queuedChecks.length > 0;
1099
+ const allPassed = !hasFailures && !hasCancelled && !hasStale && !hasPending && !hasQueued && passedChecks.length === allChecks.length;
1100
+
1101
+ // Determine overall status
1102
+ let status;
1103
+ if (allPassed) {
1104
+ status = 'success';
1105
+ } else if (hasPending || hasQueued) {
1106
+ // Some checks are still running, queued, or waiting for a runner - wait for completion
1107
+ status = 'pending';
1108
+ } else if (hasStale && !hasFailures && !hasCancelled) {
1109
+ // Stale checks need to be re-triggered (similar to cancelled)
1110
+ status = 'cancelled';
1111
+ } else if (hasFailures && !hasCancelled && !hasStale) {
1112
+ status = 'failure';
1113
+ } else if ((hasCancelled || hasStale) && !hasFailures) {
1114
+ status = 'cancelled';
1115
+ } else if (hasFailures && (hasCancelled || hasStale)) {
1116
+ // Mixed: some failed, some cancelled/stale - report as failure (the failures need attention)
1117
+ status = 'failure';
1118
+ } else {
1119
+ status = 'unknown';
1120
+ }
1121
+
1122
+ if (verbose) {
1123
+ console.log(`[VERBOSE] /merge: PR #${prNumber} detailed CI status: ${status}`);
1124
+ console.log(`[VERBOSE] /merge: Total: ${allChecks.length}, Passed: ${passedChecks.length}, Failed: ${failedChecks.length}, Cancelled: ${cancelledChecks.length}, Stale: ${staleChecks.length}, Pending: ${pendingChecks.length}, Queued: ${queuedChecks.length}`);
1125
+ }
1126
+
1127
+ return {
1128
+ status,
1129
+ checks: allChecks,
1130
+ sha,
1131
+ hasFailures,
1132
+ hasCancelled,
1133
+ hasStale,
1134
+ hasPending,
1135
+ hasQueued,
1136
+ allPassed,
1137
+ failedChecks,
1138
+ cancelledChecks,
1139
+ staleChecks,
1140
+ pendingChecks,
1141
+ queuedChecks,
1142
+ passedChecks,
1143
+ };
1144
+ } catch (error) {
1145
+ if (verbose) {
1146
+ console.log(`[VERBOSE] /merge: Error getting detailed CI status: ${error.message}`);
1147
+ }
1148
+ return {
1149
+ status: 'unknown',
1150
+ checks: [],
1151
+ sha: null,
1152
+ hasFailures: false,
1153
+ hasCancelled: false,
1154
+ hasStale: false,
1155
+ hasPending: false,
1156
+ hasQueued: false,
1157
+ allPassed: false,
1158
+ failedChecks: [],
1159
+ cancelledChecks: [],
1160
+ staleChecks: [],
1161
+ pendingChecks: [],
1162
+ queuedChecks: [],
1163
+ passedChecks: [],
1164
+ };
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Get workflow run IDs for a specific commit SHA
1170
+ * Issue #1314: Helper to find workflow runs to re-trigger
1171
+ * @param {string} owner - Repository owner
1172
+ * @param {string} repo - Repository name
1173
+ * @param {string} sha - Commit SHA
1174
+ * @param {boolean} verbose - Whether to log verbose output
1175
+ * @returns {Promise<Array<{id: number, status: string, conclusion: string|null, name: string}>>}
1176
+ */
1177
+ export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
1178
+ try {
1179
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=20" --jq '[.workflow_runs[] | {id: .id, status: .status, conclusion: .conclusion, name: .name}]'`);
1180
+ const runs = JSON.parse(stdout.trim() || '[]');
1181
+
1182
+ if (verbose) {
1183
+ console.log(`[VERBOSE] /merge: Found ${runs.length} workflow runs for SHA ${sha.substring(0, 7)}`);
1184
+ for (const run of runs) {
1185
+ console.log(`[VERBOSE] /merge: - ${run.name} (${run.id}): status=${run.status}, conclusion=${run.conclusion}`);
1186
+ }
1187
+ }
1188
+
1189
+ return runs;
1190
+ } catch (error) {
1191
+ if (verbose) {
1192
+ console.log(`[VERBOSE] /merge: Error fetching workflow runs for SHA ${sha}: ${error.message}`);
1193
+ }
1194
+ return [];
1195
+ }
1196
+ }
1197
+
793
1198
  export default {
794
1199
  READY_LABEL,
795
1200
  checkReadyLabelExists,
@@ -809,4 +1214,14 @@ export default {
809
1214
  getActiveBranchRuns,
810
1215
  waitForBranchCI,
811
1216
  getDefaultBranch,
1217
+ // Issue #1314: Billing limit detection
1218
+ getCheckRunAnnotations,
1219
+ getRepoVisibility,
1220
+ checkForBillingLimitError,
1221
+ BILLING_LIMIT_ERROR_PATTERN,
1222
+ // Issue #1314: Enhanced CI status and re-run capabilities
1223
+ getDetailedCIStatus,
1224
+ rerunWorkflowRun,
1225
+ rerunFailedJobs,
1226
+ getWorkflowRunsForSha,
812
1227
  };
package/src/lib.mjs CHANGED
@@ -243,6 +243,22 @@ export const retry = async (fn, options = {}) => {
243
243
  }
244
244
  };
245
245
 
246
+ /**
247
+ * Check if an error is a transient network error that can be retried.
248
+ * Used by validateForkParent to detect network timeouts (Issue #1311).
249
+ * @param {Error|string} error - The error to check
250
+ * @returns {boolean} True if the error is transient and retryable
251
+ */
252
+ export const isTransientNetworkError = error => {
253
+ const msg = (error?.message || error?.toString() || '').toLowerCase();
254
+ const output = (error?.stderr?.toString() || error?.stdout?.toString() || '').toLowerCase();
255
+ const combined = msg + ' ' + output;
256
+
257
+ const transientPatterns = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up'];
258
+
259
+ return transientPatterns.some(pattern => combined.includes(pattern));
260
+ };
261
+
246
262
  /**
247
263
  * Format bytes to human readable string
248
264
  * @param {number} bytes - Number of bytes
@@ -33,7 +33,7 @@ const { reportError } = sentryLib;
33
33
 
34
34
  // Import GitHub merge functions
35
35
  const githubMergeLib = await import('./github-merge.lib.mjs');
36
- const { checkPRCIStatus, checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI } = githubMergeLib;
36
+ const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha } = githubMergeLib;
37
37
 
38
38
  // Import GitHub functions for log attachment
39
39
  const githubLib = await import('./github.lib.mjs');
@@ -141,23 +141,93 @@ const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCh
141
141
 
142
142
  /**
143
143
  * Get the reasons why PR is not mergeable
144
+ * Issue #1314: Comprehensive CI/CD status handling covering all possible states:
145
+ * - success: All CI passed → no blocker
146
+ * - failure: Genuine code failures → restart AI
147
+ * - cancelled: Manually cancelled or workflow cancelled → re-trigger, don't restart AI
148
+ * - pending/queued: Still running or waiting for runner → wait, don't restart AI
149
+ * - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
150
+ * - no_checks: No CI checks yet (race condition) → wait
144
151
  */
145
152
  const getMergeBlockers = async (owner, repo, prNumber, verbose = false) => {
146
153
  const blockers = [];
147
154
 
148
- // Check CI status
149
- const ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
150
- if (ciStatus.status === 'failure') {
155
+ // Use detailed CI status to distinguish between all possible states
156
+ const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
157
+
158
+ if (ciStatus.status === 'no_checks') {
159
+ // No CI checks exist yet - race condition after push, treat as pending
151
160
  blockers.push({
152
- type: 'ci_failure',
153
- message: 'CI/CD checks are failing',
154
- details: ciStatus.checks.filter(c => c.conclusion === 'failure').map(c => c.name),
161
+ type: 'ci_pending',
162
+ message: 'CI/CD checks have not started yet (waiting for checks to appear)',
163
+ details: [],
155
164
  });
156
165
  } else if (ciStatus.status === 'pending') {
166
+ // CI is still running or queued - wait for completion
167
+ const pendingNames = [...ciStatus.pendingChecks, ...ciStatus.queuedChecks].map(c => c.name);
168
+ blockers.push({
169
+ type: 'ci_pending',
170
+ message: 'CI/CD checks are still running or queued',
171
+ details: pendingNames,
172
+ });
173
+ } else if (ciStatus.status === 'cancelled') {
174
+ // All non-passed checks are cancelled or stale (no genuine failures)
175
+ // First check if this is actually a billing limit issue (billing-limited jobs may appear as cancelled)
176
+ const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
177
+ if (billingCheck.isBillingLimitError) {
178
+ blockers.push({
179
+ type: 'billing_limit',
180
+ message: 'GitHub Actions billing/spending limit reached',
181
+ details: billingCheck.affectedJobs,
182
+ allJobsAffected: billingCheck.allJobsAffected,
183
+ billingMessage: billingCheck.message,
184
+ });
185
+ } else {
186
+ // These need to be re-triggered, NOT treated as AI-fixable failures
187
+ const cancelledOrStaleChecks = [...ciStatus.cancelledChecks, ...(ciStatus.staleChecks || [])];
188
+ blockers.push({
189
+ type: 'ci_cancelled',
190
+ message: 'CI/CD checks were cancelled or became stale',
191
+ details: cancelledOrStaleChecks.map(c => c.name),
192
+ sha: ciStatus.sha,
193
+ });
194
+ }
195
+ } else if (ciStatus.status === 'failure') {
196
+ // Some checks genuinely failed - check if it's billing limits first
197
+ const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
198
+
199
+ if (billingCheck.isBillingLimitError) {
200
+ blockers.push({
201
+ type: 'billing_limit',
202
+ message: 'GitHub Actions billing/spending limit reached',
203
+ details: billingCheck.affectedJobs,
204
+ allJobsAffected: billingCheck.allJobsAffected,
205
+ billingMessage: billingCheck.message,
206
+ });
207
+ } else {
208
+ // Check if there are also cancelled/stale checks alongside failures
209
+ const cancelledOrStaleChecks = [...(ciStatus.hasCancelled ? ciStatus.cancelledChecks : []), ...((ciStatus.hasStale && ciStatus.staleChecks) || [])];
210
+ if (cancelledOrStaleChecks.length > 0) {
211
+ blockers.push({
212
+ type: 'ci_cancelled',
213
+ message: 'Some CI/CD checks were cancelled or became stale (will be re-triggered)',
214
+ details: cancelledOrStaleChecks.map(c => c.name),
215
+ sha: ciStatus.sha,
216
+ });
217
+ }
218
+ blockers.push({
219
+ type: 'ci_failure',
220
+ message: 'CI/CD checks are failing',
221
+ details: ciStatus.failedChecks.map(c => c.name),
222
+ });
223
+ }
224
+ } else if (ciStatus.status === 'unknown') {
225
+ // Unable to determine CI status - treat as pending to be safe
226
+ // Do NOT treat as mergeable (which would be incorrect)
157
227
  blockers.push({
158
228
  type: 'ci_pending',
159
- message: 'CI/CD checks are still running',
160
- details: ciStatus.checks.filter(c => c.status !== 'completed').map(c => c.name),
229
+ message: 'CI/CD status could not be determined (will retry)',
230
+ details: [],
161
231
  });
162
232
  }
163
233
 
@@ -303,9 +373,112 @@ export const watchUntilMergeable = async params => {
303
373
  feedbackLines.push('Please review and address the feedback from these comments.');
304
374
  }
305
375
 
306
- // Reason 2: CI failures
376
+ // Issue #1314: Check for billing limit errors BEFORE regular CI failures
377
+ // Billing limits require human intervention and should NOT trigger AI restarts
378
+ const billingBlocker = blockers.find(b => b.type === 'billing_limit');
379
+ if (billingBlocker) {
380
+ await log('');
381
+ await log(formatAligned('💳', 'GITHUB ACTIONS BILLING LIMIT DETECTED', ''));
382
+ await log(formatAligned('', 'Affected jobs:', billingBlocker.details.join(', '), 2));
383
+ await log(formatAligned('', 'All jobs affected:', billingBlocker.allJobsAffected ? 'Yes' : 'No', 2));
384
+ await log('');
385
+
386
+ // Check if this is a private repository
387
+ const repoInfo = await getRepoVisibility(owner, repo, argv.verbose);
388
+
389
+ if (repoInfo.isPrivate) {
390
+ // For private repos, human intervention is required - stop and post comment
391
+ await log(formatAligned('🛑', 'STOPPING', 'Private repository - billing limit requires human intervention'));
392
+ await log(formatAligned('', 'Action required:', "Check the 'Billing & plans' section in your GitHub settings", 2));
393
+
394
+ // Post comment explaining the billing limit issue
395
+ try {
396
+ const commentBody = `## 💳 GitHub Actions Billing Limit Reached
397
+
398
+ The CI/CD jobs could not start due to billing/spending limits.
399
+
400
+ **Affected jobs:**
401
+ ${billingBlocker.details.map(j => `- ${j}`).join('\n')}
402
+
403
+ **Error message:**
404
+ > ${billingBlocker.billingMessage || BILLING_LIMIT_ERROR_PATTERN}
405
+
406
+ **Action Required:**
407
+ Please check the 'Billing & plans' section in your GitHub settings and either:
408
+ 1. Add or update your payment method
409
+ 2. Increase your spending limit
410
+ 3. Wait for the free tier limits to reset (if applicable)
411
+
412
+ Once the billing issue is resolved, you can re-run the CI checks or push a new commit to trigger a new run.
413
+
414
+ ---
415
+ *Detected by hive-mind with --auto-restart-until-mergeable flag. This is NOT a code issue - human intervention is required.*`;
416
+ await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
417
+ await log(formatAligned('', '💬 Posted billing limit notification to PR', '', 2));
418
+ } catch (commentError) {
419
+ reportError(commentError, {
420
+ context: 'post_billing_limit_comment',
421
+ owner,
422
+ repo,
423
+ prNumber,
424
+ operation: 'comment_on_pr',
425
+ });
426
+ await log(formatAligned('', '⚠️ Could not post comment to PR', '', 2));
427
+ }
428
+
429
+ return { success: false, reason: 'billing_limit', latestSessionId, latestAnthropicCost };
430
+ } else {
431
+ // For public repos (unusual case), apply exponential backoff and wait
432
+ // Public repos typically have unlimited free CI, so this is unexpected
433
+ await log(formatAligned('⏳', 'Public repository with billing limit (unusual)', 'Applying exponential backoff'));
434
+ await log(formatAligned('', 'Next check in:', `${currentBackoffSeconds} seconds`, 2));
435
+
436
+ // Don't trigger AI restart - just wait and check again
437
+ // The backoff will be applied at the end of the loop
438
+ currentBackoffSeconds = Math.min(currentBackoffSeconds * 2, 3600); // Max 1 hour
439
+ }
440
+ }
441
+
442
+ // Issue #1314: Handle cancelled CI/CD checks - re-trigger them instead of restarting AI
443
+ // Cancelled checks (e.g., manually cancelled, cancelled by another workflow) should be
444
+ // re-triggered automatically. We should NOT restart the AI for these.
445
+ const cancelledBlocker = blockers.find(b => b.type === 'ci_cancelled');
446
+ if (cancelledBlocker && !billingBlocker) {
447
+ await log('');
448
+ await log(formatAligned('🔄', 'CANCELLED CI/CD CHECKS DETECTED', ''));
449
+ await log(formatAligned('', 'Cancelled checks:', cancelledBlocker.details.join(', '), 2));
450
+
451
+ // Attempt to re-trigger the cancelled/stale workflow runs
452
+ const sha = cancelledBlocker.sha;
453
+ if (sha) {
454
+ const runs = await getWorkflowRunsForSha(owner, repo, sha, argv.verbose);
455
+ const retriggerable = runs.filter(r => r.conclusion === 'cancelled' || r.conclusion === 'stale');
456
+ let rerunTriggered = false;
457
+
458
+ for (const run of retriggerable) {
459
+ await log(formatAligned('', `Re-triggering workflow "${run.name}" (${run.id})...`, '', 2));
460
+ const rerunResult = await rerunWorkflowRun(owner, repo, run.id, argv.verbose);
461
+ if (rerunResult.success) {
462
+ await log(formatAligned('', `✅ Re-triggered: ${run.name}`, '', 2));
463
+ rerunTriggered = true;
464
+ } else {
465
+ await log(formatAligned('', `⚠️ Could not re-trigger ${run.name}: ${rerunResult.error}`, '', 2));
466
+ }
467
+ }
468
+
469
+ if (rerunTriggered) {
470
+ await log(formatAligned('⏳', 'Waiting for re-triggered CI to complete...', '', 2));
471
+ // Don't restart AI - just wait for re-triggered jobs to complete
472
+ // The next iteration of the loop will check the new status
473
+ }
474
+ }
475
+ // Don't set shouldRestart for cancelled checks - wait for re-triggered jobs instead
476
+ }
477
+
478
+ // Reason 2: CI failures (only if NOT a billing limit issue and NOT just cancelled)
479
+ // Only restart AI when we have genuine code failures (real feedback to act on)
307
480
  const ciBlocker = blockers.find(b => b.type === 'ci_failure');
308
- if (ciBlocker) {
481
+ if (ciBlocker && !billingBlocker) {
309
482
  shouldRestart = true;
310
483
  restartReason = restartReason ? `${restartReason}; CI failures` : 'CI failures detected';
311
484
  feedbackLines.push('❌ CI/CD checks are failing:');
@@ -468,8 +641,18 @@ export const watchUntilMergeable = async params => {
468
641
  // Update last check time after restart
469
642
  lastCheckTime = new Date();
470
643
  } else if (blockers.length > 0) {
471
- // There are blockers but none that warrant a restart (e.g., CI pending)
472
- await log(formatAligned('⏳', 'Waiting for:', blockers.map(b => b.message).join(', '), 2));
644
+ // There are blockers but none that warrant an AI restart
645
+ // Issue #1314: Distinguish between different waiting reasons
646
+ const pendingBlocker = blockers.find(b => b.type === 'ci_pending');
647
+ const cancelledOnly = blockers.every(b => b.type === 'ci_cancelled' || b.type === 'ci_pending');
648
+
649
+ if (cancelledOnly && cancelledBlocker) {
650
+ await log(formatAligned('🔄', 'Waiting for re-triggered CI:', cancelledBlocker.details.join(', '), 2));
651
+ } else if (pendingBlocker) {
652
+ await log(formatAligned('⏳', 'Waiting for CI:', pendingBlocker.details.length > 0 ? pendingBlocker.details.join(', ') : pendingBlocker.message, 2));
653
+ } else {
654
+ await log(formatAligned('⏳', 'Waiting for:', blockers.map(b => b.message).join(', '), 2));
655
+ }
473
656
  } else {
474
657
  await log(formatAligned('', 'No action needed', 'Continuing to monitor...', 2));
475
658
  }
@@ -110,93 +110,82 @@ export const checkExistingForkOfRoot = async rootRepo => {
110
110
  * This prevents issues where a fork was created from an intermediate fork (fork of a fork)
111
111
  * instead of directly from the intended upstream repository.
112
112
  *
113
+ * Issue #1311: Added retry logic for transient network errors (TCP timeouts, etc.)
114
+ *
113
115
  * @param {string} forkRepo - The fork repository to validate (e.g., "user/repo")
114
116
  * @param {string} expectedUpstream - The expected upstream repository (e.g., "owner/repo")
115
- * @returns {Promise<{isValid: boolean, isFork: boolean, parent: string|null, source: string|null, error: string|null}>}
117
+ * @returns {Promise<{isValid: boolean, isFork: boolean, parent: string|null, source: string|null, error: string|null, isNetworkError?: boolean}>}
116
118
  */
117
119
  export const validateForkParent = async (forkRepo, expectedUpstream) => {
118
- try {
119
- const forkInfoResult = await $`gh api repos/${forkRepo} --jq '{fork: .fork, parent: .parent.full_name, source: .source.full_name}'`;
120
-
121
- if (forkInfoResult.code !== 0) {
122
- return {
123
- isValid: false,
124
- isFork: false,
125
- parent: null,
126
- source: null,
127
- error: `Failed to get fork info for ${forkRepo}`,
128
- };
129
- }
120
+ // Issue #1311: Retry configuration for transient network errors
121
+ const maxAttempts = 3;
122
+ const baseDelay = 2000;
123
+ const networkErr = msg => ({ isValid: false, isFork: false, parent: null, source: null, error: msg, isNetworkError: true });
130
124
 
131
- const forkInfo = JSON.parse(forkInfoResult.stdout.toString().trim());
132
- const isFork = forkInfo.fork === true;
133
- const parent = forkInfo.parent || null;
134
- const source = forkInfo.source || null;
135
-
136
- // If not a fork at all, it's invalid for our purposes
137
- if (!isFork) {
138
- return {
139
- isValid: false,
140
- isFork: false,
141
- parent: null,
142
- source: null,
143
- error: `Repository ${forkRepo} is not a GitHub fork`,
144
- };
145
- }
125
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
126
+ try {
127
+ const forkInfoResult = await $`gh api repos/${forkRepo} --jq '{fork: .fork, parent: .parent.full_name, source: .source.full_name}'`;
128
+
129
+ // Check for network errors in non-zero exit code
130
+ if (forkInfoResult.code !== 0) {
131
+ const errorOutput = (forkInfoResult.stderr?.toString() || '') + (forkInfoResult.stdout?.toString() || '');
132
+ // Issue #1311: Retry on transient network errors
133
+ if (lib.isTransientNetworkError({ message: errorOutput })) {
134
+ if (attempt < maxAttempts) {
135
+ const delay = baseDelay * Math.pow(2, attempt - 1);
136
+ await log(` ⚠️ Network error, retrying in ${delay / 1000}s... (${attempt}/${maxAttempts})`, { level: 'warning' });
137
+ await lib.sleep(delay);
138
+ continue;
139
+ }
140
+ return networkErr(`Network error after ${maxAttempts} attempts: ${errorOutput.substring(0, 200)}`);
141
+ }
142
+ return { isValid: false, isFork: false, parent: null, source: null, error: `Failed to get fork info for ${forkRepo}` };
143
+ }
146
144
 
147
- // The fork's PARENT (immediate upstream) should match expectedUpstream
148
- // The SOURCE (ultimate root) is also acceptable as it indicates the fork
149
- // is part of the correct hierarchy, just at a different level
150
- const parentMatches = parent === expectedUpstream;
151
- const sourceMatches = source === expectedUpstream;
152
-
153
- // Ideal case: parent matches directly (fork was made from expected upstream)
154
- if (parentMatches) {
155
- return {
156
- isValid: true,
157
- isFork: true,
158
- parent,
159
- source,
160
- error: null,
161
- };
162
- }
145
+ const forkInfo = JSON.parse(forkInfoResult.stdout.toString().trim());
146
+ const isFork = forkInfo.fork === true;
147
+ const parent = forkInfo.parent || null;
148
+ const source = forkInfo.source || null;
163
149
 
164
- // Special case: source matches but parent doesn't
165
- // This means the fork was made from an intermediate fork
166
- // For issue #967, this is the problematic case we want to catch
167
- if (sourceMatches && !parentMatches) {
168
- return {
169
- isValid: false,
170
- isFork: true,
171
- parent,
172
- source,
173
- error: `Fork ${forkRepo} was created from ${parent} (intermediate fork), not directly from ${expectedUpstream}. ` + `This can cause pull requests to include unexpected commits from the intermediate fork.`,
174
- };
175
- }
150
+ // If not a fork at all, it's invalid for our purposes
151
+ if (!isFork) {
152
+ return { isValid: false, isFork: false, parent: null, source: null, error: `Repository ${forkRepo} is not a GitHub fork` };
153
+ }
176
154
 
177
- // Neither parent nor source matches - completely different repository tree
178
- return {
179
- isValid: false,
180
- isFork: true,
181
- parent,
182
- source,
183
- error: `Fork ${forkRepo} is from a different repository tree (parent: ${parent}, source: ${source}) and cannot be used with ${expectedUpstream}`,
184
- };
185
- } catch (error) {
186
- reportError(error, {
187
- context: 'validate_fork_parent',
188
- forkRepo,
189
- expectedUpstream,
190
- operation: 'check_fork_hierarchy',
191
- });
192
- return {
193
- isValid: false,
194
- isFork: false,
195
- parent: null,
196
- source: null,
197
- error: `Error validating fork parent: ${error.message}`,
198
- };
155
+ // The fork's PARENT (immediate upstream) should match expectedUpstream
156
+ // The SOURCE (ultimate root) is also acceptable as it indicates the fork is part of the correct hierarchy
157
+ const parentMatches = parent === expectedUpstream;
158
+ const sourceMatches = source === expectedUpstream;
159
+
160
+ if (parentMatches) {
161
+ return { isValid: true, isFork: true, parent, source, error: null };
162
+ }
163
+
164
+ // Special case: source matches but parent doesn't - fork was made from an intermediate fork
165
+ // For issue #967, this is the problematic case we want to catch
166
+ if (sourceMatches && !parentMatches) {
167
+ return { isValid: false, isFork: true, parent, source, error: `Fork ${forkRepo} was created from ${parent} (intermediate fork), not directly from ${expectedUpstream}. This can cause pull requests to include unexpected commits from the intermediate fork.` };
168
+ }
169
+
170
+ // Neither parent nor source matches - completely different repository tree
171
+ return { isValid: false, isFork: true, parent, source, error: `Fork ${forkRepo} is from a different repository tree (parent: ${parent}, source: ${source}) and cannot be used with ${expectedUpstream}` };
172
+ } catch (error) {
173
+ // Issue #1311: Retry on transient network errors
174
+ if (lib.isTransientNetworkError(error)) {
175
+ if (attempt < maxAttempts) {
176
+ const delay = baseDelay * Math.pow(2, attempt - 1);
177
+ await log(` ⚠️ Network error, retrying in ${delay / 1000}s... (${attempt}/${maxAttempts})`, { level: 'warning' });
178
+ await lib.sleep(delay);
179
+ continue;
180
+ }
181
+ reportError(error, { context: 'validate_fork_parent', forkRepo, expectedUpstream, operation: 'check_fork_hierarchy', attempt, maxAttempts, isNetworkError: true });
182
+ return networkErr(`Network error after ${maxAttempts} attempts: ${error.message}`);
183
+ }
184
+ reportError(error, { context: 'validate_fork_parent', forkRepo, expectedUpstream, operation: 'check_fork_hierarchy' });
185
+ return { isValid: false, isFork: false, parent: null, source: null, error: `Error validating fork parent: ${error.message}` };
186
+ }
199
187
  }
188
+ return networkErr(`Failed to validate fork after ${maxAttempts} attempts`);
200
189
  };
201
190
 
202
191
  /**
@@ -513,6 +502,25 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
513
502
  const forkValidation = await validateForkParent(existingForkName, `${owner}/${repo}`);
514
503
 
515
504
  if (!forkValidation.isValid) {
505
+ // Issue #1311: Handle network errors separately from fork mismatch errors
506
+ if (forkValidation.isNetworkError) {
507
+ await log('');
508
+ await log(`${formatAligned('❌', 'NETWORK ERROR DURING FORK VALIDATION', '')}`, { level: 'error' });
509
+ await log('');
510
+ await log(' 🔍 What happened:');
511
+ await log(` Failed to connect to GitHub API while validating fork.`);
512
+ await log(` Error: ${forkValidation.error}`);
513
+ await log('');
514
+ await log(' 💡 This is likely a temporary network issue. You can:');
515
+ await log(' 1. Wait a moment and try again');
516
+ await log(' 2. Check your internet connection');
517
+ await log(' 3. Check GitHub status: https://www.githubstatus.com/');
518
+ await log('');
519
+ await log(' Or use --no-fork to skip fork validation if you have write access.');
520
+ await log('');
521
+ await safeExit(1, 'Network error during fork validation - please retry');
522
+ }
523
+
516
524
  // Fork parent mismatch detected - this prevents issue #967
517
525
  await log('');
518
526
  await log(`${formatAligned('❌', 'FORK PARENT MISMATCH DETECTED', '')}`, { level: 'error' });
@@ -842,29 +850,44 @@ Thank you!`;
842
850
  const forkValidation = await validateForkParent(actualForkName, `${owner}/${repo}`);
843
851
 
844
852
  if (!forkValidation.isValid) {
845
- // Fork parent mismatch detected
846
- await log('');
847
- await log(`${formatAligned('⚠️', 'FORK PARENT MISMATCH WARNING', '')}`, { level: 'warning' });
848
- await log('');
849
- await log(' 🔍 Issue detected:');
850
- if (!forkValidation.isFork) {
851
- await log(` The repository ${actualForkName} is NOT a GitHub fork.`);
853
+ // Issue #1311: Handle network errors separately from fork mismatch errors
854
+ if (forkValidation.isNetworkError) {
855
+ await log('');
856
+ await log(`${formatAligned('⚠️', 'NETWORK ERROR DURING FORK VALIDATION', '')}`, { level: 'warning' });
857
+ await log('');
858
+ await log(' 🔍 What happened:');
859
+ await log(` Failed to connect to GitHub API while validating fork.`);
860
+ await log(` Error: ${forkValidation.error}`);
861
+ await log('');
862
+ await log(' 💡 This is likely a temporary network issue.');
863
+ await log(' Continuing with the fork, but validation was skipped.');
864
+ await log('');
865
+ // Note: We continue here since this is someone else's fork and we can't verify it
852
866
  } else {
853
- await log(` The fork ${actualForkName} was created from ${forkValidation.parent},`);
854
- await log(` not directly from the target repository ${owner}/${repo}.`);
867
+ // Fork parent mismatch detected
868
+ await log('');
869
+ await log(`${formatAligned('⚠️', 'FORK PARENT MISMATCH WARNING', '')}`, { level: 'warning' });
870
+ await log('');
871
+ await log(' 🔍 Issue detected:');
872
+ if (!forkValidation.isFork) {
873
+ await log(` The repository ${actualForkName} is NOT a GitHub fork.`);
874
+ } else {
875
+ await log(` The fork ${actualForkName} was created from ${forkValidation.parent},`);
876
+ await log(` not directly from the target repository ${owner}/${repo}.`);
877
+ }
878
+ await log('');
879
+ await log(' 📦 Fork relationship:');
880
+ await log(` • Fork: ${actualForkName}`);
881
+ await log(` • Fork parent: ${forkValidation.parent || 'N/A'}`);
882
+ await log(` • Fork source (root): ${forkValidation.source || 'N/A'}`);
883
+ await log(` • Expected parent: ${owner}/${repo}`);
884
+ await log('');
885
+ await log(' ⚠️ This may cause pull requests to include unexpected commits.');
886
+ await log(' Consider using --fork to create your own fork instead.');
887
+ await log('');
888
+ // Note: We don't exit here since this is someone else's fork and we're just using it
889
+ // The user should be aware but can proceed (they didn't create this fork)
855
890
  }
856
- await log('');
857
- await log(' 📦 Fork relationship:');
858
- await log(` • Fork: ${actualForkName}`);
859
- await log(` • Fork parent: ${forkValidation.parent || 'N/A'}`);
860
- await log(` • Fork source (root): ${forkValidation.source || 'N/A'}`);
861
- await log(` • Expected parent: ${owner}/${repo}`);
862
- await log('');
863
- await log(' ⚠️ This may cause pull requests to include unexpected commits.');
864
- await log(' Consider using --fork to create your own fork instead.');
865
- await log('');
866
- // Note: We don't exit here since this is someone else's fork and we're just using it
867
- // The user should be aware but can proceed (they didn't create this fork)
868
891
  } else {
869
892
  await log(`${formatAligned('✅', 'Fork parent validated:', `${forkValidation.parent}`)}`);
870
893
  }