@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- 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 };
|