bosun 0.36.2 → 0.36.4

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.
Files changed (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. package/workspace-reaper.mjs +0 -405
@@ -1,992 +0,0 @@
1
- /**
2
- * pr-cleanup-daemon.mjs — Automated PR conflict resolution and CI cleanup
3
- *
4
- * Runs every 30 minutes to:
5
- * 1. Find PRs with conflicts or failing CI
6
- * 2. Spawn codex-sdk agents to resolve issues
7
- * 3. Auto-merge when green
8
- *
9
- * Prevents merge queue bottlenecks by handling conflicts automatically.
10
- */
11
-
12
- import { spawn } from "child_process";
13
- import { promisify } from "util";
14
- import { exec as execCallback } from "child_process";
15
- import { fileURLToPath } from "url";
16
- import { mkdtemp, rm } from "fs/promises";
17
- import { tmpdir } from "os";
18
- import { join } from "path";
19
-
20
- const exec = promisify(execCallback);
21
-
22
- /**
23
- * Check if a branch is already checked out in an existing git worktree.
24
- * Returns the worktree path if claimed, or null if free.
25
- */
26
- async function getWorktreeForBranch(branch, repoRoot) {
27
- try {
28
- const { stdout } = await exec(`git worktree list --porcelain`, { cwd: repoRoot });
29
- // Each worktree block is separated by a blank line.
30
- // Look for a line like: branch refs/heads/<branch>
31
- const blocks = stdout.split(/\n\n/);
32
- for (const block of blocks) {
33
- if (block.includes(`branch refs/heads/${branch}`)) {
34
- const wtMatch = block.match(/^worktree\s+(.+)$/m);
35
- return wtMatch ? wtMatch[1] : "unknown";
36
- }
37
- }
38
- return null;
39
- } catch {
40
- // If git worktree list itself fails, assume branch is free
41
- return null;
42
- }
43
- }
44
-
45
- // ── Configuration ────────────────────────────────────────────────────────────
46
-
47
- const CONFIG = {
48
- intervalMs: 10 * 60 * 1000, // 10 minutes — fast turnaround for agent PRs
49
- maxConcurrentCleanups: 3, // Don't overwhelm system
50
- conflictStrategy: "ours-with-review", // Prefer incoming changes, flag risky merges
51
- autoMerge: true, // Auto-merge if CI green after cleanup
52
- dryRun: false, // Set true to log actions without executing
53
- excludeLabels: ["do-not-merge", "wip", "draft"], // Skip PRs with these labels
54
- maxConflictSize: 500, // Max lines of conflict to auto-resolve (escalate if larger)
55
- postConflictRecheckAttempts: 6, // GitHub mergeability can lag after force-push
56
- postConflictRecheckDelayMs: 10_000,
57
- };
58
-
59
- // ── PR Cleanup Daemon ────────────────────────────────────────────────────────
60
-
61
- class PRCleanupDaemon {
62
- constructor(config = CONFIG) {
63
- this.config = {
64
- ...CONFIG,
65
- ...(config && typeof config === "object" ? config : {}),
66
- };
67
- this.repoRoot = this.config.repoRoot || process.cwd();
68
- this.cleanupQueue = [];
69
- this.activeCleanups = new Map(); // pr# → cleanup state
70
- this.lastRunStartedAt = 0;
71
- this.lastRunFinishedAt = 0;
72
- this.stats = {
73
- totalRuns: 0,
74
- prsProcessed: 0,
75
- conflictsResolved: 0,
76
- ciRetriggers: 0,
77
- autoMerges: 0,
78
- escalations: 0,
79
- errors: 0,
80
- };
81
- }
82
-
83
- /**
84
- * Extract base branch from PR metadata, stripping origin/ prefix.
85
- * Falls back to "main" if baseRefName is missing.
86
- * @param {Object} pr - PR object with optional baseRefName
87
- * @returns {string}
88
- */
89
- getBaseBranch(pr) {
90
- const base = pr?.baseRefName || "main";
91
- return base.replace(/^origin\//, "");
92
- }
93
-
94
- /**
95
- * Main daemon loop — fetch PRs and process cleanup queue
96
- */
97
- async run() {
98
- this.stats.totalRuns++;
99
- this.lastRunStartedAt = Date.now();
100
- console.log(`[pr-cleanup-daemon] Run #${this.stats.totalRuns} starting...`);
101
-
102
- try {
103
- // 1. Fetch PRs needing attention
104
- const prs = await this.fetchProblematicPRs();
105
- console.log(
106
- `[pr-cleanup-daemon] Found ${prs.length} PRs needing cleanup`,
107
- );
108
-
109
- // 2. Add to queue (dedup by PR number)
110
- for (const pr of prs) {
111
- if (
112
- !this.cleanupQueue.some((p) => p.number === pr.number) &&
113
- !this.activeCleanups.has(pr.number)
114
- ) {
115
- this.cleanupQueue.push(pr);
116
- }
117
- }
118
-
119
- // 3. Process queue (up to max concurrent)
120
- while (
121
- this.cleanupQueue.length > 0 &&
122
- this.activeCleanups.size < this.config.maxConcurrentCleanups
123
- ) {
124
- const pr = this.cleanupQueue.shift();
125
- void this.processPR(pr); // Don't await — run in parallel
126
- }
127
-
128
- // 4. Also scan for green PRs ready to merge (not just problematic ones)
129
- if (this.config.autoMerge) {
130
- try {
131
- await this.mergeGreenPRs();
132
- } catch (e) {
133
- console.warn(
134
- `[pr-cleanup-daemon] Green PR scan failed: ${e?.message ?? String(e)}`,
135
- );
136
- }
137
- }
138
-
139
- // 5. Log stats
140
- console.log(`[pr-cleanup-daemon] Stats:`, this.stats);
141
- console.log(
142
- `[pr-cleanup-daemon] Active cleanups: ${this.activeCleanups.size}, Queued: ${this.cleanupQueue.length}`,
143
- );
144
- } catch (err) {
145
- this.stats.errors++;
146
- console.error(
147
- `[pr-cleanup-daemon] Run failed:`,
148
- err?.message ?? String(err),
149
- );
150
- } finally {
151
- this.lastRunFinishedAt = Date.now();
152
- }
153
- }
154
-
155
- /**
156
- * Fetch PRs with conflicts or failing CI
157
- * @returns {Promise<Array>} PRs needing cleanup
158
- */
159
- async fetchProblematicPRs() {
160
- try {
161
- const { stdout } = await exec(
162
- `gh pr list --json number,title,mergeable,labels,statusCheckRollup,headRefName --limit 50`,
163
- { cwd: this.repoRoot },
164
- );
165
- const allPRs = JSON.parse(stdout);
166
-
167
- const problematicPRs = [];
168
-
169
- for (const pr of allPRs) {
170
- // Skip excluded labels (guard against missing labels or config)
171
- const excludeLabels = this.config.excludeLabels || [];
172
- if (
173
- Array.isArray(pr.labels) &&
174
- pr.labels.some((l) => l?.name && excludeLabels.includes(l.name))
175
- ) {
176
- continue;
177
- }
178
-
179
- // Check for conflicts
180
- if (pr.mergeable === "CONFLICTING") {
181
- problematicPRs.push({
182
- ...pr,
183
- issue: "conflict",
184
- priority: 1, // High priority
185
- });
186
- continue;
187
- }
188
-
189
- // Check for failing CI
190
- if (
191
- Array.isArray(pr.statusCheckRollup) &&
192
- pr.statusCheckRollup.some((check) => check?.conclusion === "FAILURE")
193
- ) {
194
- problematicPRs.push({
195
- ...pr,
196
- issue: "ci_failure",
197
- priority: 2, // Medium priority
198
- });
199
- continue;
200
- }
201
- }
202
-
203
- // Sort by priority (conflicts first)
204
- return problematicPRs.sort((a, b) => a.priority - b.priority);
205
- } catch (err) {
206
- // Handle rate limiting gracefully
207
- const errMsg =
208
- typeof err?.message === "string" ? err.message : String(err);
209
- if (errMsg.includes("HTTP 429") || errMsg.includes("rate limit")) {
210
- console.warn(
211
- `[pr-cleanup-daemon] GitHub API rate limited - will retry next cycle`,
212
- );
213
- return [];
214
- }
215
-
216
- console.error(`[pr-cleanup-daemon] Failed to fetch PRs:`, errMsg);
217
- return [];
218
- }
219
- }
220
-
221
- /**
222
- * Process a single PR — resolve conflicts or fix CI
223
- * @param {object} pr - PR metadata
224
- */
225
- async processPR(pr) {
226
- this.stats.prsProcessed++;
227
- this.activeCleanups.set(pr.number, { startedAt: Date.now(), pr });
228
-
229
- console.log(
230
- `[pr-cleanup-daemon] Processing PR #${pr.number}: ${pr.title} (${pr.issue})`,
231
- );
232
-
233
- try {
234
- let cleanupAttempted = false;
235
- if (pr.issue === "conflict") {
236
- cleanupAttempted = await this.resolveConflicts(pr);
237
- } else if (pr.issue === "ci_failure") {
238
- await this.fixCI(pr);
239
- cleanupAttempted = true;
240
- }
241
-
242
- // After cleanup, check if ready to merge
243
- if (this.config.autoMerge && cleanupAttempted) {
244
- await this.attemptAutoMerge(pr);
245
- }
246
- } catch (err) {
247
- this.stats.errors++;
248
- console.error(
249
- `[pr-cleanup-daemon] Failed to process PR #${pr.number}:`,
250
- err?.message ?? String(err),
251
- );
252
- } finally {
253
- this.activeCleanups.delete(pr.number);
254
- }
255
- }
256
-
257
- /**
258
- * Resolve conflicts on a PR — tries codex agent first, falls back to local merge
259
- * @param {object} pr - PR metadata
260
- */
261
- async resolveConflicts(pr) {
262
- console.log(`[pr-cleanup-daemon] Resolving conflicts on PR #${pr.number}`);
263
-
264
- // 1. Check conflict size (escalate if too large)
265
- const conflictSize = await this.getConflictSize(pr);
266
- if (conflictSize > this.config.maxConflictSize) {
267
- console.warn(
268
- `[pr-cleanup-daemon] PR #${pr.number} has ${conflictSize} lines of conflicts — escalating to human`,
269
- );
270
- await this.escalate(pr, "large_conflict", { lines: conflictSize });
271
- this.stats.escalations++;
272
- return false;
273
- }
274
-
275
- // 2. Try codex-sdk agent first, fall back to local merge
276
- if (this.config.dryRun) {
277
- console.log(
278
- `[pr-cleanup-daemon] [DRY RUN] Would resolve conflicts for PR #${pr.number}`,
279
- );
280
- return true;
281
- }
282
-
283
- let resolvedVia = null;
284
-
285
- try {
286
- await this.spawnCodexAgent({
287
- task: `resolve-pr-conflicts`,
288
- pr: pr.number,
289
- branch: pr.headRefName,
290
- strategy: this.config.conflictStrategy,
291
- ciWait: true,
292
- });
293
- resolvedVia = "agent";
294
- console.log(`[pr-cleanup-daemon] ✓ Agent resolved PR #${pr.number}`);
295
- } catch (agentErr) {
296
- console.warn(
297
- `[pr-cleanup-daemon] Codex agent failed for PR #${pr.number}, trying local merge: ${agentErr.message}`,
298
- );
299
-
300
- // Fallback: resolve locally using temporary worktree
301
- try {
302
- await this.resolveConflictsLocally(pr);
303
- resolvedVia = "local";
304
- console.log(`[pr-cleanup-daemon] ✓ Local merge resolved PR #${pr.number}`);
305
- } catch (localErr) {
306
- console.error(
307
- `[pr-cleanup-daemon] Failed to resolve conflicts on PR #${pr.number}:`,
308
- localErr.message,
309
- );
310
- await this.escalate(pr, "conflict_resolution_failed", {
311
- error: localErr.message,
312
- });
313
- this.stats.escalations++;
314
- return false;
315
- }
316
- }
317
-
318
- if (!resolvedVia) return false;
319
-
320
- const verified = await this.waitForMergeableState(pr.number, {
321
- attempts: this.config.postConflictRecheckAttempts,
322
- delayMs: this.config.postConflictRecheckDelayMs,
323
- context: `post-${resolvedVia}-resolution`,
324
- });
325
-
326
- if (verified.mergeable === "MERGEABLE") {
327
- this.stats.conflictsResolved++;
328
- console.log(
329
- `[pr-cleanup-daemon] :check: Verified conflict resolution on PR #${pr.number} (mergeable=${verified.mergeable})`,
330
- );
331
- return true;
332
- }
333
-
334
- // A successful agent run can still leave GitHub in CONFLICTING state (stale
335
- // merge base or partial resolution). Give one deterministic local pass.
336
- if (resolvedVia !== "local") {
337
- console.warn(
338
- `[pr-cleanup-daemon] PR #${pr.number} still ${verified.mergeable || "UNMERGEABLE"} after agent resolution — attempting local fallback`,
339
- );
340
- try {
341
- await this.resolveConflictsLocally(pr);
342
- const verifiedLocal = await this.waitForMergeableState(pr.number, {
343
- attempts: this.config.postConflictRecheckAttempts,
344
- delayMs: this.config.postConflictRecheckDelayMs,
345
- context: "post-local-fallback",
346
- });
347
- if (verifiedLocal.mergeable === "MERGEABLE") {
348
- this.stats.conflictsResolved++;
349
- console.log(
350
- `[pr-cleanup-daemon] :check: Verified conflict resolution on PR #${pr.number} after local fallback`,
351
- );
352
- return true;
353
- }
354
- console.warn(
355
- `[pr-cleanup-daemon] PR #${pr.number} still not mergeable after local fallback: ${verifiedLocal.mergeable}`,
356
- );
357
- } catch (localFallbackErr) {
358
- console.warn(
359
- `[pr-cleanup-daemon] Local fallback failed for PR #${pr.number}: ${localFallbackErr.message}`,
360
- );
361
- }
362
- }
363
-
364
- await this.escalate(pr, "conflict_still_present_after_resolution", {
365
- mergeable: verified.mergeable || "UNKNOWN",
366
- strategy: this.config.conflictStrategy,
367
- resolvedVia,
368
- });
369
- this.stats.escalations++;
370
- return false;
371
- }
372
-
373
- /**
374
- * Resolve conflicts locally using a temporary worktree and merge
375
- * Only handles auto-resolvable conflicts (lockfiles, generated files)
376
- * @param {object} pr - PR metadata
377
- */
378
- async resolveConflictsLocally(pr) {
379
- let tmpDir;
380
- try {
381
- tmpDir = await mkdtemp(join(tmpdir(), "pr-merge-"));
382
-
383
- // Fetch all relevant refs
384
- await exec(`git fetch origin ${pr.headRefName} main`, { cwd: this.repoRoot });
385
-
386
- // Guard: skip if the branch is already claimed by another worktree
387
- const existingWt = await getWorktreeForBranch(pr.headRefName, this.repoRoot);
388
- if (existingWt) {
389
- console.warn(
390
- `[pr-cleanup-daemon] WARN: Branch "${pr.headRefName}" is in an active worktree at ${existingWt} — skipping conflict resolution`,
391
- );
392
- return;
393
- }
394
-
395
- // Create worktree on the PR branch
396
- await exec(
397
- `git worktree add "${tmpDir}" "origin/${pr.headRefName}" --detach`,
398
- { cwd: this.repoRoot },
399
- );
400
- await exec(
401
- `git checkout -B "${pr.headRefName}" "origin/${pr.headRefName}"`,
402
- { cwd: tmpDir },
403
- );
404
-
405
- // Attempt merge with main
406
- try {
407
- await exec(`git merge origin/main --no-edit`, { cwd: tmpDir });
408
- } catch {
409
- // Merge has conflicts — try auto-resolving known file types
410
- const { stdout: conflictFiles } = await exec(
411
- `git diff --name-only --diff-filter=U`,
412
- { cwd: tmpDir },
413
- ).catch(() => ({ stdout: "" }));
414
-
415
- const files = conflictFiles.trim().split("\n").filter(Boolean);
416
- const autoResolvable = [
417
- "pnpm-lock.yaml",
418
- "package-lock.json",
419
- "yarn.lock",
420
- "go.sum",
421
- "coverage.txt",
422
- "results.txt",
423
- "package.json",
424
- ];
425
-
426
- let allResolved = true;
427
- for (const file of files) {
428
- const basename = file.split("/").pop();
429
- if (autoResolvable.includes(basename) || basename.endsWith(".lock")) {
430
- // Accept theirs (main) for lockfiles, ours for coverage/results
431
- const strategy = [
432
- "coverage.txt",
433
- "results.txt",
434
- "CHANGELOG.md",
435
- ].includes(basename)
436
- ? "--ours"
437
- : "--theirs";
438
- await exec(`git checkout ${strategy} -- "${file}"`, {
439
- cwd: tmpDir,
440
- });
441
- await exec(`git add "${file}"`, { cwd: tmpDir });
442
- } else {
443
- allResolved = false;
444
- }
445
- }
446
-
447
- if (!allResolved) {
448
- await exec(`git merge --abort`, { cwd: tmpDir }).catch(() => {});
449
- throw new Error(
450
- `Cannot auto-resolve all conflicts: ${files.join(", ")}`,
451
- );
452
- }
453
-
454
- // Commit the resolved merge
455
- await exec(`git commit --no-edit`, { cwd: tmpDir });
456
- }
457
-
458
- // Push the merged branch
459
- await exec(`git push origin "${pr.headRefName}"`, { cwd: tmpDir });
460
- } finally {
461
- if (tmpDir) {
462
- try {
463
- await exec(`git worktree remove "${tmpDir}" --force`, { cwd: this.repoRoot });
464
- } catch {
465
- try {
466
- await rm(tmpDir, { recursive: true, force: true });
467
- } catch {}
468
- try {
469
- await exec(`git worktree prune`, { cwd: this.repoRoot });
470
- } catch {}
471
- }
472
- }
473
- }
474
- }
475
-
476
- /**
477
- * Fix failing CI on a PR
478
- * @param {object} pr - PR metadata
479
- */
480
- async fixCI(pr) {
481
- console.log(`[pr-cleanup-daemon] Fixing CI on PR #${pr.number}`);
482
-
483
- // Get failing checks
484
- const checks = Array.isArray(pr.statusCheckRollup)
485
- ? pr.statusCheckRollup
486
- : [];
487
- const failedChecks = checks.filter((c) => c?.conclusion === "FAILURE");
488
- console.log(
489
- `[pr-cleanup-daemon] PR #${pr.number} has ${failedChecks.length} failed checks:`,
490
- failedChecks.map((c) => c?.name).join(", "),
491
- );
492
-
493
- // For now, just re-trigger CI (future: spawn agent to fix specific failures)
494
- if (this.config.dryRun) {
495
- console.log(
496
- `[pr-cleanup-daemon] [DRY RUN] Would re-trigger CI for PR #${pr.number}`,
497
- );
498
- return;
499
- }
500
-
501
- let tmpDir;
502
- try {
503
- // Use a temporary worktree to avoid conflicts with existing checkouts
504
- tmpDir = await mkdtemp(join(tmpdir(), "pr-cleanup-"));
505
-
506
- // Fetch latest refs first
507
- await exec(`git fetch origin ${pr.headRefName}`, { cwd: this.repoRoot });
508
-
509
- // Guard: skip if the branch is already claimed by another worktree
510
- const existingWt = await getWorktreeForBranch(pr.headRefName, this.repoRoot);
511
- if (existingWt) {
512
- console.warn(
513
- `[pr-cleanup-daemon] WARN: Branch "${pr.headRefName}" is in an active worktree at ${existingWt} — skipping CI re-trigger`,
514
- );
515
- return;
516
- }
517
-
518
- // Create a temporary worktree for the PR branch
519
- await exec(
520
- `git worktree add "${tmpDir}" "origin/${pr.headRefName}" --detach`,
521
- { cwd: this.repoRoot },
522
- );
523
-
524
- // Checkout the branch properly inside the worktree
525
- await exec(
526
- `git checkout -B "${pr.headRefName}" "origin/${pr.headRefName}"`,
527
- { cwd: tmpDir },
528
- );
529
-
530
- // Push empty commit to re-trigger CI
531
- await exec(`git commit --allow-empty -m "chore: re-trigger CI"`, {
532
- cwd: tmpDir,
533
- });
534
- await exec(`git push origin "${pr.headRefName}"`, { cwd: tmpDir });
535
-
536
- this.stats.ciRetriggers++;
537
- console.log(`[pr-cleanup-daemon] ✓ Re-triggered CI on PR #${pr.number}`);
538
- } catch (err) {
539
- console.error(
540
- `[pr-cleanup-daemon] Failed to re-trigger CI on PR #${pr.number}:`,
541
- err.message,
542
- );
543
- } finally {
544
- // Clean up the temporary worktree
545
- if (tmpDir) {
546
- try {
547
- await exec(`git worktree remove "${tmpDir}" --force`, { cwd: this.repoRoot });
548
- } catch {
549
- // If worktree remove fails, try manual cleanup
550
- try {
551
- await rm(tmpDir, { recursive: true, force: true });
552
- } catch {}
553
- try {
554
- await exec(`git worktree prune`, { cwd: this.repoRoot });
555
- } catch {}
556
- }
557
- }
558
- }
559
- }
560
-
561
- /**
562
- * Attempt to auto-merge PR if all checks pass
563
- * @param {object} pr - PR metadata
564
- */
565
- async attemptAutoMerge(pr) {
566
- let latest = await this.fetchPrMergeability(pr.number);
567
- if (!latest) {
568
- console.error(
569
- `[pr-cleanup-daemon] Failed to fetch PR #${pr.number} status for auto-merge`,
570
- );
571
- return;
572
- }
573
-
574
- if (latest.mergeable !== "MERGEABLE") {
575
- // Mergeability can be UNKNOWN briefly after pushes/rebases.
576
- if (latest.mergeable === "UNKNOWN") {
577
- const retry = await this.waitForMergeableState(pr.number, {
578
- attempts: 3,
579
- delayMs: 5000,
580
- context: "auto-merge",
581
- });
582
- latest = retry.raw || latest;
583
- }
584
- }
585
-
586
- if (latest.mergeable !== "MERGEABLE") {
587
- console.log(
588
- `[pr-cleanup-daemon] PR #${pr.number} not mergeable: ${latest.mergeable}`,
589
- );
590
- return;
591
- }
592
-
593
- const latestChecks = Array.isArray(latest.statusCheckRollup)
594
- ? latest.statusCheckRollup
595
- : [];
596
- const allGreen =
597
- latestChecks.length > 0 &&
598
- latestChecks.every((c) => c?.conclusion === "SUCCESS");
599
- if (!allGreen) {
600
- console.log(
601
- `[pr-cleanup-daemon] PR #${pr.number} has non-green checks, skipping auto-merge`,
602
- );
603
- return;
604
- }
605
-
606
- if (this.config.dryRun) {
607
- console.log(
608
- `[pr-cleanup-daemon] [DRY RUN] Would auto-merge PR #${pr.number}`,
609
- );
610
- return;
611
- }
612
-
613
- try {
614
- await exec(`gh pr merge ${pr.number} --auto --squash --delete-branch`, { cwd: this.repoRoot });
615
- this.stats.autoMerges++;
616
- console.log(`[pr-cleanup-daemon] ✓ Auto-merged PR #${pr.number}`);
617
- } catch (err) {
618
- console.error(
619
- `[pr-cleanup-daemon] Failed to auto-merge PR #${pr.number}:`,
620
- err?.message ?? String(err),
621
- );
622
- }
623
- }
624
-
625
- /**
626
- * Get conflict size (number of conflicting files) using GitHub API
627
- * Avoids local checkout entirely to prevent worktree/divergence issues
628
- * @param {object} pr - PR metadata
629
- * @returns {Promise<number>} Number of conflict lines (estimated)
630
- */
631
- async getConflictSize(pr) {
632
- try {
633
- // Use GitHub API to get the list of changed files and estimate conflict scope
634
- // This avoids the need for local checkout entirely
635
- const { stdout } = await exec(`gh pr diff ${pr.number} --name-only`, { cwd: this.repoRoot });
636
- const changedFiles = stdout.trim().split("\n").filter(Boolean);
637
-
638
- // Estimate: each changed file could have ~10 lines of conflicts on average
639
- // This is a rough heuristic — the real conflict size can only be known after merge attempt
640
- const estimatedConflictLines = changedFiles.length * 10;
641
- console.log(
642
- `[pr-cleanup-daemon] PR #${pr.number}: ${changedFiles.length} files changed (est. ~${estimatedConflictLines} conflict lines)`,
643
- );
644
- return estimatedConflictLines;
645
- } catch {
646
- // If we can't even get the diff (e.g., too diverged), try merge in temp worktree
647
- let tmpDir;
648
- try {
649
- tmpDir = await mkdtemp(join(tmpdir(), "pr-conflict-"));
650
- await exec(`git fetch origin ${pr.headRefName} main`, { cwd: this.repoRoot });
651
- await exec(`git worktree add "${tmpDir}" "origin/main" --detach`, { cwd: this.repoRoot });
652
-
653
- // Attempt merge to count conflicts
654
- try {
655
- await exec(
656
- `git merge --no-commit --no-ff "origin/${pr.headRefName}"`,
657
- { cwd: tmpDir },
658
- );
659
- // If merge succeeds, no conflicts
660
- await exec(`git merge --abort`, { cwd: tmpDir }).catch(() => {});
661
- return 0;
662
- } catch {
663
- // Count conflicting files
664
- const { stdout: conflictOutput } = await exec(
665
- `git diff --name-only --diff-filter=U`,
666
- { cwd: tmpDir },
667
- ).catch(() => ({ stdout: "" }));
668
- const conflictFiles = conflictOutput
669
- .trim()
670
- .split("\n")
671
- .filter(Boolean);
672
- await exec(`git merge --abort`, { cwd: tmpDir }).catch(() => {});
673
- return conflictFiles.length * 15; // ~15 lines per conflicting file
674
- }
675
- } catch (innerErr) {
676
- console.warn(
677
- `[pr-cleanup-daemon] Could not determine conflict size for PR #${pr.number}:`,
678
- innerErr.message,
679
- );
680
- return 0; // Assume small if can't determine
681
- } finally {
682
- if (tmpDir) {
683
- try {
684
- await exec(`git worktree remove "${tmpDir}" --force`, { cwd: this.repoRoot });
685
- } catch {
686
- try {
687
- await rm(tmpDir, { recursive: true, force: true });
688
- } catch {}
689
- try {
690
- await exec(`git worktree prune`, { cwd: this.repoRoot });
691
- } catch {}
692
- }
693
- }
694
- }
695
- }
696
- }
697
-
698
- /**
699
- * Spawn a codex-sdk agent to handle complex cleanup
700
- * @param {object} opts - Agent options
701
- */
702
- async spawnCodexAgent(opts) {
703
- return new Promise((resolve, reject) => {
704
- const scriptPath = new URL(
705
- "./codex-shell.mjs",
706
- import.meta.url,
707
- ).pathname.replace(/^\/([A-Z]:)/, "$1");
708
- const args = [scriptPath, "spawn-agent", JSON.stringify(opts)];
709
-
710
- const child = spawn("node", args, {
711
- stdio: "inherit",
712
- env: process.env,
713
- cwd: this.repoRoot,
714
- });
715
-
716
- child.on("exit", (code) => {
717
- if (code === 0) {
718
- resolve();
719
- } else {
720
- reject(new Error(`Codex agent exited with code ${code}`));
721
- }
722
- });
723
-
724
- child.on("error", reject);
725
- });
726
- }
727
-
728
- /**
729
- * Scan all open PRs and auto-merge any that are green + mergeable.
730
- * This catches PRs that were created by agents but never had auto-merge enabled.
731
- */
732
- async mergeGreenPRs() {
733
- if (!this.config.autoMerge) {
734
- return;
735
- }
736
- try {
737
- const { stdout } = await exec(
738
- `gh pr list --json number,title,mergeable,statusCheckRollup,headRefName,autoMergeRequest --limit 30`,
739
- { cwd: this.repoRoot },
740
- );
741
- const allPRs = JSON.parse(stdout);
742
-
743
- for (const pr of allPRs) {
744
- // Skip if already has auto-merge enabled
745
- if (pr.autoMergeRequest) continue;
746
-
747
- // Skip excluded labels
748
- const excludeLabels = this.config.excludeLabels || [];
749
- if (
750
- Array.isArray(pr.labels) &&
751
- pr.labels.some((l) => l?.name && excludeLabels.includes(l.name))
752
- )
753
- continue;
754
-
755
- // Only process MERGEABLE PRs
756
- if (pr.mergeable !== "MERGEABLE") continue;
757
-
758
- // Check if all CI checks are green
759
- const checks = Array.isArray(pr.statusCheckRollup)
760
- ? pr.statusCheckRollup
761
- : [];
762
- const hasChecks = checks.length > 0;
763
- const allGreen =
764
- hasChecks &&
765
- checks.every(
766
- (c) =>
767
- c?.conclusion === "SUCCESS" ||
768
- c?.conclusion === "SKIPPED" ||
769
- c?.conclusion === "NEUTRAL",
770
- );
771
-
772
- if (!allGreen) {
773
- // Still pending? Enable auto-merge so it merges when CI passes
774
- const hasPending = checks.some(
775
- (c) => !c?.conclusion || c?.conclusion === "PENDING",
776
- );
777
- if (hasPending && pr.mergeable === "MERGEABLE") {
778
- try {
779
- await exec(
780
- `gh pr merge ${pr.number} --auto --squash --delete-branch`,
781
- { cwd: this.repoRoot },
782
- );
783
- console.log(
784
- `[pr-cleanup-daemon] :clock: Auto-merge queued for PR #${pr.number} (CI pending)`,
785
- );
786
- } catch {
787
- /* auto-merge may not be available */
788
- }
789
- }
790
- continue;
791
- }
792
-
793
- if (this.config.dryRun) {
794
- console.log(
795
- `[pr-cleanup-daemon] [DRY RUN] Would merge green PR #${pr.number}`,
796
- );
797
- continue;
798
- }
799
-
800
- // All green + mergeable → merge now
801
- try {
802
- await exec(`gh pr merge ${pr.number} --squash --delete-branch`, { cwd: this.repoRoot });
803
- this.stats.autoMerges++;
804
- console.log(
805
- `[pr-cleanup-daemon] :check: Auto-merged green PR #${pr.number}: ${pr.title}`,
806
- );
807
- } catch (err) {
808
- // Fallback: enable auto-merge
809
- try {
810
- await exec(
811
- `gh pr merge ${pr.number} --auto --squash --delete-branch`,
812
- { cwd: this.repoRoot },
813
- );
814
- console.log(
815
- `[pr-cleanup-daemon] :clock: Auto-merge enabled for PR #${pr.number}`,
816
- );
817
- } catch {
818
- console.warn(
819
- `[pr-cleanup-daemon] Failed to merge/auto-merge PR #${pr.number}: ${err?.message}`,
820
- );
821
- }
822
- }
823
- }
824
- } catch (err) {
825
- console.warn(
826
- `[pr-cleanup-daemon] Green PR scan error: ${err?.message ?? String(err)}`,
827
- );
828
- }
829
- }
830
-
831
- /**
832
- * Escalate PR to human for manual intervention
833
- * @param {object} pr - PR metadata
834
- * @param {string} reason - Escalation reason
835
- * @param {object} context - Additional context
836
- */
837
- async escalate(pr, reason, context = {}) {
838
- const message =
839
- `:alert: PR #${pr.number} escalated: ${reason}\n\n` +
840
- `Title: ${pr.title}\n` +
841
- `Context: ${JSON.stringify(context, null, 2)}\n\n` +
842
- `Manual intervention required.`;
843
-
844
- console.warn(`[pr-cleanup-daemon] ESCALATION:`, message);
845
-
846
- // Send Telegram notification if configured
847
- if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
848
- try {
849
- await exec(
850
- `curl -s -X POST "https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage" -d chat_id="${process.env.TELEGRAM_CHAT_ID}" -d text="${encodeURIComponent(message)}"`,
851
- );
852
- } catch (err) {
853
- console.error(
854
- `[pr-cleanup-daemon] Failed to send Telegram alert:`,
855
- err?.message ?? String(err),
856
- );
857
- }
858
- }
859
- }
860
-
861
- /**
862
- * Fetch current mergeability/check status for a PR.
863
- * @param {number|string} prNumber
864
- * @returns {Promise<object|null>}
865
- */
866
- async fetchPrMergeability(prNumber) {
867
- try {
868
- const { stdout } = await exec(
869
- `gh pr view ${prNumber} --json mergeable,statusCheckRollup`,
870
- { cwd: this.repoRoot },
871
- );
872
- return JSON.parse(stdout);
873
- } catch (err) {
874
- console.warn(
875
- `[pr-cleanup-daemon] Failed to fetch PR #${prNumber} mergeability: ${err?.message ?? String(err)}`,
876
- );
877
- return null;
878
- }
879
- }
880
-
881
- /**
882
- * Wait for GitHub mergeability state to settle after conflict resolution.
883
- * @param {number|string} prNumber
884
- * @param {{ attempts?: number, delayMs?: number, context?: string }} [opts]
885
- * @returns {Promise<{ mergeable: string, raw: object|null }>}
886
- */
887
- async waitForMergeableState(prNumber, opts = {}) {
888
- const attempts = Math.max(1, Number(opts.attempts || 1));
889
- const delayMs = Math.max(1000, Number(opts.delayMs || 5000));
890
- const context = opts.context || "mergeability-check";
891
-
892
- let last = null;
893
- for (let i = 1; i <= attempts; i++) {
894
- last = await this.fetchPrMergeability(prNumber);
895
- const mergeable = String(last?.mergeable || "UNKNOWN").toUpperCase();
896
- if (mergeable === "MERGEABLE") {
897
- if (i > 1) {
898
- console.log(
899
- `[pr-cleanup-daemon] PR #${prNumber} mergeability settled (${mergeable}) after ${i}/${attempts} checks (${context})`,
900
- );
901
- }
902
- return { mergeable, raw: last };
903
- }
904
- if (mergeable === "CONFLICTING" && i === attempts) {
905
- return { mergeable, raw: last };
906
- }
907
- if (i < attempts) {
908
- await new Promise((resolveDelay) => setTimeout(resolveDelay, delayMs));
909
- }
910
- }
911
- return {
912
- mergeable: String(last?.mergeable || "UNKNOWN").toUpperCase(),
913
- raw: last,
914
- };
915
- }
916
-
917
- /**
918
- * Lightweight status payload for /agents and health diagnostics.
919
- */
920
- getStatus() {
921
- return {
922
- running: !!this.interval,
923
- intervalMs: this.config.intervalMs,
924
- activeCleanups: this.activeCleanups.size,
925
- queuedCleanups: this.cleanupQueue.length,
926
- lastRunStartedAt: this.lastRunStartedAt || 0,
927
- lastRunFinishedAt: this.lastRunFinishedAt || 0,
928
- stats: { ...this.stats },
929
- };
930
- }
931
-
932
- /**
933
- * Start the daemon (run on interval)
934
- */
935
- start() {
936
- console.log(
937
- `[pr-cleanup-daemon] Starting with interval ${this.config.intervalMs}ms`,
938
- );
939
-
940
- // Run immediately on start
941
- void this.run();
942
-
943
- // Then run on interval
944
- this.interval = setInterval(() => {
945
- void this.run();
946
- }, this.config.intervalMs);
947
-
948
- this.interval.unref?.(); // Allow process to exit if this is the only thing running
949
- }
950
-
951
- /**
952
- * Stop the daemon
953
- */
954
- stop() {
955
- if (this.interval) {
956
- clearInterval(this.interval);
957
- this.interval = null;
958
- console.log(`[pr-cleanup-daemon] Stopped`);
959
- }
960
- }
961
- }
962
-
963
- // ── CLI Entry Point ──────────────────────────────────────────────────────────
964
-
965
- const isMainModule = () => {
966
- try {
967
- const modulePath = fileURLToPath(import.meta.url);
968
- return process.argv[1] === modulePath;
969
- } catch {
970
- return false;
971
- }
972
- };
973
-
974
- if (isMainModule()) {
975
- const daemon = new PRCleanupDaemon();
976
- daemon.start();
977
-
978
- // Graceful shutdown
979
- process.on("SIGINT", () => {
980
- console.log("\n[pr-cleanup-daemon] Received SIGINT, shutting down...");
981
- daemon.stop();
982
- process.exit(0);
983
- });
984
-
985
- process.on("SIGTERM", () => {
986
- console.log("\n[pr-cleanup-daemon] Received SIGTERM, shutting down...");
987
- daemon.stop();
988
- process.exit(0);
989
- });
990
- }
991
-
992
- export { PRCleanupDaemon, CONFIG };