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