great-cto 2.3.4 → 2.5.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/dist/adapt.js +342 -0
- package/dist/ci.js +258 -0
- package/dist/main.js +135 -0
- package/dist/mcp.js +355 -0
- package/dist/report.js +410 -0
- package/dist/serve.js +289 -0
- 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
package/dist/serve.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// great-cto serve — webhook receiver with HMAC verification, retry, DLQ.
|
|
2
|
+
// v2.5.0 production-grade upgrade.
|
|
3
|
+
//
|
|
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
|
|
11
|
+
//
|
|
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>
|
|
15
|
+
//
|
|
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.
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { createServer } from "node:http";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { dispatch, getDlqPath } from "./webhook-dispatch.js";
|
|
27
|
+
import { getIncoming } from "./webhook-config.js";
|
|
28
|
+
const EVENTS_LOG = join(homedir(), ".great_cto", "webhook-events.log");
|
|
29
|
+
function logEvent(ev, noLog) {
|
|
30
|
+
if (noLog)
|
|
31
|
+
return;
|
|
32
|
+
try {
|
|
33
|
+
const dir = join(homedir(), ".great_cto");
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
appendFileSync(EVENTS_LOG, JSON.stringify(ev) + "\n");
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
process.stderr.write(`serve: failed to log event: ${e.message}\n`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function readBody(req) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
req.on("data", chunk => chunks.push(Buffer.from(chunk)));
|
|
46
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
47
|
+
req.on("error", reject);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function json(res, status, body) {
|
|
51
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify(body));
|
|
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
|
+
}
|
|
76
|
+
// ── Endpoint handlers ──────────────────────────────────────────────────────
|
|
77
|
+
async function handleGitHub(req, res, args) {
|
|
78
|
+
const body = await readBody(req);
|
|
79
|
+
const eventType = req.headers["x-github-event"] ?? "unknown";
|
|
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
|
+
}
|
|
97
|
+
let payload;
|
|
98
|
+
try {
|
|
99
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
json(res, 400, { error: "invalid JSON" });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const action = payload.action ?? "?";
|
|
106
|
+
const summary = eventType === "pull_request"
|
|
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({
|
|
132
|
+
ts: new Date().toISOString(),
|
|
133
|
+
source: "github",
|
|
134
|
+
event_type: eventType,
|
|
135
|
+
summary,
|
|
136
|
+
action_taken: outboundFired > 0 ? `dispatched to ${outboundFired} outbound hook(s)` : "logged",
|
|
137
|
+
meta: { delivery_id: deliveryId, pr_number: payload.number, action: payload.action },
|
|
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 });
|
|
183
|
+
}
|
|
184
|
+
async function handleGeneric(req, res, args) {
|
|
185
|
+
const body = await readBody(req);
|
|
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");
|
|
195
|
+
try {
|
|
196
|
+
payload = JSON.parse(payload);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
/* keep as raw string */
|
|
200
|
+
}
|
|
201
|
+
const ev = {
|
|
202
|
+
ts: new Date().toISOString(),
|
|
203
|
+
source: "generic",
|
|
204
|
+
event_type: "incoming",
|
|
205
|
+
summary: `payload ${body.length} bytes`,
|
|
206
|
+
action_taken: "logged",
|
|
207
|
+
meta: { payload_preview: String(body.toString("utf8")).slice(0, 200) },
|
|
208
|
+
};
|
|
209
|
+
logEvent(ev, args.noLog);
|
|
210
|
+
json(res, 200, { ok: true, recorded: true });
|
|
211
|
+
}
|
|
212
|
+
function handleEvents(_req, res) {
|
|
213
|
+
if (!existsSync(EVENTS_LOG)) {
|
|
214
|
+
json(res, 200, { events: [] });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const lines = readFileSync(EVENTS_LOG, "utf8")
|
|
218
|
+
.split("\n")
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.slice(-50);
|
|
221
|
+
const events = lines
|
|
222
|
+
.map(l => { try {
|
|
223
|
+
return JSON.parse(l);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
} })
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
json(res, 200, { events });
|
|
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
|
+
}
|
|
248
|
+
// ── Main entry ─────────────────────────────────────────────────────────────
|
|
249
|
+
export async function runServe(args) {
|
|
250
|
+
const insecure = args.insecure ?? process.env.GREATCTO_WEBHOOK_INSECURE === "1";
|
|
251
|
+
const finalArgs = { ...args, insecure };
|
|
252
|
+
const server = createServer(async (req, res) => {
|
|
253
|
+
const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
|
|
254
|
+
const path = url.pathname;
|
|
255
|
+
if (req.method === "GET" && path === "/healthz") {
|
|
256
|
+
return json(res, 200, { ok: true, service: "great-cto serve", insecure });
|
|
257
|
+
}
|
|
258
|
+
if (req.method === "GET" && path === "/events") {
|
|
259
|
+
return handleEvents(req, res);
|
|
260
|
+
}
|
|
261
|
+
if (req.method === "GET" && path === "/dlq") {
|
|
262
|
+
return handleDlq(req, res);
|
|
263
|
+
}
|
|
264
|
+
if (req.method === "POST" && path === "/webhook/github") {
|
|
265
|
+
return handleGitHub(req, res, finalArgs);
|
|
266
|
+
}
|
|
267
|
+
if (req.method === "POST" && path === "/webhook/sentry") {
|
|
268
|
+
return handleSentry(req, res, finalArgs);
|
|
269
|
+
}
|
|
270
|
+
if (req.method === "POST" && path === "/webhook/generic") {
|
|
271
|
+
return handleGeneric(req, res, finalArgs);
|
|
272
|
+
}
|
|
273
|
+
json(res, 404, { error: "not found", path });
|
|
274
|
+
});
|
|
275
|
+
return new Promise(resolve => {
|
|
276
|
+
server.listen(args.port, "127.0.0.1", () => {
|
|
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`);
|
|
284
|
+
console.error(` log: ${EVENTS_LOG}`);
|
|
285
|
+
});
|
|
286
|
+
process.on("SIGINT", () => { server.close(); resolve(0); });
|
|
287
|
+
process.on("SIGTERM", () => { server.close(); resolve(0); });
|
|
288
|
+
});
|
|
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