@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,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
4
|
+
|
|
5
|
+
if (!token) {
|
|
6
|
+
console.error("Missing TELEGRAM_BOT_TOKEN environment variable.");
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const url = `https://api.telegram.org/bot${token}/getUpdates`;
|
|
11
|
+
|
|
12
|
+
async function main() {
|
|
13
|
+
let res;
|
|
14
|
+
try {
|
|
15
|
+
res = await fetch(url);
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.error(`Fetch error: ${err.message}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text();
|
|
23
|
+
console.error(`Request failed: ${res.status} ${body}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
if (!data.result || data.result.length === 0) {
|
|
29
|
+
console.log(
|
|
30
|
+
"No updates found. Send a message to the bot first, then retry.",
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const chats = new Map();
|
|
36
|
+
for (const update of data.result) {
|
|
37
|
+
const message =
|
|
38
|
+
update.message || update.channel_post || update.edited_message;
|
|
39
|
+
if (!message || !message.chat) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const chat = message.chat;
|
|
43
|
+
if (!chats.has(chat.id)) {
|
|
44
|
+
chats.set(chat.id, {
|
|
45
|
+
id: chat.id,
|
|
46
|
+
type: chat.type,
|
|
47
|
+
title: chat.title || "",
|
|
48
|
+
username: chat.username || "",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (chats.size === 0) {
|
|
54
|
+
console.log(
|
|
55
|
+
"No chat IDs found in updates. Send a message to the bot first.",
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log("Found chat IDs:");
|
|
61
|
+
for (const chat of chats.values()) {
|
|
62
|
+
const titlePart = chat.title ? ` title=\"${chat.title}\"` : "";
|
|
63
|
+
const userPart = chat.username ? ` username=@${chat.username}` : "";
|
|
64
|
+
console.log(`- id=${chat.id} type=${chat.type}${userPart}${titlePart}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
main().catch((err) => {
|
|
69
|
+
console.error(`Error: ${err.message || err}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
package/git-safety.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function runGit(args, cwd, timeout = 15_000) {
|
|
4
|
+
return spawnSync("git", args, {
|
|
5
|
+
cwd,
|
|
6
|
+
encoding: "utf8",
|
|
7
|
+
timeout,
|
|
8
|
+
shell: false,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function countTrackedFiles(cwd, ref) {
|
|
13
|
+
const result = runGit(["ls-tree", "-r", "--name-only", ref], cwd, 30_000);
|
|
14
|
+
if (result.status !== 0) return null;
|
|
15
|
+
const out = (result.stdout || "").trim();
|
|
16
|
+
if (!out) return 0;
|
|
17
|
+
return out.split("\n").filter(Boolean).length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getNumstat(cwd, rangeSpec) {
|
|
21
|
+
const result = runGit(["diff", "--numstat", rangeSpec], cwd, 30_000);
|
|
22
|
+
if (result.status !== 0) return null;
|
|
23
|
+
const out = (result.stdout || "").trim();
|
|
24
|
+
if (!out) {
|
|
25
|
+
return { files: 0, inserted: 0, deleted: 0 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let files = 0;
|
|
29
|
+
let inserted = 0;
|
|
30
|
+
let deleted = 0;
|
|
31
|
+
for (const line of out.split("\n")) {
|
|
32
|
+
if (!line.trim()) continue;
|
|
33
|
+
const [addRaw, delRaw] = line.split("\t");
|
|
34
|
+
files += 1;
|
|
35
|
+
const add = Number.parseInt(addRaw, 10);
|
|
36
|
+
const del = Number.parseInt(delRaw, 10);
|
|
37
|
+
if (Number.isFinite(add)) inserted += add;
|
|
38
|
+
if (Number.isFinite(del)) deleted += del;
|
|
39
|
+
}
|
|
40
|
+
return { files, inserted, deleted };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isSafeGitBranchName(rawBranch) {
|
|
44
|
+
const branch = String(rawBranch || "").trim();
|
|
45
|
+
if (!branch) return false;
|
|
46
|
+
|
|
47
|
+
// Disallow anything that looks like a git option
|
|
48
|
+
if (branch.startsWith("-")) return false;
|
|
49
|
+
|
|
50
|
+
// Disallow whitespace and obvious ref/metacharacters that can change semantics
|
|
51
|
+
if (
|
|
52
|
+
/[\s]/.test(branch) ||
|
|
53
|
+
branch.includes("..") ||
|
|
54
|
+
branch.includes(":") ||
|
|
55
|
+
branch.includes("~") ||
|
|
56
|
+
branch.includes("^") ||
|
|
57
|
+
branch.includes("?") ||
|
|
58
|
+
branch.includes("*") ||
|
|
59
|
+
branch.includes("[") ||
|
|
60
|
+
branch.includes("\\")
|
|
61
|
+
) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Disallow URL-like or SSH-style prefixes to avoid transport/URL interpretation
|
|
66
|
+
const lower = branch.toLowerCase();
|
|
67
|
+
if (
|
|
68
|
+
lower.startsWith("http://") ||
|
|
69
|
+
lower.startsWith("https://") ||
|
|
70
|
+
lower.startsWith("ssh://") ||
|
|
71
|
+
lower.startsWith("git@") ||
|
|
72
|
+
lower.startsWith("file://")
|
|
73
|
+
) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeBaseBranch(baseBranch = "main", remote = "origin") {
|
|
81
|
+
let branch = String(baseBranch || "main").trim();
|
|
82
|
+
if (!branch) branch = "main";
|
|
83
|
+
|
|
84
|
+
branch = branch.replace(/^refs\/heads\//, "");
|
|
85
|
+
branch = branch.replace(/^refs\/remotes\//, "");
|
|
86
|
+
|
|
87
|
+
while (branch.startsWith(`${remote}/`)) {
|
|
88
|
+
branch = branch.slice(remote.length + 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!branch) branch = "main";
|
|
92
|
+
|
|
93
|
+
if (!isSafeGitBranchName(branch)) {
|
|
94
|
+
throw new Error(`Invalid base branch name: ${branch}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { branch, remoteRef: `${remote}/${branch}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Prevent catastrophic pushes when a worktree is in a corrupted state
|
|
102
|
+
* (for example, a branch that suddenly tracks only README and would
|
|
103
|
+
* delete the whole repo on push).
|
|
104
|
+
*/
|
|
105
|
+
export function evaluateBranchSafetyForPush(worktreePath, opts = {}) {
|
|
106
|
+
const { baseBranch = "main", remote = "origin" } = opts;
|
|
107
|
+
|
|
108
|
+
if (process.env.VE_ALLOW_DESTRUCTIVE_PUSH === "1") {
|
|
109
|
+
return {
|
|
110
|
+
safe: true,
|
|
111
|
+
bypassed: true,
|
|
112
|
+
reason: "VE_ALLOW_DESTRUCTIVE_PUSH=1",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { remoteRef } = normalizeBaseBranch(baseBranch, remote);
|
|
117
|
+
const baseFiles = countTrackedFiles(worktreePath, remoteRef);
|
|
118
|
+
const headFiles = countTrackedFiles(worktreePath, "HEAD");
|
|
119
|
+
const diff = getNumstat(worktreePath, `${remoteRef}...HEAD`);
|
|
120
|
+
|
|
121
|
+
// If we can't assess reliably, do not block the push.
|
|
122
|
+
if (baseFiles == null || headFiles == null || diff == null) {
|
|
123
|
+
return {
|
|
124
|
+
safe: true,
|
|
125
|
+
bypassed: true,
|
|
126
|
+
reason: "safety assessment unavailable",
|
|
127
|
+
stats: { baseFiles, headFiles, ...diff },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const reasons = [];
|
|
132
|
+
if (baseFiles >= 500 && headFiles <= Math.max(25, Math.floor(baseFiles * 0.15))) {
|
|
133
|
+
reasons.push(`HEAD tracks only ${headFiles}/${baseFiles} files vs ${remoteRef}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const deletedToInserted =
|
|
137
|
+
diff.inserted > 0 ? diff.deleted / diff.inserted : diff.deleted > 0 ? Infinity : 0;
|
|
138
|
+
const manyFilesChanged = diff.files >= Math.max(2_000, Math.floor(baseFiles * 0.5));
|
|
139
|
+
const deletionHeavy = diff.deleted >= 200_000 && deletedToInserted > 50;
|
|
140
|
+
if (manyFilesChanged && deletionHeavy) {
|
|
141
|
+
reasons.push(
|
|
142
|
+
`diff vs ${remoteRef} is deletion-heavy (${diff.deleted} deleted, ${diff.inserted} inserted across ${diff.files} files)`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (reasons.length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
safe: false,
|
|
149
|
+
reason: reasons.join("; "),
|
|
150
|
+
stats: {
|
|
151
|
+
baseFiles,
|
|
152
|
+
headFiles,
|
|
153
|
+
filesChanged: diff.files,
|
|
154
|
+
inserted: diff.inserted,
|
|
155
|
+
deleted: diff.deleted,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
safe: true,
|
|
162
|
+
stats: {
|
|
163
|
+
baseFiles,
|
|
164
|
+
headFiles,
|
|
165
|
+
filesChanged: diff.files,
|
|
166
|
+
inserted: diff.inserted,
|
|
167
|
+
deleted: diff.deleted,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
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
|
+
|
|
16
|
+
function parseNumber(value, fallback) {
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseRepoSlug(raw) {
|
|
22
|
+
const text = String(raw || "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
|
23
|
+
if (!text) return "";
|
|
24
|
+
const cleaned = text.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
25
|
+
const [owner, repo] = cleaned.split("/", 2);
|
|
26
|
+
if (!owner || !repo) return "";
|
|
27
|
+
return `${owner}/${repo}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseIssueRefs(text) {
|
|
31
|
+
const refs = new Set();
|
|
32
|
+
const raw = String(text || "");
|
|
33
|
+
const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)\b/gi;
|
|
34
|
+
let match = re.exec(raw);
|
|
35
|
+
while (match) {
|
|
36
|
+
refs.add(String(match[1]));
|
|
37
|
+
match = re.exec(raw);
|
|
38
|
+
}
|
|
39
|
+
return refs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseIssueFromBranch(branchName) {
|
|
43
|
+
const match = String(branchName || "").trim().match(/^ve\/(\d+)-/i);
|
|
44
|
+
return match?.[1] || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeIssueLabels(issue) {
|
|
48
|
+
const labels = Array.isArray(issue?.labels) ? issue.labels : [];
|
|
49
|
+
return labels
|
|
50
|
+
.map((label) =>
|
|
51
|
+
typeof label === "string" ? label : String(label?.name || "").trim(),
|
|
52
|
+
)
|
|
53
|
+
.map((label) => label.toLowerCase())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isIssueInReview(issue) {
|
|
58
|
+
const labels = new Set(normalizeIssueLabels(issue));
|
|
59
|
+
return labels.has("inreview") || labels.has("in-review");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTrackingIssue(issue, trackingLabels) {
|
|
63
|
+
const title = String(issue?.title || "").toLowerCase();
|
|
64
|
+
if (title.includes("meta issue") || title.includes("tracker")) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const labels = new Set(normalizeIssueLabels(issue));
|
|
68
|
+
for (const label of trackingLabels) {
|
|
69
|
+
if (labels.has(label)) return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function defaultGh(args) {
|
|
75
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
76
|
+
encoding: "utf8",
|
|
77
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
78
|
+
timeout: 120_000,
|
|
79
|
+
});
|
|
80
|
+
const raw = String(stdout || "").trim();
|
|
81
|
+
if (!raw) return [];
|
|
82
|
+
return JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildIssueMappings(openPrs, mergedPrs) {
|
|
86
|
+
const map = new Map();
|
|
87
|
+
|
|
88
|
+
function ensure(issueNumber) {
|
|
89
|
+
if (!map.has(issueNumber)) {
|
|
90
|
+
map.set(issueNumber, {
|
|
91
|
+
openPrs: [],
|
|
92
|
+
mergedPrs: [],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return map.get(issueNumber);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function refsForPr(pr) {
|
|
99
|
+
const refs = new Set();
|
|
100
|
+
for (const issue of parseIssueRefs(pr?.title)) refs.add(issue);
|
|
101
|
+
for (const issue of parseIssueRefs(pr?.body)) refs.add(issue);
|
|
102
|
+
const fromBranch = parseIssueFromBranch(pr?.headRefName);
|
|
103
|
+
if (fromBranch) refs.add(fromBranch);
|
|
104
|
+
return refs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const pr of openPrs) {
|
|
108
|
+
for (const issueNumber of refsForPr(pr)) {
|
|
109
|
+
ensure(issueNumber).openPrs.push(pr);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const pr of mergedPrs) {
|
|
113
|
+
for (const issueNumber of refsForPr(pr)) {
|
|
114
|
+
ensure(issueNumber).mergedPrs.push(pr);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return map;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class GitHubReconciler {
|
|
121
|
+
constructor(options = {}) {
|
|
122
|
+
this.enabled = options.enabled !== false;
|
|
123
|
+
this.intervalMs = Math.max(
|
|
124
|
+
30_000,
|
|
125
|
+
parseNumber(options.intervalMs, DEFAULT_INTERVAL_MS),
|
|
126
|
+
);
|
|
127
|
+
this.mergedLookbackHours = Math.max(
|
|
128
|
+
1,
|
|
129
|
+
parseNumber(
|
|
130
|
+
options.mergedLookbackHours,
|
|
131
|
+
DEFAULT_MERGED_LOOKBACK_HOURS,
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
this.repoSlug =
|
|
135
|
+
parseRepoSlug(options.repoSlug) ||
|
|
136
|
+
parseRepoSlug(process.env.GITHUB_REPOSITORY) ||
|
|
137
|
+
parseRepoSlug(
|
|
138
|
+
process.env.GITHUB_REPO_OWNER && process.env.GITHUB_REPO_NAME
|
|
139
|
+
? `${process.env.GITHUB_REPO_OWNER}/${process.env.GITHUB_REPO_NAME}`
|
|
140
|
+
: "",
|
|
141
|
+
) ||
|
|
142
|
+
"unknown/unknown";
|
|
143
|
+
this.trackingLabels = new Set(
|
|
144
|
+
(Array.isArray(options.trackingLabels)
|
|
145
|
+
? options.trackingLabels
|
|
146
|
+
: String(options.trackingLabels || "tracking").split(",")
|
|
147
|
+
)
|
|
148
|
+
.map((value) => String(value || "").trim().toLowerCase())
|
|
149
|
+
.filter(Boolean),
|
|
150
|
+
);
|
|
151
|
+
this.addComment = options.addComment || addKanbanComment;
|
|
152
|
+
this.updateTaskStatus = options.updateTaskStatus || updateTaskStatus;
|
|
153
|
+
this.gh = options.gh || defaultGh;
|
|
154
|
+
this.sendTelegram = options.sendTelegram || null;
|
|
155
|
+
this.timer = null;
|
|
156
|
+
this.running = false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async _listOpenIssues() {
|
|
160
|
+
return await this.gh([
|
|
161
|
+
"issue",
|
|
162
|
+
"list",
|
|
163
|
+
"--repo",
|
|
164
|
+
this.repoSlug,
|
|
165
|
+
"--state",
|
|
166
|
+
"open",
|
|
167
|
+
"--limit",
|
|
168
|
+
"200",
|
|
169
|
+
"--json",
|
|
170
|
+
"number,title,labels,url",
|
|
171
|
+
]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _listOpenPrs() {
|
|
175
|
+
return await this.gh([
|
|
176
|
+
"pr",
|
|
177
|
+
"list",
|
|
178
|
+
"--repo",
|
|
179
|
+
this.repoSlug,
|
|
180
|
+
"--state",
|
|
181
|
+
"open",
|
|
182
|
+
"--limit",
|
|
183
|
+
"200",
|
|
184
|
+
"--json",
|
|
185
|
+
"number,title,body,headRefName,url",
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async _listMergedPrs() {
|
|
190
|
+
const since = new Date(
|
|
191
|
+
Date.now() - this.mergedLookbackHours * 60 * 60 * 1000,
|
|
192
|
+
)
|
|
193
|
+
.toISOString()
|
|
194
|
+
.slice(0, 10);
|
|
195
|
+
return await this.gh([
|
|
196
|
+
"pr",
|
|
197
|
+
"list",
|
|
198
|
+
"--repo",
|
|
199
|
+
this.repoSlug,
|
|
200
|
+
"--state",
|
|
201
|
+
"merged",
|
|
202
|
+
"--search",
|
|
203
|
+
`merged:>=${since}`,
|
|
204
|
+
"--limit",
|
|
205
|
+
"200",
|
|
206
|
+
"--json",
|
|
207
|
+
"number,title,body,headRefName,mergedAt,url",
|
|
208
|
+
]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async reconcileOnce() {
|
|
212
|
+
const backend = String(getKanbanBackendName() || "").toLowerCase();
|
|
213
|
+
if (!this.enabled) {
|
|
214
|
+
return { status: "skipped", reason: "disabled" };
|
|
215
|
+
}
|
|
216
|
+
if (backend !== "github") {
|
|
217
|
+
return { status: "skipped", reason: `backend=${backend || "unknown"}` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const summary = {
|
|
221
|
+
status: "ok",
|
|
222
|
+
checked: 0,
|
|
223
|
+
closed: 0,
|
|
224
|
+
inreview: 0,
|
|
225
|
+
normalized: 0,
|
|
226
|
+
skippedTracking: 0,
|
|
227
|
+
projectMismatches: 0,
|
|
228
|
+
errors: 0,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Build a map of project board statuses for issues when in kanban mode
|
|
232
|
+
/** @type {Map<string, string>} issueNumber → project board status */
|
|
233
|
+
const projectStatusMap = new Map();
|
|
234
|
+
const projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues").trim().toLowerCase();
|
|
235
|
+
if (projectMode === "kanban") {
|
|
236
|
+
try {
|
|
237
|
+
const adapter = getKanbanAdapter();
|
|
238
|
+
if (typeof adapter.listTasksFromProject === "function") {
|
|
239
|
+
const projectNumber =
|
|
240
|
+
process.env.GITHUB_PROJECT_NUMBER ||
|
|
241
|
+
process.env.GITHUB_PROJECT_ID ||
|
|
242
|
+
null;
|
|
243
|
+
if (projectNumber) {
|
|
244
|
+
const projectTasks = await adapter.listTasksFromProject(projectNumber);
|
|
245
|
+
for (const task of projectTasks) {
|
|
246
|
+
if (task?.id && task?.status) {
|
|
247
|
+
projectStatusMap.set(String(task.id), task.status);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.warn(`${TAG} failed to read project board for reconciliation: ${err?.message || err}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const [issuesRaw, openPrsRaw, mergedPrsRaw] = await Promise.all([
|
|
258
|
+
this._listOpenIssues(),
|
|
259
|
+
this._listOpenPrs(),
|
|
260
|
+
this._listMergedPrs(),
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const issues = Array.isArray(issuesRaw) ? issuesRaw : [];
|
|
264
|
+
const openPrs = Array.isArray(openPrsRaw) ? openPrsRaw : [];
|
|
265
|
+
const mergedPrs = Array.isArray(mergedPrsRaw) ? mergedPrsRaw : [];
|
|
266
|
+
const mappings = buildIssueMappings(openPrs, mergedPrs);
|
|
267
|
+
|
|
268
|
+
for (const issue of issues) {
|
|
269
|
+
const issueNumber = String(issue?.number || "").trim();
|
|
270
|
+
if (!issueNumber) continue;
|
|
271
|
+
summary.checked += 1;
|
|
272
|
+
const mapped = mappings.get(issueNumber) || {
|
|
273
|
+
openPrs: [],
|
|
274
|
+
mergedPrs: [],
|
|
275
|
+
};
|
|
276
|
+
const hasOpenPr = mapped.openPrs.length > 0;
|
|
277
|
+
const hasMergedPr = mapped.mergedPrs.length > 0;
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
if (hasMergedPr) {
|
|
281
|
+
if (isTrackingIssue(issue, this.trackingLabels)) {
|
|
282
|
+
summary.skippedTracking += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
await this.updateTaskStatus(issueNumber, "done");
|
|
286
|
+
if (this.addComment) {
|
|
287
|
+
const mergedUrls = mapped.mergedPrs
|
|
288
|
+
.slice(0, 3)
|
|
289
|
+
.map((pr) => pr?.url)
|
|
290
|
+
.filter(Boolean);
|
|
291
|
+
const suffix =
|
|
292
|
+
mergedUrls.length > 0
|
|
293
|
+
? `\n\nMerged PR(s):\n${mergedUrls.map((url) => `- ${url}`).join("\n")}`
|
|
294
|
+
: "";
|
|
295
|
+
await this.addComment(
|
|
296
|
+
issueNumber,
|
|
297
|
+
`## ✅ Auto-Reconciled\nThis issue was auto-closed by openfleet after detecting merged PR linkage.${suffix}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
summary.closed += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (hasOpenPr) {
|
|
305
|
+
if (!isIssueInReview(issue)) {
|
|
306
|
+
await this.updateTaskStatus(issueNumber, "inreview");
|
|
307
|
+
summary.inreview += 1;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (isIssueInReview(issue)) {
|
|
313
|
+
await this.updateTaskStatus(issueNumber, "todo");
|
|
314
|
+
summary.normalized += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Project board mismatch detection (kanban mode only)
|
|
319
|
+
if (projectStatusMap.size > 0) {
|
|
320
|
+
const projectStatus = projectStatusMap.get(issueNumber);
|
|
321
|
+
if (projectStatus) {
|
|
322
|
+
const issueStatus = isIssueInReview(issue) ? "inreview" : "todo";
|
|
323
|
+
if (projectStatus !== issueStatus && projectStatus !== "todo") {
|
|
324
|
+
// Project board says a different status than issue labels — reconcile
|
|
325
|
+
try {
|
|
326
|
+
await this.updateTaskStatus(issueNumber, projectStatus);
|
|
327
|
+
summary.projectMismatches += 1;
|
|
328
|
+
} catch (syncErr) {
|
|
329
|
+
console.warn(
|
|
330
|
+
`${TAG} failed to sync project status for #${issueNumber}: ${syncErr?.message || syncErr}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
summary.errors += 1;
|
|
338
|
+
console.warn(
|
|
339
|
+
`${TAG} failed reconciling issue #${issueNumber}: ${err?.message || err}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log(
|
|
345
|
+
`${TAG} cycle complete: checked=${summary.checked} closed=${summary.closed} inreview=${summary.inreview} normalized=${summary.normalized} skippedTracking=${summary.skippedTracking} projectMismatches=${summary.projectMismatches} errors=${summary.errors}`,
|
|
346
|
+
);
|
|
347
|
+
return summary;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
start() {
|
|
351
|
+
if (this.running) return this;
|
|
352
|
+
this.running = true;
|
|
353
|
+
console.log(
|
|
354
|
+
`${TAG} started (repo=${this.repoSlug}, interval=${this.intervalMs}ms, lookback=${this.mergedLookbackHours}h)`,
|
|
355
|
+
);
|
|
356
|
+
void this.reconcileOnce().catch((err) => {
|
|
357
|
+
console.warn(`${TAG} initial cycle failed: ${err?.message || err}`);
|
|
358
|
+
});
|
|
359
|
+
this.timer = setInterval(() => {
|
|
360
|
+
void this.reconcileOnce().catch((err) => {
|
|
361
|
+
console.warn(`${TAG} cycle failed: ${err?.message || err}`);
|
|
362
|
+
if (this.sendTelegram) {
|
|
363
|
+
void this.sendTelegram(
|
|
364
|
+
`⚠️ GitHub reconciler cycle failed: ${err?.message || err}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}, this.intervalMs);
|
|
369
|
+
if (this.timer?.unref) this.timer.unref();
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
stop() {
|
|
374
|
+
if (this.timer) {
|
|
375
|
+
clearInterval(this.timer);
|
|
376
|
+
this.timer = null;
|
|
377
|
+
}
|
|
378
|
+
this.running = false;
|
|
379
|
+
console.log(`${TAG} stopped`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let _singleton = null;
|
|
384
|
+
|
|
385
|
+
export function startGitHubReconciler(options = {}) {
|
|
386
|
+
if (_singleton) {
|
|
387
|
+
_singleton.stop();
|
|
388
|
+
}
|
|
389
|
+
_singleton = new GitHubReconciler(options);
|
|
390
|
+
return _singleton.start();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function stopGitHubReconciler() {
|
|
394
|
+
if (_singleton) {
|
|
395
|
+
_singleton.stop();
|
|
396
|
+
_singleton = null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function runGitHubReconcilerOnce(options = {}) {
|
|
401
|
+
const reconciler = new GitHubReconciler(options);
|
|
402
|
+
return await reconciler.reconcileOnce();
|
|
403
|
+
}
|