@link-assistant/hive-mind 1.7.2 → 1.8.0
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 +18 -0
- package/src/github-merge.lib.mjs +560 -0
- package/src/telegram-bot.mjs +19 -9
- package/src/telegram-merge-command.lib.mjs +366 -0
- package/src/telegram-merge-queue.lib.mjs +560 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 53e1686: Add experimental /merge command to hive-telegram-bot for sequential PR merging
|
|
8
|
+
- New `/merge <repository-url>` command to process merge queues
|
|
9
|
+
- Automatically checks/creates 'ready' label in repository
|
|
10
|
+
- Merges PRs with 'ready' label sequentially (oldest first)
|
|
11
|
+
- Waits for CI/CD completion between each merge
|
|
12
|
+
- Includes `/merge_cancel` and `/merge_status` helper commands
|
|
13
|
+
- Supports linking issues to PRs (uses minimum creation date for ordering)
|
|
14
|
+
|
|
3
15
|
## 1.7.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/config.lib.mjs
CHANGED
|
@@ -168,6 +168,23 @@ export const version = {
|
|
|
168
168
|
default: getenv('HIVE_MIND_VERSION_DEFAULT', '0.14.3'),
|
|
169
169
|
};
|
|
170
170
|
|
|
171
|
+
// Merge queue configurations
|
|
172
|
+
// See: https://github.com/link-assistant/hive-mind/issues/1143
|
|
173
|
+
export const mergeQueue = {
|
|
174
|
+
// Maximum PRs to process in one merge session
|
|
175
|
+
// Default: 10 PRs per session
|
|
176
|
+
maxPrsPerSession: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_MAX_PRS', 10),
|
|
177
|
+
// CI/CD polling interval in milliseconds
|
|
178
|
+
// Default: 5 minutes (300000ms) - checks CI status every 5 minutes
|
|
179
|
+
ciPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_CI_POLL_INTERVAL_MS', 5 * 60 * 1000),
|
|
180
|
+
// CI/CD timeout in milliseconds
|
|
181
|
+
// Default: 7 hours (25200000ms) - maximum wait time for CI to complete
|
|
182
|
+
ciTimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_CI_TIMEOUT_MS', 7 * 60 * 60 * 1000),
|
|
183
|
+
// Wait time after merge before processing next PR
|
|
184
|
+
// Default: 1 minute (60000ms) - allows CI to stabilize
|
|
185
|
+
postMergeWaitMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_POST_MERGE_WAIT_MS', 60 * 1000),
|
|
186
|
+
};
|
|
187
|
+
|
|
171
188
|
// Helper function to validate configuration values
|
|
172
189
|
export function validateConfig() {
|
|
173
190
|
// Ensure all numeric values are valid
|
|
@@ -213,6 +230,7 @@ export function getAllConfigurations() {
|
|
|
213
230
|
externalUrls,
|
|
214
231
|
modelConfig,
|
|
215
232
|
version,
|
|
233
|
+
mergeQueue,
|
|
216
234
|
};
|
|
217
235
|
}
|
|
218
236
|
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Merge Queue Library
|
|
4
|
+
*
|
|
5
|
+
* Provides utilities for the /merge command including:
|
|
6
|
+
* - Label management (create/check 'ready' label)
|
|
7
|
+
* - Fetching PRs with 'ready' label
|
|
8
|
+
* - CI/CD status monitoring
|
|
9
|
+
* - Sequential merge execution
|
|
10
|
+
*
|
|
11
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1143
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promisify } from 'util';
|
|
15
|
+
import { exec as execCallback } from 'child_process';
|
|
16
|
+
|
|
17
|
+
const exec = promisify(execCallback);
|
|
18
|
+
|
|
19
|
+
// Import GitHub URL parser
|
|
20
|
+
import { parseGitHubUrl } from './github.lib.mjs';
|
|
21
|
+
|
|
22
|
+
// Default label configuration
|
|
23
|
+
export const READY_LABEL = {
|
|
24
|
+
name: 'ready',
|
|
25
|
+
description: 'Is ready to be merged',
|
|
26
|
+
color: '0E8A16', // Green color
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check if 'ready' label exists in repository
|
|
31
|
+
* @param {string} owner - Repository owner
|
|
32
|
+
* @param {string} repo - Repository name
|
|
33
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
34
|
+
* @returns {Promise<{exists: boolean, label: Object|null}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function checkReadyLabelExists(owner, repo, verbose = false) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await exec(`gh api repos/${owner}/${repo}/labels/${READY_LABEL.name} 2>/dev/null || echo ""`);
|
|
39
|
+
if (stdout.trim()) {
|
|
40
|
+
const label = JSON.parse(stdout.trim());
|
|
41
|
+
if (verbose) {
|
|
42
|
+
console.log(`[VERBOSE] /merge: 'ready' label exists in ${owner}/${repo}`);
|
|
43
|
+
}
|
|
44
|
+
return { exists: true, label };
|
|
45
|
+
}
|
|
46
|
+
if (verbose) {
|
|
47
|
+
console.log(`[VERBOSE] /merge: 'ready' label does not exist in ${owner}/${repo}`);
|
|
48
|
+
}
|
|
49
|
+
return { exists: false, label: null };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (verbose) {
|
|
52
|
+
console.log(`[VERBOSE] /merge: Error checking label: ${error.message}`);
|
|
53
|
+
}
|
|
54
|
+
return { exists: false, label: null };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create 'ready' label in repository
|
|
60
|
+
* @param {string} owner - Repository owner
|
|
61
|
+
* @param {string} repo - Repository name
|
|
62
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
63
|
+
* @returns {Promise<{success: boolean, label: Object|null, error: string|null}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function createReadyLabel(owner, repo, verbose = false) {
|
|
66
|
+
try {
|
|
67
|
+
const labelData = JSON.stringify({
|
|
68
|
+
name: READY_LABEL.name,
|
|
69
|
+
description: READY_LABEL.description,
|
|
70
|
+
color: READY_LABEL.color,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const { stdout } = await exec(`gh api repos/${owner}/${repo}/labels -X POST -H "Accept: application/vnd.github+json" --input - <<< '${labelData}'`);
|
|
74
|
+
const label = JSON.parse(stdout.trim());
|
|
75
|
+
|
|
76
|
+
if (verbose) {
|
|
77
|
+
console.log(`[VERBOSE] /merge: Created 'ready' label in ${owner}/${repo}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { success: true, label, error: null };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (verbose) {
|
|
83
|
+
console.log(`[VERBOSE] /merge: Failed to create label: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
return { success: false, label: null, error: error.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if we have admin/write permissions to manage labels
|
|
91
|
+
* @param {string} owner - Repository owner
|
|
92
|
+
* @param {string} repo - Repository name
|
|
93
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
94
|
+
* @returns {Promise<{canManageLabels: boolean, permission: string|null}>}
|
|
95
|
+
*/
|
|
96
|
+
export async function checkLabelPermissions(owner, repo, verbose = false) {
|
|
97
|
+
try {
|
|
98
|
+
const { stdout } = await exec(`gh api repos/${owner}/${repo} --jq .permissions`);
|
|
99
|
+
const permissions = JSON.parse(stdout.trim());
|
|
100
|
+
|
|
101
|
+
const canManageLabels = permissions.admin === true || permissions.push === true || permissions.maintain === true;
|
|
102
|
+
|
|
103
|
+
if (verbose) {
|
|
104
|
+
console.log(`[VERBOSE] /merge: Repository permissions for ${owner}/${repo}: ${JSON.stringify(permissions)}`);
|
|
105
|
+
console.log(`[VERBOSE] /merge: Can manage labels: ${canManageLabels}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
canManageLabels,
|
|
110
|
+
permission: permissions.admin ? 'admin' : permissions.maintain ? 'maintain' : permissions.push ? 'push' : 'read',
|
|
111
|
+
};
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (verbose) {
|
|
114
|
+
console.log(`[VERBOSE] /merge: Error checking permissions: ${error.message}`);
|
|
115
|
+
}
|
|
116
|
+
return { canManageLabels: false, permission: null };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Ensure 'ready' label exists, creating it if we have permissions
|
|
122
|
+
* @param {string} owner - Repository owner
|
|
123
|
+
* @param {string} repo - Repository name
|
|
124
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
125
|
+
* @returns {Promise<{success: boolean, created: boolean, error: string|null}>}
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureReadyLabel(owner, repo, verbose = false) {
|
|
128
|
+
// Check if label already exists
|
|
129
|
+
const { exists } = await checkReadyLabelExists(owner, repo, verbose);
|
|
130
|
+
if (exists) {
|
|
131
|
+
return { success: true, created: false, error: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check permissions before trying to create
|
|
135
|
+
const { canManageLabels } = await checkLabelPermissions(owner, repo, verbose);
|
|
136
|
+
if (!canManageLabels) {
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
created: false,
|
|
140
|
+
error: `No permission to create labels in ${owner}/${repo}. Please ask a repository admin to create the 'ready' label.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Create the label
|
|
145
|
+
const createResult = await createReadyLabel(owner, repo, verbose);
|
|
146
|
+
if (createResult.success) {
|
|
147
|
+
return { success: true, created: true, error: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { success: false, created: false, error: createResult.error };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Fetch all open PRs with 'ready' label
|
|
155
|
+
* @param {string} owner - Repository owner
|
|
156
|
+
* @param {string} repo - Repository name
|
|
157
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
158
|
+
* @returns {Promise<Array<Object>>} Array of PR objects sorted by creation date
|
|
159
|
+
*/
|
|
160
|
+
export async function fetchReadyPullRequests(owner, repo, verbose = false) {
|
|
161
|
+
try {
|
|
162
|
+
const { stdout } = await exec(`gh pr list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus --limit 100`);
|
|
163
|
+
|
|
164
|
+
const prs = JSON.parse(stdout.trim() || '[]');
|
|
165
|
+
|
|
166
|
+
if (verbose) {
|
|
167
|
+
console.log(`[VERBOSE] /merge: Found ${prs.length} open PRs with 'ready' label in ${owner}/${repo}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Sort by creation date (oldest first)
|
|
171
|
+
prs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
172
|
+
|
|
173
|
+
return prs;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (verbose) {
|
|
176
|
+
console.log(`[VERBOSE] /merge: Error fetching PRs: ${error.message}`);
|
|
177
|
+
}
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fetch all open issues with 'ready' label and find their linked PRs
|
|
184
|
+
* @param {string} owner - Repository owner
|
|
185
|
+
* @param {string} repo - Repository name
|
|
186
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
187
|
+
* @returns {Promise<Array<Object>>} Array of {issue, pr} objects sorted by creation date
|
|
188
|
+
*/
|
|
189
|
+
export async function fetchReadyIssuesWithPRs(owner, repo, verbose = false) {
|
|
190
|
+
try {
|
|
191
|
+
// Fetch open issues with 'ready' label
|
|
192
|
+
const { stdout: issuesJson } = await exec(`gh issue list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title,url,createdAt --limit 100`);
|
|
193
|
+
|
|
194
|
+
const issues = JSON.parse(issuesJson.trim() || '[]');
|
|
195
|
+
|
|
196
|
+
if (verbose) {
|
|
197
|
+
console.log(`[VERBOSE] /merge: Found ${issues.length} open issues with 'ready' label in ${owner}/${repo}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// For each issue, find linked PRs using the closing keyword search
|
|
201
|
+
const result = [];
|
|
202
|
+
for (const issue of issues) {
|
|
203
|
+
try {
|
|
204
|
+
// Search for PRs that reference this issue with closing keywords
|
|
205
|
+
const { stdout: searchJson } = await exec(`gh pr list --repo ${owner}/${repo} --search "in:body closes #${issue.number} OR fixes #${issue.number} OR resolves #${issue.number}" --state open --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus --limit 5`);
|
|
206
|
+
|
|
207
|
+
const linkedPRs = JSON.parse(searchJson.trim() || '[]');
|
|
208
|
+
|
|
209
|
+
if (linkedPRs.length > 0) {
|
|
210
|
+
// Take the first linked PR (oldest if multiple)
|
|
211
|
+
linkedPRs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
212
|
+
result.push({
|
|
213
|
+
issue,
|
|
214
|
+
pr: linkedPRs[0],
|
|
215
|
+
// Use minimum of issue and PR creation dates for sorting
|
|
216
|
+
sortDate: new Date(Math.min(new Date(issue.createdAt), new Date(linkedPRs[0].createdAt))),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (verbose) {
|
|
220
|
+
console.log(`[VERBOSE] /merge: Issue #${issue.number} linked to PR #${linkedPRs[0].number}`);
|
|
221
|
+
}
|
|
222
|
+
} else if (verbose) {
|
|
223
|
+
console.log(`[VERBOSE] /merge: Issue #${issue.number} has no linked open PR`);
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (verbose) {
|
|
227
|
+
console.log(`[VERBOSE] /merge: Error finding linked PR for issue #${issue.number}: ${err.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Sort by the minimum creation date
|
|
233
|
+
result.sort((a, b) => a.sortDate - b.sortDate);
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (verbose) {
|
|
238
|
+
console.log(`[VERBOSE] /merge: Error fetching issues: ${error.message}`);
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get combined list of ready PRs (from both direct PR labels and issue labels)
|
|
246
|
+
* @param {string} owner - Repository owner
|
|
247
|
+
* @param {string} repo - Repository name
|
|
248
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
249
|
+
* @returns {Promise<Array<Object>>} Array of PR objects with optional issue reference, sorted by creation date
|
|
250
|
+
*/
|
|
251
|
+
export async function getAllReadyPRs(owner, repo, verbose = false) {
|
|
252
|
+
// Fetch both direct PRs and issue-linked PRs in parallel
|
|
253
|
+
const [directPRs, issueLinkedPRs] = await Promise.all([fetchReadyPullRequests(owner, repo, verbose), fetchReadyIssuesWithPRs(owner, repo, verbose)]);
|
|
254
|
+
|
|
255
|
+
// Build a map to deduplicate by PR number
|
|
256
|
+
const prMap = new Map();
|
|
257
|
+
|
|
258
|
+
// Add direct PRs
|
|
259
|
+
for (const pr of directPRs) {
|
|
260
|
+
prMap.set(pr.number, {
|
|
261
|
+
pr,
|
|
262
|
+
issue: null,
|
|
263
|
+
sortDate: new Date(pr.createdAt),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Add issue-linked PRs (may override if PR is already in map)
|
|
268
|
+
for (const { issue, pr, sortDate } of issueLinkedPRs) {
|
|
269
|
+
const existing = prMap.get(pr.number);
|
|
270
|
+
if (existing) {
|
|
271
|
+
// If PR exists, use the minimum of both sort dates
|
|
272
|
+
existing.issue = issue;
|
|
273
|
+
existing.sortDate = new Date(Math.min(existing.sortDate, sortDate));
|
|
274
|
+
} else {
|
|
275
|
+
prMap.set(pr.number, { pr, issue, sortDate });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Convert to array and sort by sortDate
|
|
280
|
+
const result = Array.from(prMap.values());
|
|
281
|
+
result.sort((a, b) => a.sortDate - b.sortDate);
|
|
282
|
+
|
|
283
|
+
if (verbose) {
|
|
284
|
+
console.log(`[VERBOSE] /merge: Total unique ready PRs: ${result.length}`);
|
|
285
|
+
for (const { pr, issue } of result) {
|
|
286
|
+
const issueInfo = issue ? ` (linked to issue #${issue.number})` : '';
|
|
287
|
+
console.log(`[VERBOSE] /merge: PR #${pr.number}: ${pr.title}${issueInfo}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Check CI/CD status for a PR
|
|
296
|
+
* @param {string} owner - Repository owner
|
|
297
|
+
* @param {string} repo - Repository name
|
|
298
|
+
* @param {number} prNumber - Pull request number
|
|
299
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
300
|
+
* @returns {Promise<{status: string, checks: Array<Object>, allPassed: boolean, hasPending: boolean}>}
|
|
301
|
+
*/
|
|
302
|
+
export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
|
|
303
|
+
try {
|
|
304
|
+
// Get the PR's head SHA
|
|
305
|
+
const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid`);
|
|
306
|
+
const prData = JSON.parse(prJson.trim());
|
|
307
|
+
const sha = prData.headRefOid;
|
|
308
|
+
|
|
309
|
+
// Get check runs for this SHA
|
|
310
|
+
const { stdout: checksJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/check-runs --paginate --jq '.check_runs'`);
|
|
311
|
+
const checkRuns = JSON.parse(checksJson.trim() || '[]');
|
|
312
|
+
|
|
313
|
+
// Get commit statuses (some CI systems use status API instead of checks API)
|
|
314
|
+
const { stdout: statusJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/status --jq '.statuses'`);
|
|
315
|
+
const statuses = JSON.parse(statusJson.trim() || '[]');
|
|
316
|
+
|
|
317
|
+
// Combine both check runs and statuses
|
|
318
|
+
const allChecks = [
|
|
319
|
+
...checkRuns.map(check => ({
|
|
320
|
+
name: check.name,
|
|
321
|
+
status: check.status,
|
|
322
|
+
conclusion: check.conclusion,
|
|
323
|
+
type: 'check_run',
|
|
324
|
+
})),
|
|
325
|
+
...statuses.map(status => ({
|
|
326
|
+
name: status.context,
|
|
327
|
+
status: status.state === 'pending' ? 'in_progress' : 'completed',
|
|
328
|
+
conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
|
|
329
|
+
type: 'status',
|
|
330
|
+
})),
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const hasPending = allChecks.some(c => c.status !== 'completed' || c.conclusion === null);
|
|
334
|
+
const allPassed = !hasPending && allChecks.every(c => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral');
|
|
335
|
+
const hasFailed = allChecks.some(c => c.conclusion === 'failure' || c.conclusion === 'cancelled' || c.conclusion === 'timed_out');
|
|
336
|
+
|
|
337
|
+
let status;
|
|
338
|
+
if (hasPending) {
|
|
339
|
+
status = 'pending';
|
|
340
|
+
} else if (allPassed) {
|
|
341
|
+
status = 'success';
|
|
342
|
+
} else if (hasFailed) {
|
|
343
|
+
status = 'failure';
|
|
344
|
+
} else {
|
|
345
|
+
status = 'unknown';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (verbose) {
|
|
349
|
+
console.log(`[VERBOSE] /merge: PR #${prNumber} CI status: ${status}`);
|
|
350
|
+
console.log(`[VERBOSE] /merge: Checks: ${allChecks.length}, Passed: ${allPassed}, Pending: ${hasPending}`);
|
|
351
|
+
for (const check of allChecks) {
|
|
352
|
+
console.log(`[VERBOSE] /merge: - ${check.name}: ${check.status}/${check.conclusion}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
status,
|
|
358
|
+
checks: allChecks,
|
|
359
|
+
allPassed,
|
|
360
|
+
hasPending,
|
|
361
|
+
};
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (verbose) {
|
|
364
|
+
console.log(`[VERBOSE] /merge: Error checking CI status: ${error.message}`);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
status: 'unknown',
|
|
368
|
+
checks: [],
|
|
369
|
+
allPassed: false,
|
|
370
|
+
hasPending: false,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Check if PR is mergeable
|
|
377
|
+
* @param {string} owner - Repository owner
|
|
378
|
+
* @param {string} repo - Repository name
|
|
379
|
+
* @param {number} prNumber - Pull request number
|
|
380
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
381
|
+
* @returns {Promise<{mergeable: boolean, reason: string|null}>}
|
|
382
|
+
*/
|
|
383
|
+
export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
|
|
384
|
+
try {
|
|
385
|
+
const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
|
|
386
|
+
const pr = JSON.parse(stdout.trim());
|
|
387
|
+
|
|
388
|
+
const mergeable = pr.mergeable === 'MERGEABLE';
|
|
389
|
+
let reason = null;
|
|
390
|
+
|
|
391
|
+
if (!mergeable) {
|
|
392
|
+
switch (pr.mergeStateStatus) {
|
|
393
|
+
case 'BLOCKED':
|
|
394
|
+
reason = 'PR is blocked (possibly by branch protection rules)';
|
|
395
|
+
break;
|
|
396
|
+
case 'BEHIND':
|
|
397
|
+
reason = 'PR branch is behind the base branch';
|
|
398
|
+
break;
|
|
399
|
+
case 'DIRTY':
|
|
400
|
+
reason = 'PR has merge conflicts';
|
|
401
|
+
break;
|
|
402
|
+
case 'UNSTABLE':
|
|
403
|
+
reason = 'PR has failing required status checks';
|
|
404
|
+
break;
|
|
405
|
+
case 'DRAFT':
|
|
406
|
+
reason = 'PR is a draft';
|
|
407
|
+
break;
|
|
408
|
+
default:
|
|
409
|
+
reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (verbose) {
|
|
414
|
+
console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { mergeable, reason };
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (verbose) {
|
|
420
|
+
console.log(`[VERBOSE] /merge: Error checking mergeability: ${error.message}`);
|
|
421
|
+
}
|
|
422
|
+
return { mergeable: false, reason: error.message };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Merge a pull request
|
|
428
|
+
* @param {string} owner - Repository owner
|
|
429
|
+
* @param {string} repo - Repository name
|
|
430
|
+
* @param {number} prNumber - Pull request number
|
|
431
|
+
* @param {Object} options - Merge options
|
|
432
|
+
* @param {boolean} options.squash - Whether to squash merge (default: false, uses default merge method)
|
|
433
|
+
* @param {boolean} options.deleteAfter - Whether to delete branch after merge (default: false)
|
|
434
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
435
|
+
* @returns {Promise<{success: boolean, error: string|null}>}
|
|
436
|
+
*/
|
|
437
|
+
export async function mergePullRequest(owner, repo, prNumber, options = {}, verbose = false) {
|
|
438
|
+
const { squash = false, deleteAfter = false } = options;
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
let mergeArgs = `--repo ${owner}/${repo}`;
|
|
442
|
+
if (squash) {
|
|
443
|
+
mergeArgs += ' --squash';
|
|
444
|
+
}
|
|
445
|
+
if (deleteAfter) {
|
|
446
|
+
mergeArgs += ' --delete-branch';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const { stdout } = await exec(`gh pr merge ${prNumber} ${mergeArgs}`);
|
|
450
|
+
|
|
451
|
+
if (verbose) {
|
|
452
|
+
console.log(`[VERBOSE] /merge: Successfully merged PR #${prNumber}`);
|
|
453
|
+
if (stdout) console.log(`[VERBOSE] /merge: stdout: ${stdout.trim()}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return { success: true, error: null };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (verbose) {
|
|
459
|
+
console.log(`[VERBOSE] /merge: Failed to merge PR #${prNumber}: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
return { success: false, error: error.message };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Wait for CI/CD to complete with polling
|
|
467
|
+
* @param {string} owner - Repository owner
|
|
468
|
+
* @param {string} repo - Repository name
|
|
469
|
+
* @param {number} prNumber - Pull request number
|
|
470
|
+
* @param {Object} options - Wait options
|
|
471
|
+
* @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
|
|
472
|
+
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
473
|
+
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
474
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
475
|
+
* @returns {Promise<{success: boolean, status: string, error: string|null}>}
|
|
476
|
+
*/
|
|
477
|
+
export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
|
|
478
|
+
const { timeout = 30 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
|
|
479
|
+
|
|
480
|
+
const startTime = Date.now();
|
|
481
|
+
|
|
482
|
+
while (Date.now() - startTime < timeout) {
|
|
483
|
+
const ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
|
|
484
|
+
|
|
485
|
+
if (onStatusUpdate) {
|
|
486
|
+
await onStatusUpdate(ciStatus);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (ciStatus.status === 'success') {
|
|
490
|
+
return { success: true, status: 'success', error: null };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (ciStatus.status === 'failure') {
|
|
494
|
+
return { success: false, status: 'failure', error: 'CI checks failed' };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (ciStatus.status === 'pending') {
|
|
498
|
+
if (verbose) {
|
|
499
|
+
console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
|
|
500
|
+
}
|
|
501
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Unknown status - wait and retry
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Parse and validate a repository URL for the merge command
|
|
514
|
+
* @param {string} url - Repository URL
|
|
515
|
+
* @returns {{valid: boolean, owner: string|null, repo: string|null, error: string|null}}
|
|
516
|
+
*/
|
|
517
|
+
export function parseRepositoryUrl(url) {
|
|
518
|
+
const parsed = parseGitHubUrl(url);
|
|
519
|
+
|
|
520
|
+
if (!parsed.valid) {
|
|
521
|
+
return { valid: false, owner: null, repo: null, error: parsed.error };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Accept repo, issues_list, pulls_list, or organization URLs
|
|
525
|
+
if (parsed.type === 'repo' || parsed.type === 'issues_list' || parsed.type === 'pulls_list') {
|
|
526
|
+
return { valid: true, owner: parsed.owner, repo: parsed.repo, error: null };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (parsed.type === 'user' || parsed.type === 'organization') {
|
|
530
|
+
return {
|
|
531
|
+
valid: false,
|
|
532
|
+
owner: parsed.owner,
|
|
533
|
+
repo: null,
|
|
534
|
+
error: 'URL points to a user/organization. Please provide a specific repository URL.',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
valid: false,
|
|
540
|
+
owner: parsed.owner,
|
|
541
|
+
repo: parsed.repo,
|
|
542
|
+
error: `URL type '${parsed.type}' is not supported for merge queue. Please provide a repository URL.`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export default {
|
|
547
|
+
READY_LABEL,
|
|
548
|
+
checkReadyLabelExists,
|
|
549
|
+
createReadyLabel,
|
|
550
|
+
checkLabelPermissions,
|
|
551
|
+
ensureReadyLabel,
|
|
552
|
+
fetchReadyPullRequests,
|
|
553
|
+
fetchReadyIssuesWithPRs,
|
|
554
|
+
getAllReadyPRs,
|
|
555
|
+
checkPRCIStatus,
|
|
556
|
+
checkPRMergeable,
|
|
557
|
+
mergePullRequest,
|
|
558
|
+
waitForCI,
|
|
559
|
+
parseRepositoryUrl,
|
|
560
|
+
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -762,8 +762,11 @@ bot.command('help', async ctx => {
|
|
|
762
762
|
message += '*/limits* - Show usage limits\n';
|
|
763
763
|
message += '*/version* - Show bot and runtime versions\n';
|
|
764
764
|
message += '*/accept\\_invites* - Accept all pending GitHub invitations\n';
|
|
765
|
+
message += '*/merge* - Merge queue (experimental)\n';
|
|
766
|
+
message += 'Usage: `/merge <github-repo-url>`\n';
|
|
767
|
+
message += "Merges all PRs with 'ready' label sequentially.\n";
|
|
765
768
|
message += '*/help* - Show this help message\n\n';
|
|
766
|
-
message += '⚠️ *Note:* /solve, /hive, /limits, /version
|
|
769
|
+
message += '⚠️ *Note:* /solve, /hive, /limits, /version, /accept\\_invites and /merge commands only work in group chats.\n\n';
|
|
767
770
|
message += '🔧 *Common Options:*\n';
|
|
768
771
|
message += '• `--model <model>` or `-m` - Specify AI model (sonnet, opus, haiku, haiku-3-5, haiku-3)\n';
|
|
769
772
|
message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
|
|
@@ -901,6 +904,17 @@ registerAcceptInvitesCommand(bot, {
|
|
|
901
904
|
addBreadcrumb,
|
|
902
905
|
});
|
|
903
906
|
|
|
907
|
+
// Register /merge command from separate module (experimental, see issue #1143)
|
|
908
|
+
const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
|
|
909
|
+
registerMergeCommand(bot, {
|
|
910
|
+
VERBOSE,
|
|
911
|
+
isOldMessage,
|
|
912
|
+
isForwardedOrReply,
|
|
913
|
+
isGroupChat,
|
|
914
|
+
isChatAuthorized,
|
|
915
|
+
addBreadcrumb,
|
|
916
|
+
});
|
|
917
|
+
|
|
904
918
|
bot.command(/^solve$/i, async ctx => {
|
|
905
919
|
if (VERBOSE) {
|
|
906
920
|
console.log('[VERBOSE] /solve command received');
|
|
@@ -1460,14 +1474,12 @@ bot.telegram
|
|
|
1460
1474
|
process.exit(1);
|
|
1461
1475
|
});
|
|
1462
1476
|
|
|
1463
|
-
// Helper to stop solve queue gracefully on shutdown
|
|
1464
|
-
// See: https://github.com/link-assistant/hive-mind/issues/1083
|
|
1477
|
+
// Helper to stop solve queue gracefully on shutdown (see issue #1083)
|
|
1465
1478
|
const stopSolveQueue = () => {
|
|
1466
1479
|
try {
|
|
1467
1480
|
getSolveQueue({ verbose: VERBOSE }).stop();
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
if (VERBOSE) console.log('[VERBOSE] Could not stop solve queue:', err.message);
|
|
1481
|
+
} catch {
|
|
1482
|
+
/* ignore errors during shutdown */
|
|
1471
1483
|
}
|
|
1472
1484
|
};
|
|
1473
1485
|
|
|
@@ -1481,10 +1493,8 @@ process.once('SIGINT', () => {
|
|
|
1481
1493
|
|
|
1482
1494
|
process.once('SIGTERM', () => {
|
|
1483
1495
|
isShuttingDown = true;
|
|
1484
|
-
console.log('\n🛑 Received SIGTERM, stopping bot...');
|
|
1496
|
+
console.log('\n🛑 Received SIGTERM, stopping bot... (Check system logs: journalctl -u <service> or dmesg)');
|
|
1485
1497
|
if (VERBOSE) console.log(`[VERBOSE] Signal: SIGTERM, PID: ${process.pid}, PPID: ${process.ppid}`);
|
|
1486
|
-
console.log('ℹ️ SIGTERM is typically sent by: system shutdown, process manager, kill command, or container orchestration');
|
|
1487
|
-
console.log('💡 Check system logs for details: journalctl -u <service> or dmesg');
|
|
1488
1498
|
stopSolveQueue();
|
|
1489
1499
|
bot.stop('SIGTERM');
|
|
1490
1500
|
});
|