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.
- package/agent-prompts.mjs +95 -0
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/bosun.schema.json +101 -3
- package/codex-shell.mjs +85 -10
- package/desktop/main.mjs +871 -48
- package/desktop/preload.mjs +54 -1
- package/desktop-shortcut.mjs +90 -11
- package/git-editor-fix.mjs +273 -0
- package/mcp-registry.mjs +579 -0
- package/meeting-workflow-service.mjs +631 -0
- package/monitor.mjs +18 -103
- package/package.json +21 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/setup-web-server.mjs +20 -10
- package/setup.mjs +376 -83
- package/startup-service.mjs +51 -6
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +164 -4
- package/ui/components/agent-selector.js +145 -1
- package/ui/components/chat-view.js +161 -15
- package/ui/components/session-list.js +2 -2
- package/ui/components/shared.js +188 -15
- package/ui/modules/icons.js +13 -0
- package/ui/modules/utils.js +44 -0
- package/ui/modules/voice-client-sdk.js +733 -0
- package/ui/modules/voice-overlay.js +128 -15
- package/ui/modules/voice.js +15 -6
- package/ui/setup.html +281 -81
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +122 -14
- package/ui/styles.css +14 -0
- package/ui/tabs/agents.js +1 -1
- package/ui/tabs/chat.js +123 -14
- package/ui/tabs/control.js +16 -22
- package/ui/tabs/dashboard.js +85 -8
- package/ui/tabs/library.js +113 -17
- package/ui/tabs/settings.js +116 -2
- package/ui/tabs/tasks.js +388 -39
- package/ui/tabs/telemetry.js +0 -1
- package/ui/tabs/workflows.js +4 -0
- package/ui-server.mjs +400 -22
- package/update-check.mjs +41 -13
- package/voice-action-dispatcher.mjs +844 -0
- package/voice-agents-sdk.mjs +664 -0
- package/voice-auth-manager.mjs +164 -0
- package/voice-relay.mjs +1194 -0
- package/voice-tools.mjs +914 -0
- package/workflow-templates/agents.mjs +6 -2
- package/workflow-templates/github.mjs +154 -12
- package/workflow-templates.mjs +3 -0
- package/github-reconciler.mjs +0 -506
- package/merge-strategy.mjs +0 -1210
- package/pr-cleanup-daemon.mjs +0 -992
- package/workspace-reaper.mjs +0 -405
package/github-reconciler.mjs
DELETED
|
@@ -1,506 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
4
|
-
addComment as addKanbanComment,
|
|
5
|
-
getKanbanAdapter,
|
|
6
|
-
getKanbanBackendName,
|
|
7
|
-
updateTaskStatus,
|
|
8
|
-
} from "./kanban-adapter.mjs";
|
|
9
|
-
|
|
10
|
-
const execFileAsync = promisify(execFile);
|
|
11
|
-
const TAG = "[gh-reconciler]";
|
|
12
|
-
|
|
13
|
-
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
|
|
14
|
-
const DEFAULT_MERGED_LOOKBACK_HOURS = 72;
|
|
15
|
-
const DEFAULT_MAX_BACKOFF_MS = 30 * 60 * 1000; // 30 minutes max backoff
|
|
16
|
-
|
|
17
|
-
function parseNumber(value, fallback) {
|
|
18
|
-
const parsed = Number(value);
|
|
19
|
-
return Number.isFinite(parsed) ? parsed : fallback;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function parseRepoSlug(raw) {
|
|
23
|
-
const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
|
24
|
-
if (!text) return "";
|
|
25
|
-
const cleaned = text.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
26
|
-
const [owner, repo] = cleaned.split("/", 2);
|
|
27
|
-
if (!owner || !repo) return "";
|
|
28
|
-
return `${owner}/${repo}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function parseIssueRefs(text) {
|
|
32
|
-
const refs = new Set();
|
|
33
|
-
const raw = String(text || "");
|
|
34
|
-
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)\b/gi;
|
|
35
|
-
let match = re.exec(raw);
|
|
36
|
-
while (match) {
|
|
37
|
-
refs.add(String(match[1]));
|
|
38
|
-
match = re.exec(raw);
|
|
39
|
-
}
|
|
40
|
-
return refs;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function parseIssueFromBranch(branchName) {
|
|
44
|
-
const match = String(branchName || "").trim().match(/^ve\/(\d+)-/i);
|
|
45
|
-
return match?.[1] || null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function normalizeIssueLabels(issue) {
|
|
49
|
-
const labels = Array.isArray(issue?.labels) ? issue.labels : [];
|
|
50
|
-
return labels
|
|
51
|
-
.map((label) =>
|
|
52
|
-
typeof label === "string" ? label : String(label?.name || "").trim(),
|
|
53
|
-
)
|
|
54
|
-
.map((label) => label.toLowerCase())
|
|
55
|
-
.filter(Boolean);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function isIssueInReview(issue) {
|
|
59
|
-
const labels = new Set(normalizeIssueLabels(issue));
|
|
60
|
-
return labels.has("inreview") || labels.has("in-review");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function isTrackingIssue(issue, trackingLabels) {
|
|
64
|
-
const title = String(issue?.title || "").toLowerCase();
|
|
65
|
-
if (title.includes("meta issue") || title.includes("tracker")) {
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
const labels = new Set(normalizeIssueLabels(issue));
|
|
69
|
-
for (const label of trackingLabels) {
|
|
70
|
-
if (labels.has(label)) return true;
|
|
71
|
-
}
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function defaultGh(args) {
|
|
76
|
-
const { stdout } = await execFileAsync("gh", args, {
|
|
77
|
-
encoding: "utf8",
|
|
78
|
-
maxBuffer: 20 * 1024 * 1024,
|
|
79
|
-
timeout: 120_000,
|
|
80
|
-
});
|
|
81
|
-
const raw = String(stdout || "").trim();
|
|
82
|
-
if (!raw) return [];
|
|
83
|
-
return JSON.parse(raw);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function buildIssueMappings(openPrs, mergedPrs) {
|
|
87
|
-
const map = new Map();
|
|
88
|
-
|
|
89
|
-
function ensure(issueNumber) {
|
|
90
|
-
if (!map.has(issueNumber)) {
|
|
91
|
-
map.set(issueNumber, {
|
|
92
|
-
openPrs: [],
|
|
93
|
-
mergedPrs: [],
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
return map.get(issueNumber);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function refsForPr(pr) {
|
|
100
|
-
const refs = new Set();
|
|
101
|
-
for (const issue of parseIssueRefs(pr?.title)) refs.add(issue);
|
|
102
|
-
for (const issue of parseIssueRefs(pr?.body)) refs.add(issue);
|
|
103
|
-
const fromBranch = parseIssueFromBranch(pr?.headRefName);
|
|
104
|
-
if (fromBranch) refs.add(fromBranch);
|
|
105
|
-
return refs;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
for (const pr of openPrs) {
|
|
109
|
-
for (const issueNumber of refsForPr(pr)) {
|
|
110
|
-
ensure(issueNumber).openPrs.push(pr);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
for (const pr of mergedPrs) {
|
|
114
|
-
for (const issueNumber of refsForPr(pr)) {
|
|
115
|
-
ensure(issueNumber).mergedPrs.push(pr);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return map;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export class GitHubReconciler {
|
|
122
|
-
constructor(options = {}) {
|
|
123
|
-
this.enabled = options.enabled !== false;
|
|
124
|
-
this.intervalMs = Math.max(
|
|
125
|
-
30_000,
|
|
126
|
-
parseNumber(options.intervalMs, DEFAULT_INTERVAL_MS),
|
|
127
|
-
);
|
|
128
|
-
this.mergedLookbackHours = Math.max(
|
|
129
|
-
1,
|
|
130
|
-
parseNumber(
|
|
131
|
-
options.mergedLookbackHours,
|
|
132
|
-
DEFAULT_MERGED_LOOKBACK_HOURS,
|
|
133
|
-
),
|
|
134
|
-
);
|
|
135
|
-
this.repoSlug =
|
|
136
|
-
parseRepoSlug(options.repoSlug) ||
|
|
137
|
-
parseRepoSlug(process.env.GITHUB_REPOSITORY) ||
|
|
138
|
-
parseRepoSlug(
|
|
139
|
-
process.env.GITHUB_REPO_OWNER && process.env.GITHUB_REPO_NAME
|
|
140
|
-
? `${process.env.GITHUB_REPO_OWNER}/${process.env.GITHUB_REPO_NAME}`
|
|
141
|
-
: "",
|
|
142
|
-
) ||
|
|
143
|
-
"unknown/unknown";
|
|
144
|
-
this.trackingLabels = new Set(
|
|
145
|
-
(Array.isArray(options.trackingLabels)
|
|
146
|
-
? options.trackingLabels
|
|
147
|
-
: String(options.trackingLabels || "tracking").split(",")
|
|
148
|
-
)
|
|
149
|
-
.map((value) => String(value || "").trim().toLowerCase())
|
|
150
|
-
.filter(Boolean),
|
|
151
|
-
);
|
|
152
|
-
this.addComment = options.addComment || addKanbanComment;
|
|
153
|
-
this.updateTaskStatus = options.updateTaskStatus || updateTaskStatus;
|
|
154
|
-
this.gh = options.gh || defaultGh;
|
|
155
|
-
this.sendTelegram = options.sendTelegram || null;
|
|
156
|
-
this.timer = null;
|
|
157
|
-
this.running = false;
|
|
158
|
-
// Back-off state for rate-limit handling
|
|
159
|
-
this._currentIntervalMs = this.intervalMs;
|
|
160
|
-
this._maxBackoffMs = parseNumber(
|
|
161
|
-
options.maxBackoffMs,
|
|
162
|
-
DEFAULT_MAX_BACKOFF_MS,
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async _listOpenIssues() {
|
|
167
|
-
return await this.gh([
|
|
168
|
-
"issue",
|
|
169
|
-
"list",
|
|
170
|
-
"--repo",
|
|
171
|
-
this.repoSlug,
|
|
172
|
-
"--state",
|
|
173
|
-
"open",
|
|
174
|
-
"--limit",
|
|
175
|
-
"200",
|
|
176
|
-
"--json",
|
|
177
|
-
"number,title,labels,url",
|
|
178
|
-
]);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async _listOpenPrs() {
|
|
182
|
-
return await this.gh([
|
|
183
|
-
"pr",
|
|
184
|
-
"list",
|
|
185
|
-
"--repo",
|
|
186
|
-
this.repoSlug,
|
|
187
|
-
"--state",
|
|
188
|
-
"open",
|
|
189
|
-
"--limit",
|
|
190
|
-
"200",
|
|
191
|
-
"--json",
|
|
192
|
-
"number,title,body,headRefName,url",
|
|
193
|
-
]);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async _listMergedPrs() {
|
|
197
|
-
const since = new Date(
|
|
198
|
-
Date.now() - this.mergedLookbackHours * 60 * 60 * 1000,
|
|
199
|
-
)
|
|
200
|
-
.toISOString()
|
|
201
|
-
.slice(0, 10);
|
|
202
|
-
return await this.gh([
|
|
203
|
-
"pr",
|
|
204
|
-
"list",
|
|
205
|
-
"--repo",
|
|
206
|
-
this.repoSlug,
|
|
207
|
-
"--state",
|
|
208
|
-
"merged",
|
|
209
|
-
"--search",
|
|
210
|
-
`merged:>=${since}`,
|
|
211
|
-
"--limit",
|
|
212
|
-
"200",
|
|
213
|
-
"--json",
|
|
214
|
-
"number,title,body,headRefName,mergedAt,url",
|
|
215
|
-
]);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Fetch issues that were closed within the merged lookback window.
|
|
220
|
-
* These may have been auto-closed by GitHub when a PR merged while Bosun
|
|
221
|
-
* was offline — they never went through the reconciler's normal path.
|
|
222
|
-
*/
|
|
223
|
-
async _listRecentlyClosedIssues() {
|
|
224
|
-
const since = new Date(
|
|
225
|
-
Date.now() - this.mergedLookbackHours * 60 * 60 * 1000,
|
|
226
|
-
)
|
|
227
|
-
.toISOString()
|
|
228
|
-
.slice(0, 10);
|
|
229
|
-
try {
|
|
230
|
-
return await this.gh([
|
|
231
|
-
"issue",
|
|
232
|
-
"list",
|
|
233
|
-
"--repo",
|
|
234
|
-
this.repoSlug,
|
|
235
|
-
"--state",
|
|
236
|
-
"closed",
|
|
237
|
-
"--search",
|
|
238
|
-
`closed:>=${since}`,
|
|
239
|
-
"--limit",
|
|
240
|
-
"200",
|
|
241
|
-
"--json",
|
|
242
|
-
"number,title,labels,url,closedAt",
|
|
243
|
-
]);
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.warn(`${TAG} failed to list recently-closed issues: ${err?.message || err}`);
|
|
246
|
-
return [];
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async reconcileOnce() {
|
|
251
|
-
const backend = String(getKanbanBackendName() || "").toLowerCase();
|
|
252
|
-
if (!this.enabled) {
|
|
253
|
-
return { status: "skipped", reason: "disabled" };
|
|
254
|
-
}
|
|
255
|
-
if (backend !== "github") {
|
|
256
|
-
return { status: "skipped", reason: `backend=${backend || "unknown"}` };
|
|
257
|
-
}
|
|
258
|
-
if (!this.repoSlug || this.repoSlug === "unknown/unknown") {
|
|
259
|
-
return { status: "skipped", reason: "missing-repo" };
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const summary = {
|
|
263
|
-
status: "ok",
|
|
264
|
-
checked: 0,
|
|
265
|
-
closed: 0,
|
|
266
|
-
recentlyClosed: 0,
|
|
267
|
-
inreview: 0,
|
|
268
|
-
normalized: 0,
|
|
269
|
-
skippedTracking: 0,
|
|
270
|
-
projectMismatches: 0,
|
|
271
|
-
errors: 0,
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
// Build a map of project board statuses for issues when in kanban mode
|
|
275
|
-
/** @type {Map<string, string>} issueNumber → project board status */
|
|
276
|
-
const projectStatusMap = new Map();
|
|
277
|
-
const projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues").trim().toLowerCase();
|
|
278
|
-
if (projectMode === "kanban") {
|
|
279
|
-
try {
|
|
280
|
-
const adapter = getKanbanAdapter();
|
|
281
|
-
if (typeof adapter.listTasksFromProject === "function") {
|
|
282
|
-
const projectNumber =
|
|
283
|
-
process.env.GITHUB_PROJECT_NUMBER ||
|
|
284
|
-
process.env.GITHUB_PROJECT_ID ||
|
|
285
|
-
null;
|
|
286
|
-
if (projectNumber) {
|
|
287
|
-
const projectTasks = await adapter.listTasksFromProject(projectNumber);
|
|
288
|
-
for (const task of projectTasks) {
|
|
289
|
-
if (task?.id && task?.status) {
|
|
290
|
-
projectStatusMap.set(String(task.id), task.status);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
} catch (err) {
|
|
296
|
-
console.warn(`${TAG} failed to read project board for reconciliation: ${err?.message || err}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const [issuesRaw, openPrsRaw, mergedPrsRaw, recentlyClosedRaw] = await Promise.all([
|
|
301
|
-
this._listOpenIssues(),
|
|
302
|
-
this._listOpenPrs(),
|
|
303
|
-
this._listMergedPrs(),
|
|
304
|
-
this._listRecentlyClosedIssues(),
|
|
305
|
-
]);
|
|
306
|
-
|
|
307
|
-
const issues = Array.isArray(issuesRaw) ? issuesRaw : [];
|
|
308
|
-
const openPrs = Array.isArray(openPrsRaw) ? openPrsRaw : [];
|
|
309
|
-
const mergedPrs = Array.isArray(mergedPrsRaw) ? mergedPrsRaw : [];
|
|
310
|
-
const recentlyClosed = Array.isArray(recentlyClosedRaw) ? recentlyClosedRaw : [];
|
|
311
|
-
const mappings = buildIssueMappings(openPrs, mergedPrs);
|
|
312
|
-
|
|
313
|
-
for (const issue of issues) {
|
|
314
|
-
const issueNumber = String(issue?.number || "").trim();
|
|
315
|
-
if (!issueNumber) continue;
|
|
316
|
-
summary.checked += 1;
|
|
317
|
-
const mapped = mappings.get(issueNumber) || {
|
|
318
|
-
openPrs: [],
|
|
319
|
-
mergedPrs: [],
|
|
320
|
-
};
|
|
321
|
-
const hasOpenPr = mapped.openPrs.length > 0;
|
|
322
|
-
const hasMergedPr = mapped.mergedPrs.length > 0;
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
if (hasMergedPr) {
|
|
326
|
-
if (isTrackingIssue(issue, this.trackingLabels)) {
|
|
327
|
-
summary.skippedTracking += 1;
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
await this.updateTaskStatus(issueNumber, "done");
|
|
331
|
-
if (this.addComment) {
|
|
332
|
-
const mergedUrls = mapped.mergedPrs
|
|
333
|
-
.slice(0, 3)
|
|
334
|
-
.map((pr) => pr?.url)
|
|
335
|
-
.filter(Boolean);
|
|
336
|
-
const suffix =
|
|
337
|
-
mergedUrls.length > 0
|
|
338
|
-
? `\n\nMerged PR(s):\n${mergedUrls.map((url) => `- ${url}`).join("\n")}`
|
|
339
|
-
: "";
|
|
340
|
-
await this.addComment(
|
|
341
|
-
issueNumber,
|
|
342
|
-
`## :check: Auto-Reconciled\nThis issue was auto-closed by bosun after detecting merged PR linkage.${suffix}`,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
summary.closed += 1;
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
if (hasOpenPr) {
|
|
350
|
-
if (!isIssueInReview(issue)) {
|
|
351
|
-
await this.updateTaskStatus(issueNumber, "inreview");
|
|
352
|
-
summary.inreview += 1;
|
|
353
|
-
}
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (isIssueInReview(issue)) {
|
|
358
|
-
await this.updateTaskStatus(issueNumber, "todo");
|
|
359
|
-
summary.normalized += 1;
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Project board mismatch detection (kanban mode only)
|
|
364
|
-
if (projectStatusMap.size > 0) {
|
|
365
|
-
const projectStatus = projectStatusMap.get(issueNumber);
|
|
366
|
-
if (projectStatus) {
|
|
367
|
-
const issueStatus = isIssueInReview(issue) ? "inreview" : "todo";
|
|
368
|
-
if (projectStatus !== issueStatus && projectStatus !== "todo") {
|
|
369
|
-
// Project board says a different status than issue labels — reconcile
|
|
370
|
-
try {
|
|
371
|
-
await this.updateTaskStatus(issueNumber, projectStatus);
|
|
372
|
-
summary.projectMismatches += 1;
|
|
373
|
-
} catch (syncErr) {
|
|
374
|
-
console.warn(
|
|
375
|
-
`${TAG} failed to sync project status for #${issueNumber}: ${syncErr?.message || syncErr}`,
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
} catch (err) {
|
|
382
|
-
summary.errors += 1;
|
|
383
|
-
console.warn(
|
|
384
|
-
`${TAG} failed reconciling issue #${issueNumber}: ${err?.message || err}`,
|
|
385
|
-
);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Second pass: recently-closed issues that may have been auto-closed by GitHub
|
|
390
|
-
// (when a PR merged while Bosun was offline). Ensure project board is synced to "done".
|
|
391
|
-
for (const issue of recentlyClosed) {
|
|
392
|
-
const issueNumber = String(issue?.number || "").trim();
|
|
393
|
-
if (!issueNumber) continue;
|
|
394
|
-
const mapped = mappings.get(issueNumber);
|
|
395
|
-
const hasMergedPr = mapped && mapped.mergedPrs.length > 0;
|
|
396
|
-
if (!hasMergedPr) continue;
|
|
397
|
-
if (isTrackingIssue(issue, this.trackingLabels)) continue;
|
|
398
|
-
try {
|
|
399
|
-
// updateTaskStatus("done") on an already-closed issue is a no-op for the issue
|
|
400
|
-
// itself but triggers project board sync when in kanban mode.
|
|
401
|
-
await this.updateTaskStatus(issueNumber, "done");
|
|
402
|
-
summary.recentlyClosed += 1;
|
|
403
|
-
} catch (err) {
|
|
404
|
-
summary.errors += 1;
|
|
405
|
-
console.warn(
|
|
406
|
-
`${TAG} failed syncing recently-closed issue #${issueNumber}: ${err?.message || err}`,
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
console.log(
|
|
412
|
-
`${TAG} cycle complete: checked=${summary.checked} closed=${summary.closed} recentlyClosed=${summary.recentlyClosed} inreview=${summary.inreview} normalized=${summary.normalized} skippedTracking=${summary.skippedTracking} projectMismatches=${summary.projectMismatches} errors=${summary.errors}`,
|
|
413
|
-
);
|
|
414
|
-
return summary;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
start() {
|
|
418
|
-
if (this.running) return this;
|
|
419
|
-
this.running = true;
|
|
420
|
-
if (!this.repoSlug || this.repoSlug === "unknown/unknown") {
|
|
421
|
-
console.warn(`${TAG} disabled (missing repo slug)`);
|
|
422
|
-
return this;
|
|
423
|
-
}
|
|
424
|
-
console.log(
|
|
425
|
-
`${TAG} started (repo=${this.repoSlug}, interval=${this.intervalMs}ms, lookback=${this.mergedLookbackHours}h)`,
|
|
426
|
-
);
|
|
427
|
-
this._scheduleNext(0); // run immediately on start
|
|
428
|
-
return this;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Schedule the next reconcile cycle after `delayMs` milliseconds. */
|
|
432
|
-
_scheduleNext(delayMs) {
|
|
433
|
-
if (!this.running) return;
|
|
434
|
-
this.timer = setTimeout(() => {
|
|
435
|
-
this.reconcileOnce()
|
|
436
|
-
.then(() => {
|
|
437
|
-
// Success: reset backoff to base interval
|
|
438
|
-
if (this._currentIntervalMs !== this.intervalMs) {
|
|
439
|
-
console.log(
|
|
440
|
-
`${TAG} rate-limit backoff cleared — restoring interval to ${this.intervalMs}ms`,
|
|
441
|
-
);
|
|
442
|
-
this._currentIntervalMs = this.intervalMs;
|
|
443
|
-
}
|
|
444
|
-
this._scheduleNext(this._currentIntervalMs);
|
|
445
|
-
})
|
|
446
|
-
.catch((err) => {
|
|
447
|
-
const msg = String(err?.message || err);
|
|
448
|
-
console.warn(`${TAG} cycle failed: ${msg}`);
|
|
449
|
-
const isRateLimit =
|
|
450
|
-
msg.includes("rate limit") ||
|
|
451
|
-
msg.includes("API rate limit") ||
|
|
452
|
-
msg.includes("rate-limit");
|
|
453
|
-
if (isRateLimit) {
|
|
454
|
-
// Exponential back-off: double interval up to the max
|
|
455
|
-
this._currentIntervalMs = Math.min(
|
|
456
|
-
this._currentIntervalMs * 2,
|
|
457
|
-
this._maxBackoffMs,
|
|
458
|
-
);
|
|
459
|
-
console.warn(
|
|
460
|
-
`${TAG} rate-limit detected — backing off to ${this._currentIntervalMs}ms`,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
if (this.sendTelegram) {
|
|
464
|
-
void Promise.resolve(
|
|
465
|
-
this.sendTelegram(
|
|
466
|
-
`:alert: GitHub reconciler cycle failed: ${msg}`,
|
|
467
|
-
),
|
|
468
|
-
).catch(() => {});
|
|
469
|
-
}
|
|
470
|
-
this._scheduleNext(this._currentIntervalMs);
|
|
471
|
-
});
|
|
472
|
-
}, delayMs);
|
|
473
|
-
if (this.timer?.unref) this.timer.unref();
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
stop() {
|
|
477
|
-
if (this.timer) {
|
|
478
|
-
clearTimeout(this.timer);
|
|
479
|
-
this.timer = null;
|
|
480
|
-
}
|
|
481
|
-
this.running = false;
|
|
482
|
-
console.log(`${TAG} stopped`);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
let _singleton = null;
|
|
487
|
-
|
|
488
|
-
export function startGitHubReconciler(options = {}) {
|
|
489
|
-
if (_singleton) {
|
|
490
|
-
_singleton.stop();
|
|
491
|
-
}
|
|
492
|
-
_singleton = new GitHubReconciler(options);
|
|
493
|
-
return _singleton.start();
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
export function stopGitHubReconciler() {
|
|
497
|
-
if (_singleton) {
|
|
498
|
-
_singleton.stop();
|
|
499
|
-
_singleton = null;
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
export async function runGitHubReconcilerOnce(options = {}) {
|
|
504
|
-
const reconciler = new GitHubReconciler(options);
|
|
505
|
-
return await reconciler.reconcileOnce();
|
|
506
|
-
}
|