great-cto 2.4.0 → 2.5.1
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/dist/agentshield/scanner.js +38 -13
- package/dist/main.js +36 -0
- package/dist/mcp.js +70 -2
- package/dist/report.js +410 -0
- package/dist/serve.js +188 -57
- package/dist/webhook-cli.js +150 -0
- package/dist/webhook-config.js +65 -0
- package/dist/webhook-dispatch.js +132 -0
- package/package.json +1 -1
|
@@ -54,21 +54,46 @@ function* walk(root, exclude) {
|
|
|
54
54
|
function fileMatchesGlobs(file, globs) {
|
|
55
55
|
if (!globs || globs.length === 0)
|
|
56
56
|
return true;
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
// 2. Escape remaining regex metachars.
|
|
60
|
-
// 3. Replace placeholders with their regex equivalents.
|
|
57
|
+
// Normalize path separators for cross-platform matching
|
|
58
|
+
const normalized = file.replace(/\\/g, '/');
|
|
61
59
|
return globs.some((g) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
// Token-based glob → regex conversion. Walks character-by-character to
|
|
61
|
+
// avoid the substitution-order pitfalls of multiple replace passes.
|
|
62
|
+
// Treats `**/` as "zero or more path segments" — so `**/*.ts` matches
|
|
63
|
+
// both `foo.ts` (root) and `src/lib/foo.ts` (nested).
|
|
64
|
+
let re = '';
|
|
65
|
+
for (let i = 0; i < g.length; i++) {
|
|
66
|
+
const c = g[i];
|
|
67
|
+
if (c === '*' && g[i + 1] === '*') {
|
|
68
|
+
// ** consumes the trailing /, so `**/x` becomes `(?:.*\/)?x` not `.*\/x`
|
|
69
|
+
if (g[i + 2] === '/') {
|
|
70
|
+
re += '(?:.*\\/)?';
|
|
71
|
+
i += 2;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
re += '.*';
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else if (c === '*') {
|
|
79
|
+
re += '[^/]*';
|
|
80
|
+
}
|
|
81
|
+
else if (c === '?') {
|
|
82
|
+
re += '.';
|
|
83
|
+
}
|
|
84
|
+
else if ('.+^${}()|[]\\'.includes(c)) {
|
|
85
|
+
re += '\\' + c;
|
|
86
|
+
}
|
|
87
|
+
else if (c === '/') {
|
|
88
|
+
re += '/';
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
re += c;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
70
94
|
try {
|
|
71
|
-
|
|
95
|
+
// Match suffix — `src/foo.ts` matches `**/*.ts` regardless of cwd
|
|
96
|
+
return new RegExp('(?:^|/)' + re + '$').test(normalized);
|
|
72
97
|
}
|
|
73
98
|
catch {
|
|
74
99
|
return false;
|
package/dist/main.js
CHANGED
|
@@ -91,6 +91,10 @@ function parseArgs(argv) {
|
|
|
91
91
|
args.command = "adapt";
|
|
92
92
|
else if (a === "serve")
|
|
93
93
|
args.command = "serve";
|
|
94
|
+
else if (a === "webhook")
|
|
95
|
+
args.command = "webhook";
|
|
96
|
+
else if (a === "report")
|
|
97
|
+
args.command = "report";
|
|
94
98
|
else if (a.startsWith("--dir="))
|
|
95
99
|
args.dir = a.slice("--dir=".length);
|
|
96
100
|
else if (a === "--dir")
|
|
@@ -823,6 +827,7 @@ async function main() {
|
|
|
823
827
|
const code = await runServe({
|
|
824
828
|
port: args.boardPort === 3141 ? 3142 : args.boardPort,
|
|
825
829
|
noLog: rawArgv.includes("--no-log"),
|
|
830
|
+
insecure: rawArgv.includes("--insecure"),
|
|
826
831
|
});
|
|
827
832
|
process.exit(code);
|
|
828
833
|
}
|
|
@@ -831,6 +836,37 @@ async function main() {
|
|
|
831
836
|
process.exit(2);
|
|
832
837
|
}
|
|
833
838
|
}
|
|
839
|
+
if (args.command === "webhook") {
|
|
840
|
+
try {
|
|
841
|
+
const { runWebhookCli, parseWebhookArgs } = await import("./webhook-cli.js");
|
|
842
|
+
const parsed = parseWebhookArgs(rawArgv);
|
|
843
|
+
if (!parsed) {
|
|
844
|
+
error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
|
|
845
|
+
process.exit(2);
|
|
846
|
+
}
|
|
847
|
+
const code = await runWebhookCli(parsed);
|
|
848
|
+
process.exit(code);
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
error(e.message);
|
|
852
|
+
process.exit(2);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (args.command === "report") {
|
|
856
|
+
try {
|
|
857
|
+
const { runReport, parseReportArgs } = await import("./report.js");
|
|
858
|
+
const parsed = parseReportArgs(rawArgv, args.dir);
|
|
859
|
+
if (!parsed) {
|
|
860
|
+
process.exit(2);
|
|
861
|
+
}
|
|
862
|
+
const code = await runReport(parsed);
|
|
863
|
+
process.exit(code);
|
|
864
|
+
}
|
|
865
|
+
catch (e) {
|
|
866
|
+
error(e.message);
|
|
867
|
+
process.exit(2);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
834
870
|
if (args.command === "version") {
|
|
835
871
|
// Version resolved in index.mjs or from package.json at runtime
|
|
836
872
|
try {
|
package/dist/mcp.js
CHANGED
|
@@ -245,6 +245,75 @@ async function handle(req) {
|
|
|
245
245
|
return fail(-32603, `Internal error: ${e.message}`);
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
|
+
// ── SSE transport ──────────────────────────────────────────────────────────
|
|
249
|
+
async function runSse(port, version) {
|
|
250
|
+
const { createServer } = await import("node:http");
|
|
251
|
+
// Each connection gets a unique session id and an open SSE stream.
|
|
252
|
+
// Inbound JSON-RPC arrives via POST /message?sessionId=<id>; responses are
|
|
253
|
+
// pushed back over the SSE stream. This matches the standard MCP SSE
|
|
254
|
+
// transport (https://spec.modelcontextprotocol.io/specification/transports).
|
|
255
|
+
const sessions = new Map();
|
|
256
|
+
const server = createServer(async (req, res) => {
|
|
257
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
258
|
+
if (req.method === "GET" && url.pathname === "/sse") {
|
|
259
|
+
const sessionId = `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
260
|
+
res.writeHead(200, {
|
|
261
|
+
"Content-Type": "text/event-stream",
|
|
262
|
+
"Cache-Control": "no-cache, no-transform",
|
|
263
|
+
Connection: "keep-alive",
|
|
264
|
+
"X-Accel-Buffering": "no",
|
|
265
|
+
});
|
|
266
|
+
// Initial endpoint event — tells client where to POST messages
|
|
267
|
+
res.write(`event: endpoint\ndata: /message?sessionId=${sessionId}\n\n`);
|
|
268
|
+
sessions.set(sessionId, res);
|
|
269
|
+
req.on("close", () => sessions.delete(sessionId));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (req.method === "POST" && url.pathname === "/message") {
|
|
273
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
274
|
+
const sse = sessions.get(sessionId);
|
|
275
|
+
if (!sse) {
|
|
276
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
277
|
+
res.end(JSON.stringify({ error: "unknown sessionId" }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const chunks = [];
|
|
281
|
+
req.on("data", c => chunks.push(Buffer.from(c)));
|
|
282
|
+
req.on("end", async () => {
|
|
283
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
284
|
+
try {
|
|
285
|
+
const reqJson = JSON.parse(body);
|
|
286
|
+
const reply = await handle(reqJson);
|
|
287
|
+
if (reply) {
|
|
288
|
+
sse.write(`event: message\ndata: ${JSON.stringify(reply)}\n\n`);
|
|
289
|
+
}
|
|
290
|
+
res.writeHead(202).end();
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
294
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (req.method === "GET" && url.pathname === "/healthz") {
|
|
300
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
301
|
+
res.end(JSON.stringify({ ok: true, version, sessions: sessions.size, transport: "sse" }));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
res.writeHead(404).end();
|
|
305
|
+
});
|
|
306
|
+
return new Promise(resolve => {
|
|
307
|
+
server.listen(port, "127.0.0.1", () => {
|
|
308
|
+
process.stderr.write(`great-cto mcp v${version} (sse) → http://localhost:${port}/sse\n`);
|
|
309
|
+
process.stderr.write(` GET /sse open event stream\n`);
|
|
310
|
+
process.stderr.write(` POST /message?sessionId=... send JSON-RPC\n`);
|
|
311
|
+
process.stderr.write(` GET /healthz liveness\n`);
|
|
312
|
+
});
|
|
313
|
+
process.on("SIGINT", () => { server.close(); resolve(0); });
|
|
314
|
+
process.on("SIGTERM", () => { server.close(); resolve(0); });
|
|
315
|
+
});
|
|
316
|
+
}
|
|
248
317
|
// ── stdio transport ────────────────────────────────────────────────────────
|
|
249
318
|
async function runStdio() {
|
|
250
319
|
// Read newline-delimited JSON from stdin, write to stdout. This is the
|
|
@@ -278,8 +347,7 @@ async function runStdio() {
|
|
|
278
347
|
export async function runMcp(args) {
|
|
279
348
|
SERVER_INFO.version = args.version;
|
|
280
349
|
if (args.mode === "sse") {
|
|
281
|
-
|
|
282
|
-
return 2;
|
|
350
|
+
return runSse(args.port, args.version);
|
|
283
351
|
}
|
|
284
352
|
// Notify clients we're ready (some hosts log this)
|
|
285
353
|
process.stderr.write(`great-cto mcp v${args.version} (stdio) — ${TOOLS.length} tools\n`);
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// great-cto report — shareable cost / agents / compliance reports.
|
|
2
|
+
//
|
|
3
|
+
// Three report types, two output formats (HTML self-contained, JSON):
|
|
4
|
+
//
|
|
5
|
+
// great-cto report cost --period 30d --format html > cost.html
|
|
6
|
+
// great-cto report agents --since-last-release --format json
|
|
7
|
+
// great-cto report compliance --archetype fintech --format html
|
|
8
|
+
//
|
|
9
|
+
// HTML output is fully self-contained (no external CSS/JS) so it can be
|
|
10
|
+
// emailed to a CFO, attached to a PR, or hosted as a GitHub Pages artifact.
|
|
11
|
+
// JSON is for downstream automation.
|
|
12
|
+
//
|
|
13
|
+
// Data sources:
|
|
14
|
+
// cost → ~/.great_cto/verdicts/*.log (LLM cost ledger) +
|
|
15
|
+
// .great_cto/PROJECT.md (monthly-budget) +
|
|
16
|
+
// bd tasks (closed_at-created_at timing)
|
|
17
|
+
// agents → ~/.great_cto/verdicts/*.log + plugin agents/*.md
|
|
18
|
+
// compliance → .great_cto/PROJECT.md (compliance gates) +
|
|
19
|
+
// docs/security/CSO-*.md + docs/qa-reports/QA-*.md +
|
|
20
|
+
// gates closed via bd
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
// ── Data collectors ────────────────────────────────────────────────────────
|
|
25
|
+
function readAllVerdicts() {
|
|
26
|
+
const dir = join(homedir(), ".great_cto", "verdicts");
|
|
27
|
+
if (!existsSync(dir))
|
|
28
|
+
return [];
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const f of readdirSync(dir)) {
|
|
31
|
+
if (!f.endsWith(".log"))
|
|
32
|
+
continue;
|
|
33
|
+
const agent = f.replace(/\.log$/, "");
|
|
34
|
+
const lines = readFileSync(join(dir, f), "utf8").split("\n").filter(Boolean);
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const ts = line.split(/\s+/)[0] ?? "";
|
|
37
|
+
const verdict = line.split(/\s+/)[1] ?? "";
|
|
38
|
+
const costMatch = line.match(/\bcost(?:_usd)?[=:]?\s*\$?(\d+\.?\d*)/i);
|
|
39
|
+
out.push({
|
|
40
|
+
ts, agent, verdict,
|
|
41
|
+
cost_usd: costMatch ? parseFloat(costMatch[1]) : null,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
46
|
+
}
|
|
47
|
+
function periodToCutoff(period) {
|
|
48
|
+
const m = period.match(/^(\d+)d$/);
|
|
49
|
+
if (!m)
|
|
50
|
+
return "";
|
|
51
|
+
const days = parseInt(m[1], 10);
|
|
52
|
+
return new Date(Date.now() - days * 86_400_000).toISOString();
|
|
53
|
+
}
|
|
54
|
+
// ── Cost report ────────────────────────────────────────────────────────────
|
|
55
|
+
function buildCostReport(args) {
|
|
56
|
+
const cutoff = periodToCutoff(args.period);
|
|
57
|
+
const verdicts = readAllVerdicts().filter(v => v.ts >= cutoff);
|
|
58
|
+
const HUMAN_RATE_PER_HR = 150;
|
|
59
|
+
const LLM_RATE_PER_HR = 0.02;
|
|
60
|
+
const RATIO = HUMAN_RATE_PER_HR / LLM_RATE_PER_HR;
|
|
61
|
+
// Per-agent aggregation
|
|
62
|
+
const byAgent = new Map();
|
|
63
|
+
let totalLlm = 0;
|
|
64
|
+
for (const v of verdicts) {
|
|
65
|
+
const cost = v.cost_usd ?? 0;
|
|
66
|
+
totalLlm += cost;
|
|
67
|
+
const cur = byAgent.get(v.agent) ?? { llm: 0, runs: 0 };
|
|
68
|
+
cur.llm += cost;
|
|
69
|
+
cur.runs += 1;
|
|
70
|
+
byAgent.set(v.agent, cur);
|
|
71
|
+
}
|
|
72
|
+
// Day-level series for chart
|
|
73
|
+
const byDay = new Map();
|
|
74
|
+
for (const v of verdicts) {
|
|
75
|
+
const day = v.ts.slice(0, 10);
|
|
76
|
+
byDay.set(day, (byDay.get(day) ?? 0) + (v.cost_usd ?? 0));
|
|
77
|
+
}
|
|
78
|
+
const series = Array.from(byDay.entries())
|
|
79
|
+
.map(([date, llm]) => ({ date, llm }))
|
|
80
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
81
|
+
// Read budget
|
|
82
|
+
const projectMd = join(args.cwd, ".great_cto", "PROJECT.md");
|
|
83
|
+
const budgetMatch = existsSync(projectMd)
|
|
84
|
+
? readFileSync(projectMd, "utf8").match(/monthly[-_]budget:\s*\$?(\d[\d,]+)/i)
|
|
85
|
+
: null;
|
|
86
|
+
const budget = budgetMatch ? parseFloat(budgetMatch[1].replace(/,/g, "")) : null;
|
|
87
|
+
const totalHuman = totalLlm * RATIO;
|
|
88
|
+
return {
|
|
89
|
+
type: "cost",
|
|
90
|
+
period: args.period,
|
|
91
|
+
generated_at: new Date().toISOString(),
|
|
92
|
+
summary: {
|
|
93
|
+
total_llm_usd: +totalLlm.toFixed(4),
|
|
94
|
+
total_human_equivalent_usd: +totalHuman.toFixed(2),
|
|
95
|
+
savings_x: Math.round(RATIO),
|
|
96
|
+
savings_usd: +(totalHuman - totalLlm).toFixed(2),
|
|
97
|
+
runs: verdicts.length,
|
|
98
|
+
monthly_budget: budget,
|
|
99
|
+
pct_of_budget: budget ? +((totalLlm / budget) * 100).toFixed(1) : null,
|
|
100
|
+
},
|
|
101
|
+
by_agent: Array.from(byAgent.entries())
|
|
102
|
+
.map(([agent, { llm, runs }]) => ({
|
|
103
|
+
agent,
|
|
104
|
+
llm_usd: +llm.toFixed(4),
|
|
105
|
+
human_equivalent_usd: +(llm * RATIO).toFixed(2),
|
|
106
|
+
runs,
|
|
107
|
+
}))
|
|
108
|
+
.sort((a, b) => b.llm_usd - a.llm_usd),
|
|
109
|
+
series,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// ── Agents report ──────────────────────────────────────────────────────────
|
|
113
|
+
function buildAgentsReport(args) {
|
|
114
|
+
const cutoff = periodToCutoff(args.period);
|
|
115
|
+
const verdicts = readAllVerdicts().filter(v => v.ts >= cutoff);
|
|
116
|
+
const byAgent = new Map();
|
|
117
|
+
for (const v of verdicts) {
|
|
118
|
+
const cur = byAgent.get(v.agent) ?? { runs: 0, ok: 0, fail: 0, lastTs: "", cost: 0 };
|
|
119
|
+
cur.runs += 1;
|
|
120
|
+
const u = (v.verdict || "").toUpperCase();
|
|
121
|
+
if (["OK", "APPROVED", "DONE", "PASS", "PASSED"].includes(u))
|
|
122
|
+
cur.ok += 1;
|
|
123
|
+
else if (["FAIL", "FAILED", "BLOCKED", "REJECTED"].includes(u))
|
|
124
|
+
cur.fail += 1;
|
|
125
|
+
if (v.ts > cur.lastTs)
|
|
126
|
+
cur.lastTs = v.ts;
|
|
127
|
+
cur.cost += v.cost_usd ?? 0;
|
|
128
|
+
byAgent.set(v.agent, cur);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type: "agents",
|
|
132
|
+
period: args.period,
|
|
133
|
+
generated_at: new Date().toISOString(),
|
|
134
|
+
summary: {
|
|
135
|
+
total_agents: byAgent.size,
|
|
136
|
+
total_runs: verdicts.length,
|
|
137
|
+
total_cost_usd: +Array.from(byAgent.values()).reduce((s, a) => s + a.cost, 0).toFixed(4),
|
|
138
|
+
},
|
|
139
|
+
agents: Array.from(byAgent.entries())
|
|
140
|
+
.map(([name, m]) => ({
|
|
141
|
+
agent: name,
|
|
142
|
+
runs: m.runs,
|
|
143
|
+
ok: m.ok,
|
|
144
|
+
fail: m.fail,
|
|
145
|
+
success_rate: m.runs ? +((m.ok / m.runs) * 100).toFixed(1) : null,
|
|
146
|
+
cost_usd: +m.cost.toFixed(4),
|
|
147
|
+
last_seen: m.lastTs,
|
|
148
|
+
}))
|
|
149
|
+
.sort((a, b) => b.runs - a.runs),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ── Compliance report ──────────────────────────────────────────────────────
|
|
153
|
+
function buildComplianceReport(args) {
|
|
154
|
+
const projectMd = join(args.cwd, ".great_cto", "PROJECT.md");
|
|
155
|
+
const meta = existsSync(projectMd) ? readFileSync(projectMd, "utf8") : "";
|
|
156
|
+
const declaredArchetype = (meta.match(/^primary:\s*(\S+)/m)?.[1] ?? "unknown").trim();
|
|
157
|
+
const archetype = args.archetype ?? declaredArchetype;
|
|
158
|
+
const compliance = (meta.match(/^compliance:\s*(.+)$/m)?.[1] ?? "")
|
|
159
|
+
.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
|
160
|
+
// Count gates from docs/security and docs/qa-reports
|
|
161
|
+
const securityDir = join(args.cwd, "docs", "security");
|
|
162
|
+
const qaDir = join(args.cwd, "docs", "qa-reports");
|
|
163
|
+
let secApproved = 0, secBlocked = 0, secTotal = 0;
|
|
164
|
+
if (existsSync(securityDir)) {
|
|
165
|
+
for (const f of readdirSync(securityDir).filter(x => x.endsWith(".md"))) {
|
|
166
|
+
secTotal += 1;
|
|
167
|
+
const text = readFileSync(join(securityDir, f), "utf8");
|
|
168
|
+
if (/APPROVED/i.test(text))
|
|
169
|
+
secApproved += 1;
|
|
170
|
+
if (/BLOCKED/i.test(text))
|
|
171
|
+
secBlocked += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let qaPass = 0, qaFail = 0, qaTotal = 0;
|
|
175
|
+
if (existsSync(qaDir)) {
|
|
176
|
+
for (const f of readdirSync(qaDir).filter(x => x.endsWith(".md"))) {
|
|
177
|
+
qaTotal += 1;
|
|
178
|
+
const text = readFileSync(join(qaDir, f), "utf8");
|
|
179
|
+
if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:✅|✓|pass(?:ed)?)/i.test(text))
|
|
180
|
+
qaPass += 1;
|
|
181
|
+
else if (/(?:verdict|status|result)\s*[:=]?\s*[*_`]*\s*(?:❌|✗|fail|block)/i.test(text))
|
|
182
|
+
qaFail += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
type: "compliance",
|
|
187
|
+
archetype,
|
|
188
|
+
generated_at: new Date().toISOString(),
|
|
189
|
+
declared_compliance: compliance,
|
|
190
|
+
security_gates: { total: secTotal, approved: secApproved, blocked: secBlocked },
|
|
191
|
+
qa_reports: { total: qaTotal, passed: qaPass, failed: qaFail,
|
|
192
|
+
pass_rate: qaTotal ? +((qaPass / qaTotal) * 100).toFixed(1) : null },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ── HTML rendering ─────────────────────────────────────────────────────────
|
|
196
|
+
const HTML_STYLE = `
|
|
197
|
+
:root { color-scheme: light dark; }
|
|
198
|
+
body { font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 960px; margin: 32px auto; padding: 0 24px; color: #111; background: #fafafa; }
|
|
199
|
+
@media (prefers-color-scheme: dark) { body { background: #0d0e10; color: #d6d6d6; } }
|
|
200
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
201
|
+
h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.06em; color: #888; margin: 28px 0 8px; }
|
|
202
|
+
.meta { color: #888; margin-bottom: 24px; font-size: 12px; }
|
|
203
|
+
.tile-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
204
|
+
.tile { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 14px 16px; }
|
|
205
|
+
@media (prefers-color-scheme: dark) { .tile { background: #1a1c1f; border-color: #2a2c30; } }
|
|
206
|
+
.tile-num { font-size: 26px; font-weight: 600; }
|
|
207
|
+
.tile-lbl { font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.06em; margin-top: 4px; }
|
|
208
|
+
.tile-sub { font-size: 12px; color: #666; margin-top: 4px; }
|
|
209
|
+
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
|
210
|
+
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }
|
|
211
|
+
@media (prefers-color-scheme: dark) { th, td { border-color: #2a2c30; } }
|
|
212
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #888; font-weight: 500; }
|
|
213
|
+
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
214
|
+
.bar { height: 6px; background: #eee; border-radius: 99px; overflow: hidden; min-width: 60px; }
|
|
215
|
+
@media (prefers-color-scheme: dark) { .bar { background: #2a2c30; } }
|
|
216
|
+
.bar > span { display: block; height: 100%; background: #16a34a; min-width: 2px; }
|
|
217
|
+
.footer { color: #999; font-size: 11px; margin-top: 40px; padding-top: 16px; border-top: 1px solid #eee; }
|
|
218
|
+
@media (prefers-color-scheme: dark) { .footer { border-color: #2a2c30; } }
|
|
219
|
+
.svg-chart { background: #fff; border: 1px solid #eee; border-radius: 8px; padding: 14px; margin: 8px 0 24px; }
|
|
220
|
+
@media (prefers-color-scheme: dark) { .svg-chart { background: #1a1c1f; border-color: #2a2c30; } }
|
|
221
|
+
`;
|
|
222
|
+
function fmtMoney(n) {
|
|
223
|
+
return "$" + Math.round(n).toLocaleString().replace(/,/g, " ");
|
|
224
|
+
}
|
|
225
|
+
function renderCostHtml(report) {
|
|
226
|
+
const s = report.summary;
|
|
227
|
+
const series = report.series;
|
|
228
|
+
const maxLlm = Math.max(...series.map(p => p.llm), 0.001);
|
|
229
|
+
const chartH = 120;
|
|
230
|
+
const chartW = 800;
|
|
231
|
+
const padL = 40, padR = 12, padB = 24, padT = 8;
|
|
232
|
+
const usableW = chartW - padL - padR;
|
|
233
|
+
const usableH = chartH - padT - padB;
|
|
234
|
+
const bars = series.map((p, i) => {
|
|
235
|
+
const barW = Math.max(2, usableW / Math.max(series.length, 1) - 2);
|
|
236
|
+
const x = padL + i * (usableW / Math.max(series.length, 1));
|
|
237
|
+
const h = (p.llm / maxLlm) * usableH;
|
|
238
|
+
const y = padT + (usableH - h);
|
|
239
|
+
return `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="#16a34a" />`;
|
|
240
|
+
}).join("");
|
|
241
|
+
const chartSvg = `
|
|
242
|
+
<svg class="svg-chart" viewBox="0 0 ${chartW} ${chartH}" width="100%" preserveAspectRatio="none">
|
|
243
|
+
<line x1="${padL}" y1="${padT + usableH}" x2="${chartW - padR}" y2="${padT + usableH}" stroke="#999" stroke-width="0.5" />
|
|
244
|
+
${bars}
|
|
245
|
+
<text x="${padL}" y="${chartH - 4}" font-family="ui-monospace" font-size="9" fill="#888">${series[0]?.date ?? ""}</text>
|
|
246
|
+
<text x="${chartW - padR}" y="${chartH - 4}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">${series[series.length - 1]?.date ?? ""}</text>
|
|
247
|
+
<text x="${padL - 6}" y="${padT + 8}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">${fmtMoney(maxLlm)}</text>
|
|
248
|
+
<text x="${padL - 6}" y="${chartH - padB + 4}" font-family="ui-monospace" font-size="9" fill="#888" text-anchor="end">$0</text>
|
|
249
|
+
</svg>`;
|
|
250
|
+
const agentRows = report.by_agent.map(a => `
|
|
251
|
+
<tr>
|
|
252
|
+
<td>${escapeHtml(a.agent)}</td>
|
|
253
|
+
<td class="num">${a.runs}</td>
|
|
254
|
+
<td class="num">${fmtMoney(a.llm_usd)}</td>
|
|
255
|
+
<td class="num">${fmtMoney(a.human_equivalent_usd)}</td>
|
|
256
|
+
<td><span class="bar"><span style="width:${(a.llm_usd / Math.max(report.by_agent[0]?.llm_usd || 1, 0.0001) * 100).toFixed(1)}%"></span></span></td>
|
|
257
|
+
</tr>`).join("");
|
|
258
|
+
return `<!doctype html>
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<head>
|
|
261
|
+
<meta charset="utf-8">
|
|
262
|
+
<title>great-cto cost report — ${escapeHtml(report.period)}</title>
|
|
263
|
+
<style>${HTML_STYLE}</style>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<h1>Cost report — last ${escapeHtml(report.period)}</h1>
|
|
267
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)} · ${s.runs} agent run(s) · period: ${escapeHtml(report.period)}</div>
|
|
268
|
+
|
|
269
|
+
<div class="tile-row">
|
|
270
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_llm_usd)}</div><div class="tile-lbl">LLM spend</div></div>
|
|
271
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_human_equivalent_usd)}</div><div class="tile-lbl">vs human team</div></div>
|
|
272
|
+
<div class="tile"><div class="tile-num">${s.savings_x}×</div><div class="tile-lbl">cost ratio</div><div class="tile-sub">saved ${fmtMoney(s.savings_usd)}</div></div>
|
|
273
|
+
<div class="tile"><div class="tile-num">${s.pct_of_budget != null ? s.pct_of_budget + "%" : "—"}</div><div class="tile-lbl">of monthly budget</div><div class="tile-sub">${s.monthly_budget != null ? "budget: " + fmtMoney(s.monthly_budget) : "(no budget set)"}</div></div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<h2>Daily LLM spend</h2>
|
|
277
|
+
${chartSvg}
|
|
278
|
+
|
|
279
|
+
<h2>Per-agent breakdown</h2>
|
|
280
|
+
<table>
|
|
281
|
+
<thead><tr><th>Agent</th><th class="num">Runs</th><th class="num">LLM cost</th><th class="num">Human equiv.</th><th>Share</th></tr></thead>
|
|
282
|
+
<tbody>${agentRows}</tbody>
|
|
283
|
+
</table>
|
|
284
|
+
|
|
285
|
+
<div class="footer">
|
|
286
|
+
Report generated by great-cto. LLM cost ratio model: $0.02/AI-hour vs $150/human-hour (~7500×).
|
|
287
|
+
Source: ~/.great_cto/verdicts/*.log + .great_cto/PROJECT.md.
|
|
288
|
+
</div>
|
|
289
|
+
</body>
|
|
290
|
+
</html>
|
|
291
|
+
`;
|
|
292
|
+
}
|
|
293
|
+
function renderAgentsHtml(report) {
|
|
294
|
+
const s = report.summary;
|
|
295
|
+
const rows = report.agents.map(a => `
|
|
296
|
+
<tr>
|
|
297
|
+
<td>${escapeHtml(a.agent)}</td>
|
|
298
|
+
<td class="num">${a.runs}</td>
|
|
299
|
+
<td class="num">${a.success_rate != null ? a.success_rate + "%" : "—"}</td>
|
|
300
|
+
<td class="num">${a.ok}</td>
|
|
301
|
+
<td class="num">${a.fail}</td>
|
|
302
|
+
<td class="num">${fmtMoney(a.cost_usd)}</td>
|
|
303
|
+
<td>${a.last_seen ? escapeHtml(a.last_seen.slice(0, 10)) : "—"}</td>
|
|
304
|
+
</tr>`).join("");
|
|
305
|
+
return `<!doctype html>
|
|
306
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
307
|
+
<title>great-cto agents report</title><style>${HTML_STYLE}</style></head>
|
|
308
|
+
<body>
|
|
309
|
+
<h1>Agents performance — last ${escapeHtml(report.period)}</h1>
|
|
310
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)} · ${s.total_agents} agent(s) · ${s.total_runs} run(s)</div>
|
|
311
|
+
<div class="tile-row">
|
|
312
|
+
<div class="tile"><div class="tile-num">${s.total_agents}</div><div class="tile-lbl">Active agents</div></div>
|
|
313
|
+
<div class="tile"><div class="tile-num">${s.total_runs}</div><div class="tile-lbl">Total runs</div></div>
|
|
314
|
+
<div class="tile"><div class="tile-num">${fmtMoney(s.total_cost_usd)}</div><div class="tile-lbl">Total cost</div></div>
|
|
315
|
+
</div>
|
|
316
|
+
<table>
|
|
317
|
+
<thead><tr><th>Agent</th><th class="num">Runs</th><th class="num">Success rate</th><th class="num">OK</th><th class="num">Fail</th><th class="num">Cost</th><th>Last seen</th></tr></thead>
|
|
318
|
+
<tbody>${rows}</tbody>
|
|
319
|
+
</table>
|
|
320
|
+
<div class="footer">Source: ~/.great_cto/verdicts/*.log</div>
|
|
321
|
+
</body></html>`;
|
|
322
|
+
}
|
|
323
|
+
function renderComplianceHtml(report) {
|
|
324
|
+
const sg = report.security_gates;
|
|
325
|
+
const qa = report.qa_reports;
|
|
326
|
+
const compRows = report.declared_compliance
|
|
327
|
+
.map(c => `<li>${escapeHtml(c)}</li>`)
|
|
328
|
+
.join("");
|
|
329
|
+
return `<!doctype html>
|
|
330
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
331
|
+
<title>great-cto compliance report</title><style>${HTML_STYLE}</style></head>
|
|
332
|
+
<body>
|
|
333
|
+
<h1>Compliance posture — ${escapeHtml(report.archetype)}</h1>
|
|
334
|
+
<div class="meta">Generated ${escapeHtml(report.generated_at)}</div>
|
|
335
|
+
|
|
336
|
+
<div class="tile-row">
|
|
337
|
+
<div class="tile"><div class="tile-num">${sg.total}</div><div class="tile-lbl">Security signoffs</div><div class="tile-sub">${sg.approved} approved · ${sg.blocked} blocked</div></div>
|
|
338
|
+
<div class="tile"><div class="tile-num">${qa.total}</div><div class="tile-lbl">QA reports</div><div class="tile-sub">${qa.pass_rate != null ? qa.pass_rate + "% pass rate" : "(no data)"}</div></div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<h2>Declared compliance gates</h2>
|
|
342
|
+
${compRows ? `<ul>${compRows}</ul>` : "<p>No compliance gates declared in PROJECT.md.</p>"}
|
|
343
|
+
|
|
344
|
+
<h2>Audit trail</h2>
|
|
345
|
+
<table>
|
|
346
|
+
<tr><td>Security signoffs</td><td class="num">${sg.approved}/${sg.total} approved</td></tr>
|
|
347
|
+
<tr><td>QA reports</td><td class="num">${qa.passed}/${qa.total} passed</td></tr>
|
|
348
|
+
</table>
|
|
349
|
+
|
|
350
|
+
<div class="footer">Source: docs/security/CSO-*.md, docs/qa-reports/QA-*.md, .great_cto/PROJECT.md</div>
|
|
351
|
+
</body></html>`;
|
|
352
|
+
}
|
|
353
|
+
function escapeHtml(s) {
|
|
354
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
355
|
+
}
|
|
356
|
+
// ── Main entry ─────────────────────────────────────────────────────────────
|
|
357
|
+
export async function runReport(args) {
|
|
358
|
+
let report;
|
|
359
|
+
try {
|
|
360
|
+
if (args.type === "cost")
|
|
361
|
+
report = buildCostReport(args);
|
|
362
|
+
else if (args.type === "agents")
|
|
363
|
+
report = buildAgentsReport(args);
|
|
364
|
+
else if (args.type === "compliance")
|
|
365
|
+
report = buildComplianceReport(args);
|
|
366
|
+
else {
|
|
367
|
+
console.error(`unknown report type: ${args.type}`);
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
console.error(`report failed: ${e.message}`);
|
|
373
|
+
return 2;
|
|
374
|
+
}
|
|
375
|
+
if (args.format === "json") {
|
|
376
|
+
console.log(JSON.stringify(report, null, 2));
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
let html;
|
|
380
|
+
if (args.type === "cost")
|
|
381
|
+
html = renderCostHtml(report);
|
|
382
|
+
else if (args.type === "agents")
|
|
383
|
+
html = renderAgentsHtml(report);
|
|
384
|
+
else
|
|
385
|
+
html = renderComplianceHtml(report);
|
|
386
|
+
console.log(html);
|
|
387
|
+
}
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
export function parseReportArgs(rawArgv, cwd) {
|
|
391
|
+
const idx = rawArgv.indexOf("report");
|
|
392
|
+
if (idx === -1)
|
|
393
|
+
return null;
|
|
394
|
+
const type = rawArgv[idx + 1];
|
|
395
|
+
if (!["cost", "agents", "compliance"].includes(type)) {
|
|
396
|
+
console.error(`great-cto report: type must be cost|agents|compliance (got: ${type ?? "<missing>"})`);
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const flag = (n, def) => {
|
|
400
|
+
const i = rawArgv.indexOf(`--${n}`);
|
|
401
|
+
return i >= 0 && i < rawArgv.length - 1 ? rawArgv[i + 1] : def;
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
type,
|
|
405
|
+
format: flag("format", "html"),
|
|
406
|
+
period: flag("period", "30d"),
|
|
407
|
+
archetype: flag("archetype") ?? null,
|
|
408
|
+
cwd,
|
|
409
|
+
};
|
|
410
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
|
-
// great-cto serve — webhook receiver
|
|
1
|
+
// great-cto serve — webhook receiver with HMAC verification, retry, DLQ.
|
|
2
|
+
// v2.5.0 production-grade upgrade.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// Incoming endpoints:
|
|
5
|
+
// POST /webhook/github GitHub events with X-Hub-Signature-256 verification
|
|
6
|
+
// POST /webhook/sentry Sentry alerts (HMAC via X-Sentry-Signature-256)
|
|
7
|
+
// POST /webhook/generic Generic JSON, optional shared-secret verification
|
|
8
|
+
// GET /events Recent event log (last 50)
|
|
9
|
+
// GET /healthz Liveness probe
|
|
10
|
+
// GET /dlq Recent dead-lettered outbound deliveries
|
|
6
11
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
12
|
+
// HMAC verification is REQUIRED unless GREATCTO_WEBHOOK_INSECURE=1 is set
|
|
13
|
+
// (intended only for local dev). Configure secrets via:
|
|
14
|
+
// great-cto webhook add-incoming github --secret <hmac-secret>
|
|
9
15
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// GET /healthz Liveness probe
|
|
16
|
-
// GET /events Recent event log (last 50)
|
|
17
|
-
//
|
|
18
|
-
// All events are appended to ~/.great_cto/webhook-events.log as JSONL with
|
|
19
|
-
// fields: ts, source, event_type, payload_summary, action_taken.
|
|
16
|
+
// Outbound dispatch fires automatically on certain incoming events:
|
|
17
|
+
// github.pull_request.opened → "pr.opened" event
|
|
18
|
+
// github.issues.opened → "issue.opened" event
|
|
19
|
+
// sentry.event_alert → "incident.p0" event (severity-mapped)
|
|
20
|
+
// Each registered outgoing hook listens to a subset via its triggers list.
|
|
20
21
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
21
23
|
import { createServer } from "node:http";
|
|
22
24
|
import { homedir } from "node:os";
|
|
23
25
|
import { join } from "node:path";
|
|
26
|
+
import { dispatch, getDlqPath } from "./webhook-dispatch.js";
|
|
27
|
+
import { getIncoming } from "./webhook-config.js";
|
|
24
28
|
const EVENTS_LOG = join(homedir(), ".great_cto", "webhook-events.log");
|
|
25
29
|
function logEvent(ev, noLog) {
|
|
26
30
|
if (noLog)
|
|
@@ -37,9 +41,9 @@ function logEvent(ev, noLog) {
|
|
|
37
41
|
}
|
|
38
42
|
function readBody(req) {
|
|
39
43
|
return new Promise((resolve, reject) => {
|
|
40
|
-
|
|
41
|
-
req.on("data", chunk => (
|
|
42
|
-
req.on("end", () => resolve(
|
|
44
|
+
const chunks = [];
|
|
45
|
+
req.on("data", chunk => chunks.push(Buffer.from(chunk)));
|
|
46
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
43
47
|
req.on("error", reject);
|
|
44
48
|
});
|
|
45
49
|
}
|
|
@@ -47,40 +51,149 @@ function json(res, status, body) {
|
|
|
47
51
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
48
52
|
res.end(JSON.stringify(body));
|
|
49
53
|
}
|
|
54
|
+
// ── HMAC verification ──────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Constant-time HMAC-SHA256 verification. Returns true if signatures match.
|
|
57
|
+
* GitHub format: "sha256=<hex>"
|
|
58
|
+
* Sentry format: "<hex>" (just the digest)
|
|
59
|
+
* Generic format: either accepted
|
|
60
|
+
*/
|
|
61
|
+
function verifyHmac(secret, body, headerValue) {
|
|
62
|
+
if (!headerValue)
|
|
63
|
+
return false;
|
|
64
|
+
// Strip "sha256=" prefix if present
|
|
65
|
+
const expected = headerValue.startsWith("sha256=") ? headerValue.slice(7) : headerValue;
|
|
66
|
+
const computed = createHmac("sha256", secret).update(body).digest("hex");
|
|
67
|
+
if (expected.length !== computed.length)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(computed, "hex"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
50
76
|
// ── Endpoint handlers ──────────────────────────────────────────────────────
|
|
51
77
|
async function handleGitHub(req, res, args) {
|
|
52
78
|
const body = await readBody(req);
|
|
53
79
|
const eventType = req.headers["x-github-event"] ?? "unknown";
|
|
54
80
|
const deliveryId = req.headers["x-github-delivery"] ?? "no-id";
|
|
81
|
+
const signature = req.headers["x-hub-signature-256"];
|
|
82
|
+
// HMAC verification
|
|
83
|
+
if (!args.insecure) {
|
|
84
|
+
const cfg = getIncoming("github");
|
|
85
|
+
if (!cfg?.secret) {
|
|
86
|
+
json(res, 401, {
|
|
87
|
+
error: "github webhook not configured",
|
|
88
|
+
hint: "run: great-cto webhook add-incoming github --secret <hmac-secret>",
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!verifyHmac(cfg.secret, body, signature)) {
|
|
93
|
+
json(res, 401, { error: "invalid signature" });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
55
97
|
let payload;
|
|
56
98
|
try {
|
|
57
|
-
payload = JSON.parse(body);
|
|
99
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
58
100
|
}
|
|
59
101
|
catch {
|
|
60
102
|
json(res, 400, { error: "invalid JSON" });
|
|
61
103
|
return;
|
|
62
104
|
}
|
|
63
|
-
|
|
64
|
-
// proper signature verification and clone-and-scan flow.
|
|
105
|
+
const action = payload.action ?? "?";
|
|
65
106
|
const summary = eventType === "pull_request"
|
|
66
|
-
? `${
|
|
67
|
-
:
|
|
68
|
-
|
|
107
|
+
? `${action} PR #${payload.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
|
|
108
|
+
: eventType === "issues"
|
|
109
|
+
? `${action} issue #${payload.issue?.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
|
|
110
|
+
: `${eventType} delivery=${deliveryId}`;
|
|
111
|
+
// Outbound dispatch — map GitHub events to internal trigger names
|
|
112
|
+
let outboundFired = 0;
|
|
113
|
+
if (eventType === "pull_request" && action === "opened") {
|
|
114
|
+
outboundFired = dispatch({
|
|
115
|
+
name: "pr.opened",
|
|
116
|
+
level: "info",
|
|
117
|
+
title: `PR opened: #${payload.number} in ${payload.repository?.full_name}`,
|
|
118
|
+
body: payload.pull_request?.title ?? "",
|
|
119
|
+
meta: { url: payload.pull_request?.html_url, author: payload.sender?.login },
|
|
120
|
+
}).fired;
|
|
121
|
+
}
|
|
122
|
+
else if (eventType === "issues" && action === "opened") {
|
|
123
|
+
outboundFired = dispatch({
|
|
124
|
+
name: "issue.opened",
|
|
125
|
+
level: "info",
|
|
126
|
+
title: `Issue opened: #${payload.issue.number} in ${payload.repository?.full_name}`,
|
|
127
|
+
body: payload.issue?.title ?? "",
|
|
128
|
+
meta: { url: payload.issue?.html_url, author: payload.sender?.login },
|
|
129
|
+
}).fired;
|
|
130
|
+
}
|
|
131
|
+
logEvent({
|
|
69
132
|
ts: new Date().toISOString(),
|
|
70
133
|
source: "github",
|
|
71
134
|
event_type: eventType,
|
|
72
135
|
summary,
|
|
73
|
-
action_taken: "logged",
|
|
136
|
+
action_taken: outboundFired > 0 ? `dispatched to ${outboundFired} outbound hook(s)` : "logged",
|
|
74
137
|
meta: { delivery_id: deliveryId, pr_number: payload.number, action: payload.action },
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
|
|
138
|
+
}, args.noLog);
|
|
139
|
+
json(res, 200, { ok: true, event_type: eventType, dispatched_to: outboundFired });
|
|
140
|
+
}
|
|
141
|
+
async function handleSentry(req, res, args) {
|
|
142
|
+
const body = await readBody(req);
|
|
143
|
+
const signature = req.headers["x-sentry-signature-256"];
|
|
144
|
+
if (!args.insecure) {
|
|
145
|
+
const cfg = getIncoming("sentry");
|
|
146
|
+
if (!cfg?.secret) {
|
|
147
|
+
json(res, 401, { error: "sentry webhook not configured" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!verifyHmac(cfg.secret, body, signature)) {
|
|
151
|
+
json(res, 401, { error: "invalid signature" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
let payload;
|
|
156
|
+
try {
|
|
157
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
json(res, 400, { error: "invalid JSON" });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Sentry events typically include event.level: 'fatal' | 'error' | 'warning'
|
|
164
|
+
const level = payload?.event?.level ?? payload?.level ?? "warning";
|
|
165
|
+
const title = payload?.event?.title ?? payload?.title ?? "Sentry alert";
|
|
166
|
+
const isP0 = level === "fatal" || level === "critical";
|
|
167
|
+
const fired = dispatch({
|
|
168
|
+
name: isP0 ? "incident.p0" : "incident.alert",
|
|
169
|
+
level: isP0 ? "critical" : "error",
|
|
170
|
+
title,
|
|
171
|
+
body: payload?.event?.metadata?.value ?? "",
|
|
172
|
+
meta: { url: payload?.url, project: payload?.project_slug },
|
|
173
|
+
}).fired;
|
|
174
|
+
logEvent({
|
|
175
|
+
ts: new Date().toISOString(),
|
|
176
|
+
source: "sentry",
|
|
177
|
+
event_type: isP0 ? "p0" : "alert",
|
|
178
|
+
summary: title,
|
|
179
|
+
action_taken: `dispatched to ${fired} outbound hook(s)`,
|
|
180
|
+
meta: { level, url: payload?.url },
|
|
181
|
+
}, args.noLog);
|
|
182
|
+
json(res, 200, { ok: true, dispatched_to: fired });
|
|
78
183
|
}
|
|
79
184
|
async function handleGeneric(req, res, args) {
|
|
80
185
|
const body = await readBody(req);
|
|
81
|
-
|
|
186
|
+
const signature = req.headers["x-greatcto-signature-256"];
|
|
187
|
+
if (!args.insecure) {
|
|
188
|
+
const cfg = getIncoming("generic");
|
|
189
|
+
if (cfg?.secret && !verifyHmac(cfg.secret, body, signature)) {
|
|
190
|
+
json(res, 401, { error: "invalid signature" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
let payload = body.toString("utf8");
|
|
82
195
|
try {
|
|
83
|
-
payload = JSON.parse(
|
|
196
|
+
payload = JSON.parse(payload);
|
|
84
197
|
}
|
|
85
198
|
catch {
|
|
86
199
|
/* keep as raw string */
|
|
@@ -91,7 +204,7 @@ async function handleGeneric(req, res, args) {
|
|
|
91
204
|
event_type: "incoming",
|
|
92
205
|
summary: `payload ${body.length} bytes`,
|
|
93
206
|
action_taken: "logged",
|
|
94
|
-
meta: { payload_preview: String(body).slice(0, 200) },
|
|
207
|
+
meta: { payload_preview: String(body.toString("utf8")).slice(0, 200) },
|
|
95
208
|
};
|
|
96
209
|
logEvent(ev, args.noLog);
|
|
97
210
|
json(res, 200, { ok: true, recorded: true });
|
|
@@ -106,53 +219,71 @@ function handleEvents(_req, res) {
|
|
|
106
219
|
.filter(Boolean)
|
|
107
220
|
.slice(-50);
|
|
108
221
|
const events = lines
|
|
109
|
-
.map(l => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
})
|
|
222
|
+
.map(l => { try {
|
|
223
|
+
return JSON.parse(l);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
} })
|
|
117
228
|
.filter(Boolean);
|
|
118
229
|
json(res, 200, { events });
|
|
119
230
|
}
|
|
231
|
+
function handleDlq(_req, res) {
|
|
232
|
+
const dlq = getDlqPath();
|
|
233
|
+
if (!existsSync(dlq)) {
|
|
234
|
+
json(res, 200, { dlq: [] });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const lines = readFileSync(dlq, "utf8").split("\n").filter(Boolean).slice(-50);
|
|
238
|
+
const events = lines
|
|
239
|
+
.map(l => { try {
|
|
240
|
+
return JSON.parse(l);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
} })
|
|
245
|
+
.filter(Boolean);
|
|
246
|
+
json(res, 200, { dlq: events });
|
|
247
|
+
}
|
|
120
248
|
// ── Main entry ─────────────────────────────────────────────────────────────
|
|
121
249
|
export async function runServe(args) {
|
|
250
|
+
const insecure = args.insecure ?? process.env.GREATCTO_WEBHOOK_INSECURE === "1";
|
|
251
|
+
const finalArgs = { ...args, insecure };
|
|
122
252
|
const server = createServer(async (req, res) => {
|
|
123
253
|
const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
|
|
124
254
|
const path = url.pathname;
|
|
125
|
-
// Healthz
|
|
126
255
|
if (req.method === "GET" && path === "/healthz") {
|
|
127
|
-
return json(res, 200, { ok: true, service: "great-cto serve",
|
|
256
|
+
return json(res, 200, { ok: true, service: "great-cto serve", insecure });
|
|
128
257
|
}
|
|
129
258
|
if (req.method === "GET" && path === "/events") {
|
|
130
259
|
return handleEvents(req, res);
|
|
131
260
|
}
|
|
261
|
+
if (req.method === "GET" && path === "/dlq") {
|
|
262
|
+
return handleDlq(req, res);
|
|
263
|
+
}
|
|
132
264
|
if (req.method === "POST" && path === "/webhook/github") {
|
|
133
|
-
return handleGitHub(req, res,
|
|
265
|
+
return handleGitHub(req, res, finalArgs);
|
|
266
|
+
}
|
|
267
|
+
if (req.method === "POST" && path === "/webhook/sentry") {
|
|
268
|
+
return handleSentry(req, res, finalArgs);
|
|
134
269
|
}
|
|
135
270
|
if (req.method === "POST" && path === "/webhook/generic") {
|
|
136
|
-
return handleGeneric(req, res,
|
|
271
|
+
return handleGeneric(req, res, finalArgs);
|
|
137
272
|
}
|
|
138
273
|
json(res, 404, { error: "not found", path });
|
|
139
274
|
});
|
|
140
275
|
return new Promise(resolve => {
|
|
141
276
|
server.listen(args.port, "127.0.0.1", () => {
|
|
142
|
-
console.error(`great-cto serve → http://localhost:${args.port}`);
|
|
143
|
-
console.error(` POST /webhook/github
|
|
144
|
-
console.error(` POST /webhook/
|
|
145
|
-
console.error(`
|
|
146
|
-
console.error(` GET /
|
|
277
|
+
console.error(`great-cto serve → http://localhost:${args.port}${insecure ? " [INSECURE: HMAC OFF]" : ""}`);
|
|
278
|
+
console.error(` POST /webhook/github GitHub (HMAC SHA-256)`);
|
|
279
|
+
console.error(` POST /webhook/sentry Sentry (HMAC SHA-256)`);
|
|
280
|
+
console.error(` POST /webhook/generic Generic (optional HMAC)`);
|
|
281
|
+
console.error(` GET /events Recent event log`);
|
|
282
|
+
console.error(` GET /dlq Dead-letter queue`);
|
|
283
|
+
console.error(` GET /healthz Liveness probe`);
|
|
147
284
|
console.error(` log: ${EVENTS_LOG}`);
|
|
148
285
|
});
|
|
149
|
-
process.on("SIGINT", () => {
|
|
150
|
-
|
|
151
|
-
resolve(0);
|
|
152
|
-
});
|
|
153
|
-
process.on("SIGTERM", () => {
|
|
154
|
-
server.close();
|
|
155
|
-
resolve(0);
|
|
156
|
-
});
|
|
286
|
+
process.on("SIGINT", () => { server.close(); resolve(0); });
|
|
287
|
+
process.on("SIGTERM", () => { server.close(); resolve(0); });
|
|
157
288
|
});
|
|
158
289
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// great-cto webhook — manage incoming/outgoing webhook configuration.
|
|
2
|
+
//
|
|
3
|
+
// Usage:
|
|
4
|
+
// great-cto webhook list
|
|
5
|
+
// great-cto webhook add-incoming <name> --secret <hmac> [--events e1,e2]
|
|
6
|
+
// great-cto webhook add-outgoing <name> --url <url> --format slack|discord|pagerduty|generic --triggers t1,t2
|
|
7
|
+
// great-cto webhook remove <name>
|
|
8
|
+
// great-cto webhook test <name> (sends a test event through dispatcher)
|
|
9
|
+
import { loadConfig, addIncoming, addOutgoing, removeHook, getConfigPath, } from "./webhook-config.js";
|
|
10
|
+
import { dispatch } from "./webhook-dispatch.js";
|
|
11
|
+
export async function runWebhookCli(args) {
|
|
12
|
+
switch (args.action) {
|
|
13
|
+
case "list": {
|
|
14
|
+
const cfg = loadConfig();
|
|
15
|
+
console.log(`config: ${getConfigPath()}\n`);
|
|
16
|
+
console.log(`Incoming hooks (${cfg.incoming.length}):`);
|
|
17
|
+
if (cfg.incoming.length === 0)
|
|
18
|
+
console.log(" (none)");
|
|
19
|
+
for (const h of cfg.incoming) {
|
|
20
|
+
const sec = h.secret ? `[secret: ${h.secret.slice(0, 4)}...${h.secret.slice(-2)}]` : "[NO SECRET — INSECURE]";
|
|
21
|
+
console.log(` - ${h.name} ${sec} events=${h.events?.join(",") || "all"}`);
|
|
22
|
+
}
|
|
23
|
+
console.log(`\nOutgoing hooks (${cfg.outgoing.length}):`);
|
|
24
|
+
if (cfg.outgoing.length === 0)
|
|
25
|
+
console.log(" (none)");
|
|
26
|
+
for (const h of cfg.outgoing) {
|
|
27
|
+
const url = h.url.length > 60 ? h.url.slice(0, 57) + "..." : h.url;
|
|
28
|
+
console.log(` - ${h.name} [${h.format}] triggers=${h.triggers.join(",")}\n ${url}`);
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
case "add-incoming": {
|
|
33
|
+
if (!args.name) {
|
|
34
|
+
console.error("FAIL: --name required");
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
if (!args.secret) {
|
|
38
|
+
console.error("WARN: no --secret provided. Webhooks will only work in --insecure mode.");
|
|
39
|
+
}
|
|
40
|
+
addIncoming({
|
|
41
|
+
name: args.name,
|
|
42
|
+
secret: args.secret,
|
|
43
|
+
events: args.events,
|
|
44
|
+
});
|
|
45
|
+
console.log(`✓ added incoming hook "${args.name}"`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
case "add-outgoing": {
|
|
49
|
+
if (!args.name) {
|
|
50
|
+
console.error("FAIL: --name required");
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
if (!args.url) {
|
|
54
|
+
console.error("FAIL: --url required");
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
if (!args.format) {
|
|
58
|
+
console.error("FAIL: --format required");
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
if (!args.triggers || args.triggers.length === 0) {
|
|
62
|
+
console.error("FAIL: --triggers required (comma-separated event names)");
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
const headers = {};
|
|
66
|
+
if (args.routingKey)
|
|
67
|
+
headers.routing_key = args.routingKey;
|
|
68
|
+
addOutgoing({
|
|
69
|
+
name: args.name,
|
|
70
|
+
url: args.url,
|
|
71
|
+
format: args.format,
|
|
72
|
+
triggers: args.triggers,
|
|
73
|
+
headers: Object.keys(headers).length ? headers : undefined,
|
|
74
|
+
});
|
|
75
|
+
console.log(`✓ added outgoing hook "${args.name}" (${args.format}) → ${args.triggers.join(", ")}`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
case "remove": {
|
|
79
|
+
if (!args.name) {
|
|
80
|
+
console.error("FAIL: --name required");
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
const removed = removeHook(args.name);
|
|
84
|
+
if (removed) {
|
|
85
|
+
console.log(`✓ removed hook "${args.name}"`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
console.error(`hook not found: ${args.name}`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
case "test": {
|
|
92
|
+
if (!args.name) {
|
|
93
|
+
console.error("FAIL: --name required");
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
const cfg = loadConfig();
|
|
97
|
+
const hook = cfg.outgoing.find(h => h.name === args.name);
|
|
98
|
+
if (!hook) {
|
|
99
|
+
console.error(`outgoing hook not found: ${args.name}`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const result = dispatch({
|
|
103
|
+
name: hook.triggers[0] ?? "test.event",
|
|
104
|
+
level: "info",
|
|
105
|
+
title: "great-cto webhook test",
|
|
106
|
+
body: "If you see this, your webhook is correctly configured.",
|
|
107
|
+
meta: { test: true, timestamp: new Date().toISOString() },
|
|
108
|
+
});
|
|
109
|
+
console.log(`✓ test event dispatched to ${result.fired} hook(s)`);
|
|
110
|
+
console.log(` (delivery is async — check destination shortly; check ~/.great_cto/webhook-dlq.log if it doesn't arrive)`);
|
|
111
|
+
// Give the in-flight request a moment before exit
|
|
112
|
+
await new Promise(r => setTimeout(r, 500));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.error(`unknown action: ${args.action}`);
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
export function parseWebhookArgs(rawArgv) {
|
|
120
|
+
const idx = rawArgv.indexOf("webhook");
|
|
121
|
+
if (idx === -1)
|
|
122
|
+
return null;
|
|
123
|
+
const action = rawArgv[idx + 1];
|
|
124
|
+
if (!["list", "add-incoming", "add-outgoing", "remove", "test"].includes(action)) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const flag = (n) => {
|
|
128
|
+
const i = rawArgv.indexOf(`--${n}`);
|
|
129
|
+
return i >= 0 && i < rawArgv.length - 1 ? rawArgv[i + 1] : undefined;
|
|
130
|
+
};
|
|
131
|
+
// First positional after action = name
|
|
132
|
+
let name;
|
|
133
|
+
for (let i = idx + 2; i < rawArgv.length; i++) {
|
|
134
|
+
const a = rawArgv[i];
|
|
135
|
+
if (!a.startsWith("--")) {
|
|
136
|
+
name = a;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
action,
|
|
142
|
+
name,
|
|
143
|
+
secret: flag("secret"),
|
|
144
|
+
url: flag("url"),
|
|
145
|
+
format: flag("format"),
|
|
146
|
+
triggers: flag("triggers")?.split(",").map(s => s.trim()).filter(Boolean),
|
|
147
|
+
events: flag("events")?.split(",").map(s => s.trim()).filter(Boolean),
|
|
148
|
+
routingKey: flag("routing-key"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Webhook configuration store — persisted to ~/.great_cto/webhooks.json.
|
|
2
|
+
// Used by `serve` (incoming) and the dispatcher (outgoing).
|
|
3
|
+
//
|
|
4
|
+
// Schema:
|
|
5
|
+
// {
|
|
6
|
+
// "incoming": [
|
|
7
|
+
// { "name": "github", "secret": "<hmac-secret>", "events": ["pull_request"] }
|
|
8
|
+
// ],
|
|
9
|
+
// "outgoing": [
|
|
10
|
+
// { "name": "ops-slack", "url": "https://hooks.slack.com/services/...",
|
|
11
|
+
// "format": "slack", "triggers": ["gate.approved", "incident.p0"] }
|
|
12
|
+
// ]
|
|
13
|
+
// }
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
const CONFIG_PATH = join(homedir(), ".great_cto", "webhooks.json");
|
|
18
|
+
const DEFAULT_CONFIG = { incoming: [], outgoing: [] };
|
|
19
|
+
export function getConfigPath() {
|
|
20
|
+
return CONFIG_PATH;
|
|
21
|
+
}
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
if (!existsSync(CONFIG_PATH))
|
|
24
|
+
return { ...DEFAULT_CONFIG };
|
|
25
|
+
try {
|
|
26
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
return {
|
|
29
|
+
incoming: parsed.incoming ?? [],
|
|
30
|
+
outgoing: parsed.outgoing ?? [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { ...DEFAULT_CONFIG };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function saveConfig(cfg) {
|
|
38
|
+
const dir = dirname(CONFIG_PATH);
|
|
39
|
+
if (!existsSync(dir))
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
42
|
+
}
|
|
43
|
+
export function addIncoming(hook) {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
cfg.incoming = cfg.incoming.filter(h => h.name !== hook.name);
|
|
46
|
+
cfg.incoming.push({ enabled: true, ...hook });
|
|
47
|
+
saveConfig(cfg);
|
|
48
|
+
}
|
|
49
|
+
export function addOutgoing(hook) {
|
|
50
|
+
const cfg = loadConfig();
|
|
51
|
+
cfg.outgoing = cfg.outgoing.filter(h => h.name !== hook.name);
|
|
52
|
+
cfg.outgoing.push({ enabled: true, ...hook });
|
|
53
|
+
saveConfig(cfg);
|
|
54
|
+
}
|
|
55
|
+
export function removeHook(name) {
|
|
56
|
+
const cfg = loadConfig();
|
|
57
|
+
const before = cfg.incoming.length + cfg.outgoing.length;
|
|
58
|
+
cfg.incoming = cfg.incoming.filter(h => h.name !== name);
|
|
59
|
+
cfg.outgoing = cfg.outgoing.filter(h => h.name !== name);
|
|
60
|
+
saveConfig(cfg);
|
|
61
|
+
return cfg.incoming.length + cfg.outgoing.length < before;
|
|
62
|
+
}
|
|
63
|
+
export function getIncoming(name) {
|
|
64
|
+
return loadConfig().incoming.find(h => h.name === name) ?? null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Webhook dispatcher — outbound notifications with retry + DLQ.
|
|
2
|
+
//
|
|
3
|
+
// Reliability model:
|
|
4
|
+
// - In-memory retry queue with exponential backoff (1s, 4s, 16s, 64s)
|
|
5
|
+
// - Max 4 attempts per delivery
|
|
6
|
+
// - On final failure: append to dead-letter log (~/.great_cto/webhook-dlq.log)
|
|
7
|
+
// - Dispatcher is fire-and-forget for the caller — we never block the
|
|
8
|
+
// incoming-webhook handler on outbound success
|
|
9
|
+
//
|
|
10
|
+
// Format adapters:
|
|
11
|
+
// - slack: posts as Slack incoming-webhook JSON ({text, blocks?})
|
|
12
|
+
// - discord: Discord webhook JSON ({content, embeds?})
|
|
13
|
+
// - pagerduty: Events API v2 ({routing_key, event_action, payload})
|
|
14
|
+
// - generic: arbitrary JSON POST
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { loadConfig } from "./webhook-config.js";
|
|
19
|
+
const DLQ_PATH = join(homedir(), ".great_cto", "webhook-dlq.log");
|
|
20
|
+
const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000]; // 4 attempts total
|
|
21
|
+
// ── Format adapters ────────────────────────────────────────────────────────
|
|
22
|
+
function formatSlack(ev) {
|
|
23
|
+
const emoji = ev.level === "critical" ? ":rotating_light:"
|
|
24
|
+
: ev.level === "error" ? ":x:"
|
|
25
|
+
: ev.level === "warning" ? ":warning:"
|
|
26
|
+
: ":information_source:";
|
|
27
|
+
return {
|
|
28
|
+
text: `${emoji} *${ev.title}*`,
|
|
29
|
+
attachments: ev.body ? [{ text: ev.body, color: ev.level === "critical" ? "danger" : "good" }] : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function formatDiscord(ev) {
|
|
33
|
+
const color = ev.level === "critical" ? 0xff0000
|
|
34
|
+
: ev.level === "error" ? 0xff6600
|
|
35
|
+
: ev.level === "warning" ? 0xffcc00
|
|
36
|
+
: 0x00aa66;
|
|
37
|
+
return {
|
|
38
|
+
content: ev.title,
|
|
39
|
+
embeds: ev.body ? [{ description: ev.body, color }] : undefined,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function formatPagerDuty(ev, routingKey) {
|
|
43
|
+
// PagerDuty Events API v2
|
|
44
|
+
const severity = ev.level === "critical" ? "critical"
|
|
45
|
+
: ev.level === "error" ? "error"
|
|
46
|
+
: ev.level === "warning" ? "warning"
|
|
47
|
+
: "info";
|
|
48
|
+
return {
|
|
49
|
+
routing_key: routingKey,
|
|
50
|
+
event_action: "trigger",
|
|
51
|
+
payload: {
|
|
52
|
+
summary: ev.title,
|
|
53
|
+
source: "great-cto",
|
|
54
|
+
severity,
|
|
55
|
+
custom_details: ev.body ? { details: ev.body, ...(ev.meta ?? {}) } : ev.meta,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function buildPayload(hook, ev) {
|
|
60
|
+
switch (hook.format) {
|
|
61
|
+
case "slack": return formatSlack(ev);
|
|
62
|
+
case "discord": return formatDiscord(ev);
|
|
63
|
+
case "pagerduty": {
|
|
64
|
+
// PagerDuty uses routing_key from headers config: headers.routing_key
|
|
65
|
+
const key = hook.headers?.routing_key ?? "";
|
|
66
|
+
return formatPagerDuty(ev, key);
|
|
67
|
+
}
|
|
68
|
+
case "generic":
|
|
69
|
+
default: return { event: ev.name, ...ev };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Retry / DLQ ────────────────────────────────────────────────────────────
|
|
73
|
+
async function deliver(hook, ev, attempt = 0) {
|
|
74
|
+
try {
|
|
75
|
+
const body = JSON.stringify(buildPayload(hook, ev));
|
|
76
|
+
const headers = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
...hook.headers,
|
|
79
|
+
};
|
|
80
|
+
// Don't leak routing_key as HTTP header — PagerDuty wants it in body
|
|
81
|
+
delete headers.routing_key;
|
|
82
|
+
const res = await fetch(hook.url, { method: "POST", headers, body });
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const next = attempt + 1;
|
|
89
|
+
if (next < RETRY_DELAYS_MS.length) {
|
|
90
|
+
setTimeout(() => { void deliver(hook, ev, next); }, RETRY_DELAYS_MS[next]);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Final failure — write to DLQ
|
|
94
|
+
writeToDlq(hook, ev, err);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function writeToDlq(hook, ev, err) {
|
|
98
|
+
try {
|
|
99
|
+
const dir = dirname(DLQ_PATH);
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
const entry = {
|
|
103
|
+
ts: new Date().toISOString(),
|
|
104
|
+
hook: hook.name,
|
|
105
|
+
url: hook.url,
|
|
106
|
+
event: ev,
|
|
107
|
+
error: err.message,
|
|
108
|
+
};
|
|
109
|
+
appendFileSync(DLQ_PATH, JSON.stringify(entry) + "\n");
|
|
110
|
+
process.stderr.write(`webhook-dispatch: ${hook.name} dead-lettered: ${err.message}\n`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* even DLQ failed — no recovery */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
117
|
+
/**
|
|
118
|
+
* Fire-and-forget dispatch to all outbound webhooks whose triggers include
|
|
119
|
+
* the event's name. The caller does not await delivery — we run all
|
|
120
|
+
* dispatchers in parallel with their own retry queues.
|
|
121
|
+
*/
|
|
122
|
+
export function dispatch(ev) {
|
|
123
|
+
const cfg = loadConfig();
|
|
124
|
+
const targets = cfg.outgoing.filter(h => (h.enabled !== false) && h.triggers.includes(ev.name));
|
|
125
|
+
for (const hook of targets) {
|
|
126
|
+
void deliver(hook, ev, 0);
|
|
127
|
+
}
|
|
128
|
+
return { fired: targets.length };
|
|
129
|
+
}
|
|
130
|
+
export function getDlqPath() {
|
|
131
|
+
return DLQ_PATH;
|
|
132
|
+
}
|
package/package.json
CHANGED