@link-assistant/hive-mind 1.23.8 → 1.23.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/config.lib.mjs +13 -0
- package/src/github-merge.lib.mjs +158 -0
- package/src/lib.mjs +16 -0
- package/src/solve.repository.lib.mjs +122 -99
- package/src/telegram-merge-queue.lib.mjs +71 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.23.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 1.23.9
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 4456760: Fix merge queue to wait for target branch CI before merging (Issue #1307). The merge queue now checks for active CI runs on the target branch (main) before processing the first PR in the queue. This prevents cancelled workflows, incomplete releases, and failed post-merge checks when multiple PRs are merged in quick succession.
|
|
14
|
+
|
|
3
15
|
## 1.23.8
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/config.lib.mjs
CHANGED
|
@@ -409,6 +409,7 @@ export const version = {
|
|
|
409
409
|
// Merge queue configurations
|
|
410
410
|
// See: https://github.com/link-assistant/hive-mind/issues/1143
|
|
411
411
|
// See: https://github.com/link-assistant/hive-mind/issues/1269
|
|
412
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1307
|
|
412
413
|
export const mergeQueue = {
|
|
413
414
|
// Maximum PRs to process in one merge session
|
|
414
415
|
// Default: 10 PRs per session
|
|
@@ -426,6 +427,18 @@ export const mergeQueue = {
|
|
|
426
427
|
// Issue #1269: gh pr merge requires explicit method when running non-interactively
|
|
427
428
|
// Default: 'merge' - creates a merge commit
|
|
428
429
|
mergeMethod: getenv('HIVE_MIND_MERGE_QUEUE_MERGE_METHOD', 'merge'),
|
|
430
|
+
// Issue #1307: Wait for main branch CI to complete before processing merge queue
|
|
431
|
+
// When enabled, the merge queue will wait for any active CI runs on the target branch
|
|
432
|
+
// (usually main) to complete before merging the first PR.
|
|
433
|
+
// Default: true - ensures all post-merge CI workflows complete before next merge
|
|
434
|
+
waitForTargetBranchCI: getenv('HIVE_MIND_MERGE_QUEUE_WAIT_FOR_TARGET_CI', 'true').toLowerCase() === 'true',
|
|
435
|
+
// Issue #1307: Timeout for waiting on target branch CI (in milliseconds)
|
|
436
|
+
// If active runs don't complete within this time, proceed with merge anyway
|
|
437
|
+
// Default: 45 minutes (2700000ms)
|
|
438
|
+
targetBranchCITimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_TARGET_CI_TIMEOUT_MS', 45 * 60 * 1000),
|
|
439
|
+
// Issue #1307: Polling interval for checking target branch CI status (in milliseconds)
|
|
440
|
+
// Default: 30 seconds (30000ms) - more frequent than PR CI polling since we're blocking
|
|
441
|
+
targetBranchCIPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_TARGET_CI_POLL_INTERVAL_MS', 30 * 1000),
|
|
429
442
|
};
|
|
430
443
|
|
|
431
444
|
// Helper function to validate configuration values
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -636,6 +636,160 @@ export function parseRepositoryUrl(url) {
|
|
|
636
636
|
};
|
|
637
637
|
}
|
|
638
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Get active workflow runs on a specific branch
|
|
641
|
+
* Issue #1307: Used to check if there are any in-progress or queued runs on the target branch
|
|
642
|
+
* @param {string} owner - Repository owner
|
|
643
|
+
* @param {string} repo - Repository name
|
|
644
|
+
* @param {string} branch - Branch name (default: main)
|
|
645
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
646
|
+
* @returns {Promise<{runs: Array<Object>, hasActiveRuns: boolean, count: number}>}
|
|
647
|
+
*/
|
|
648
|
+
export async function getActiveBranchRuns(owner, repo, branch = 'main', verbose = false) {
|
|
649
|
+
try {
|
|
650
|
+
// Query for in_progress and queued runs on the specified branch
|
|
651
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?branch=${branch}&per_page=10" --jq '[.workflow_runs[] | select(.status=="in_progress" or .status=="queued")] | map({id: .id, name: .name, status: .status, created_at: .created_at, html_url: .html_url})'`);
|
|
652
|
+
|
|
653
|
+
const runs = JSON.parse(stdout.trim() || '[]');
|
|
654
|
+
|
|
655
|
+
if (verbose) {
|
|
656
|
+
console.log(`[VERBOSE] /merge: Found ${runs.length} active runs on ${owner}/${repo} branch ${branch}`);
|
|
657
|
+
for (const run of runs) {
|
|
658
|
+
console.log(`[VERBOSE] /merge: - Run #${run.id}: ${run.name} (${run.status})`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
runs,
|
|
664
|
+
hasActiveRuns: runs.length > 0,
|
|
665
|
+
count: runs.length,
|
|
666
|
+
};
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (verbose) {
|
|
669
|
+
console.log(`[VERBOSE] /merge: Error checking active runs on ${branch}: ${error.message}`);
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
runs: [],
|
|
673
|
+
hasActiveRuns: false,
|
|
674
|
+
count: 0,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Wait for all active workflow runs on a branch to complete
|
|
681
|
+
* Issue #1307: Ensures all CI runs on target branch are complete before merging
|
|
682
|
+
* @param {string} owner - Repository owner
|
|
683
|
+
* @param {string} repo - Repository name
|
|
684
|
+
* @param {string} branch - Branch name (default: main)
|
|
685
|
+
* @param {Object} options - Wait options
|
|
686
|
+
* @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
|
|
687
|
+
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
688
|
+
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
689
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
690
|
+
* @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
|
|
691
|
+
*/
|
|
692
|
+
export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
|
|
693
|
+
const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
|
|
694
|
+
|
|
695
|
+
const startTime = Date.now();
|
|
696
|
+
let totalWaitedRuns = 0;
|
|
697
|
+
|
|
698
|
+
if (verbose) {
|
|
699
|
+
console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
while (Date.now() - startTime < timeout) {
|
|
703
|
+
let activeRuns;
|
|
704
|
+
try {
|
|
705
|
+
activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
// Log and continue on errors
|
|
708
|
+
console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
|
|
709
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (onStatusUpdate) {
|
|
714
|
+
try {
|
|
715
|
+
await onStatusUpdate({
|
|
716
|
+
hasActiveRuns: activeRuns.hasActiveRuns,
|
|
717
|
+
count: activeRuns.count,
|
|
718
|
+
runs: activeRuns.runs,
|
|
719
|
+
elapsedMs: Date.now() - startTime,
|
|
720
|
+
});
|
|
721
|
+
} catch (callbackError) {
|
|
722
|
+
// Log callback errors but continue
|
|
723
|
+
console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
if (!activeRuns.hasActiveRuns) {
|
|
728
|
+
if (verbose) {
|
|
729
|
+
console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
success: true,
|
|
733
|
+
waitedForRuns: totalWaitedRuns > 0,
|
|
734
|
+
completedRuns: totalWaitedRuns,
|
|
735
|
+
error: null,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
|
|
740
|
+
|
|
741
|
+
if (verbose) {
|
|
742
|
+
const elapsedSec = Math.round((Date.now() - startTime) / 1000);
|
|
743
|
+
console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Timeout reached
|
|
750
|
+
const finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
751
|
+
if (finalCheck.hasActiveRuns) {
|
|
752
|
+
return {
|
|
753
|
+
success: false,
|
|
754
|
+
waitedForRuns: true,
|
|
755
|
+
completedRuns: totalWaitedRuns - finalCheck.count,
|
|
756
|
+
error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
success: true,
|
|
762
|
+
waitedForRuns: totalWaitedRuns > 0,
|
|
763
|
+
completedRuns: totalWaitedRuns,
|
|
764
|
+
error: null,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Get the default branch for a repository
|
|
770
|
+
* @param {string} owner - Repository owner
|
|
771
|
+
* @param {string} repo - Repository name
|
|
772
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
773
|
+
* @returns {Promise<string>} Default branch name (e.g., 'main' or 'master')
|
|
774
|
+
*/
|
|
775
|
+
export async function getDefaultBranch(owner, repo, verbose = false) {
|
|
776
|
+
try {
|
|
777
|
+
const { stdout } = await exec(`gh api repos/${owner}/${repo} --jq '.default_branch'`);
|
|
778
|
+
const branch = stdout.trim();
|
|
779
|
+
|
|
780
|
+
if (verbose) {
|
|
781
|
+
console.log(`[VERBOSE] /merge: Default branch for ${owner}/${repo}: ${branch}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return branch || 'main';
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (verbose) {
|
|
787
|
+
console.log(`[VERBOSE] /merge: Error getting default branch, falling back to 'main': ${error.message}`);
|
|
788
|
+
}
|
|
789
|
+
return 'main';
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
639
793
|
export default {
|
|
640
794
|
READY_LABEL,
|
|
641
795
|
checkReadyLabelExists,
|
|
@@ -651,4 +805,8 @@ export default {
|
|
|
651
805
|
mergePullRequest,
|
|
652
806
|
waitForCI,
|
|
653
807
|
parseRepositoryUrl,
|
|
808
|
+
// Issue #1307: New exports for target branch CI waiting
|
|
809
|
+
getActiveBranchRuns,
|
|
810
|
+
waitForBranchCI,
|
|
811
|
+
getDefaultBranch,
|
|
654
812
|
};
|
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
|
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
isValid: false,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
await log(`
|
|
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
|
-
|
|
854
|
-
await log(
|
|
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
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @see https://github.com/link-assistant/hive-mind/issues/1143
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel } from './github-merge.lib.mjs';
|
|
19
|
+
import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch } from './github-merge.lib.mjs';
|
|
20
20
|
import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
|
|
21
21
|
import { getProgressBar } from './limits.lib.mjs';
|
|
22
22
|
|
|
@@ -74,6 +74,11 @@ export const MERGE_QUEUE_CONFIG = {
|
|
|
74
74
|
// Merge method: 'merge', 'squash', or 'rebase' (Issue #1269)
|
|
75
75
|
// gh pr merge requires explicit method when running non-interactively
|
|
76
76
|
MERGE_METHOD: mergeQueueConfig.mergeMethod,
|
|
77
|
+
|
|
78
|
+
// Issue #1307: Wait for target branch CI before first merge
|
|
79
|
+
WAIT_FOR_TARGET_BRANCH_CI: mergeQueueConfig.waitForTargetBranchCI,
|
|
80
|
+
TARGET_BRANCH_CI_TIMEOUT_MS: mergeQueueConfig.targetBranchCITimeoutMs,
|
|
81
|
+
TARGET_BRANCH_CI_POLL_INTERVAL_MS: mergeQueueConfig.targetBranchCIPollIntervalMs,
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
/**
|
|
@@ -229,6 +234,12 @@ export class MergeQueueProcessor {
|
|
|
229
234
|
this.isCancelled = false;
|
|
230
235
|
|
|
231
236
|
try {
|
|
237
|
+
// Issue #1307: Wait for any active CI runs on the target branch before processing
|
|
238
|
+
// This prevents merging while post-merge CI from previous merges is still running
|
|
239
|
+
if (MERGE_QUEUE_CONFIG.WAIT_FOR_TARGET_BRANCH_CI) {
|
|
240
|
+
await this.waitForTargetBranchCI();
|
|
241
|
+
}
|
|
242
|
+
|
|
232
243
|
// Process each PR sequentially
|
|
233
244
|
for (this.currentIndex = 0; this.currentIndex < this.items.length; this.currentIndex++) {
|
|
234
245
|
if (this.isCancelled) {
|
|
@@ -383,6 +394,54 @@ export class MergeQueueProcessor {
|
|
|
383
394
|
this.log('Cancellation requested');
|
|
384
395
|
}
|
|
385
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Wait for any active CI runs on the target branch to complete
|
|
399
|
+
* Issue #1307: Prevents merging while post-merge CI from previous merges is still running
|
|
400
|
+
* @returns {Promise<void>}
|
|
401
|
+
*/
|
|
402
|
+
async waitForTargetBranchCI() {
|
|
403
|
+
// Track if we're waiting for CI (for progress updates)
|
|
404
|
+
this.waitingForTargetBranchCI = true;
|
|
405
|
+
this.targetBranchCIStatus = null;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// Get the default branch (usually 'main' or 'master')
|
|
409
|
+
const targetBranch = await getDefaultBranch(this.owner, this.repo, this.verbose);
|
|
410
|
+
this.log(`Checking for active CI runs on ${targetBranch} branch before processing queue...`);
|
|
411
|
+
|
|
412
|
+
const waitResult = await waitForBranchCI(
|
|
413
|
+
this.owner,
|
|
414
|
+
this.repo,
|
|
415
|
+
targetBranch,
|
|
416
|
+
{
|
|
417
|
+
timeout: MERGE_QUEUE_CONFIG.TARGET_BRANCH_CI_TIMEOUT_MS,
|
|
418
|
+
pollInterval: MERGE_QUEUE_CONFIG.TARGET_BRANCH_CI_POLL_INTERVAL_MS,
|
|
419
|
+
onStatusUpdate: async status => {
|
|
420
|
+
this.targetBranchCIStatus = status;
|
|
421
|
+
|
|
422
|
+
// Report progress while waiting
|
|
423
|
+
if (this.onProgress) {
|
|
424
|
+
await this.onProgress(this.getProgressUpdate());
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
this.verbose
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
if (!waitResult.success) {
|
|
432
|
+
// Log warning but don't fail - proceed with merge anyway
|
|
433
|
+
console.warn(`[WARN] /merge-queue: ${waitResult.error}. Proceeding with merge anyway.`);
|
|
434
|
+
} else if (waitResult.waitedForRuns) {
|
|
435
|
+
this.log(`Waited for ${waitResult.completedRuns} CI runs to complete on ${targetBranch} branch`);
|
|
436
|
+
} else {
|
|
437
|
+
this.log(`No active CI runs on ${targetBranch} branch. Ready to proceed.`);
|
|
438
|
+
}
|
|
439
|
+
} finally {
|
|
440
|
+
this.waitingForTargetBranchCI = false;
|
|
441
|
+
this.targetBranchCIStatus = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
386
445
|
/**
|
|
387
446
|
* Get current progress update
|
|
388
447
|
*/
|
|
@@ -456,8 +515,18 @@ export class MergeQueueProcessor {
|
|
|
456
515
|
message += `⏭️ Skipped: ${update.stats.skipped} `;
|
|
457
516
|
message += `⏳ Pending: ${update.stats.total - update.progress.processed}\n\n`;
|
|
458
517
|
|
|
518
|
+
// Issue #1307: Show waiting status for target branch CI
|
|
519
|
+
if (this.waitingForTargetBranchCI && this.targetBranchCIStatus) {
|
|
520
|
+
const elapsedSec = Math.round(this.targetBranchCIStatus.elapsedMs / 1000);
|
|
521
|
+
const elapsedMin = Math.floor(elapsedSec / 60);
|
|
522
|
+
const elapsedSecRemainder = elapsedSec % 60;
|
|
523
|
+
message += `⏱️ Waiting for ${this.targetBranchCIStatus.count} CI run\\(s\\) on target branch to complete \\(${elapsedMin}m ${elapsedSecRemainder}s\\)\\.\\.\\.\\n\\n`;
|
|
524
|
+
} else if (this.waitingForTargetBranchCI) {
|
|
525
|
+
message += `⏱️ Checking for active CI runs on target branch\\.\\.\\.\n\n`;
|
|
526
|
+
}
|
|
527
|
+
|
|
459
528
|
// Current item being processed
|
|
460
|
-
if (update.current) {
|
|
529
|
+
if (update.current && !this.waitingForTargetBranchCI) {
|
|
461
530
|
const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
|
|
462
531
|
message += `${statusEmoji} ${update.current}\n\n`;
|
|
463
532
|
}
|