Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

incident-hooks.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

incident-hooks.tsxBlame1305 lines · 2 contributors
70e3293Claude1/**
2 * Production Incident Auto-Fix
3 *
4 * Receives inbound webhooks from PagerDuty, Datadog, Opsgenie, or a generic
5 * monitoring source. For each new incident:
6 *
7 * 1. Validate the secret in ?secret=<token> against stored SHA-256 hash.
8 * 2. Normalise the provider payload to { title, description, severity }.
9 * 3. Resolve the target repo (via incident_hook_configs mapping or fallback).
10 * 4. Call analyzeIncident() — git blame + recent commits + Claude AI.
11 * 5. Open an issue tagged `incident`.
12 * 6. If AI returned a fix, create a branch + commit a fix file + open a draft PR.
13 * 7. Notify repo owner + collaborators.
14 *
15 * GET /settings/incident-hooks — config page (auth required).
16 * POST /hooks/pagerduty
17 * POST /hooks/datadog
18 * POST /hooks/opsgenie
19 * POST /hooks/incident (generic)
20 */
21
22import { Hono } from "hono";
23import type { Context } from "hono";
24import { and, desc, eq, isNotNull } from "drizzle-orm";
25import { db } from "../db";
26import {
27 incidentHookConfigs,
28 issues,
29 issueLabels,
30 labels,
31 pullRequests,
32 repoCollaborators,
33 repositories,
34 users,
35} from "../db/schema";
36import { analyzeIncident } from "../lib/incident-analyzer";
37import { notify } from "../lib/notify";
38import { softAuth, requireAuth } from "../middleware/auth";
39import type { AuthEnv } from "../middleware/auth";
40import { Layout } from "../views/layout";
9b776daccantynz-alt41import { SettingsNav, settingsNavStyles } from "../views/components";
70e3293Claude42import {
43 createOrUpdateFileOnBranch,
44 getDefaultBranch,
45 getRepoPath,
46} from "../git/repository";
47
48const incidentHookRoutes = new Hono<AuthEnv>();
49
50// ─────────────────────────────────────────────────────────────────────────────
51// Crypto helpers
52// ─────────────────────────────────────────────────────────────────────────────
53
54async function sha256Hex(s: string): Promise<string> {
55 const buf = await crypto.subtle.digest(
56 "SHA-256",
57 new TextEncoder().encode(s)
58 );
59 return Array.from(new Uint8Array(buf))
60 .map((b) => b.toString(16).padStart(2, "0"))
61 .join("");
62}
63
64/** Timing-safe comparison of two hex strings. */
65function safeEqual(a: string, b: string): boolean {
66 if (a.length !== b.length) return false;
67 let diff = 0;
68 for (let i = 0; i < a.length; i++) {
69 diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
70 }
71 return diff === 0;
72}
73
74// ─────────────────────────────────────────────────────────────────────────────
75// Payload normalisation
76// ─────────────────────────────────────────────────────────────────────────────
77
78interface NormalisedIncident {
79 title: string;
80 description: string;
81 severity: "critical" | "high" | "medium" | "low";
82 /** provider-supplied repo hint (owner/repo), if any */
83 repoHint?: string;
84 /** set to 'resolve' to skip issue creation */
85 action?: "trigger" | "resolve";
86}
87
88function normalisePagerDuty(body: unknown): NormalisedIncident | null {
89 try {
90 const pd = body as {
91 messages?: Array<{
92 event?: string;
93 payload?: {
94 summary?: string;
95 severity?: string;
96 source?: string;
97 custom_details?: Record<string, string>;
98 };
99 links?: Array<{ href: string; text: string }>;
100 }>;
101 };
102 const msg = pd?.messages?.[0];
103 if (!msg) return null;
104 const event = msg.event || "";
105 const payload = msg.payload || {};
106 const sev = payload.severity || "error";
107 const severityMap: Record<string, NormalisedIncident["severity"]> = {
108 critical: "critical",
109 error: "high",
110 warning: "medium",
111 info: "low",
112 };
113 const details = payload.custom_details || {};
114 const descLines = [
115 `**Source:** ${payload.source || "(unknown)"}`,
116 "",
117 ...Object.entries(details).map(([k, v]) => `**${k}:** ${v}`),
118 ...(msg.links?.length
119 ? [
120 "",
121 "**Links:**",
122 ...msg.links.map((l) => `- [${l.text}](${l.href})`),
123 ]
124 : []),
125 ];
126 return {
127 title: payload.summary || "PagerDuty incident",
128 description: descLines.join("\n"),
129 severity: severityMap[sev] ?? "high",
130 repoHint: details["repo"] || details["repository"],
131 action: event.includes("resolve") ? "resolve" : "trigger",
132 };
133 } catch {
134 return null;
135 }
136}
137
138function normaliseDatadog(body: unknown): NormalisedIncident | null {
139 try {
140 const dd = body as {
141 title?: string;
142 text?: string;
143 priority?: string;
144 tags?: string[];
145 alert_type?: string;
146 };
147 const alertType = dd?.alert_type || "error";
148 const sevMap: Record<string, NormalisedIncident["severity"]> = {
149 error: "high",
150 warning: "medium",
151 info: "low",
152 success: "low",
153 };
154 const tags = dd?.tags || [];
155 const repoTag = tags.find(
156 (t) => t.startsWith("repo:") || t.startsWith("repository:")
157 );
158 const repoHint = repoTag ? repoTag.split(":")[1] : undefined;
159 return {
160 title: dd?.title || "Datadog alert",
161 description: [
162 dd?.text || "",
163 "",
164 tags.length ? `**Tags:** ${tags.join(", ")}` : "",
165 ]
166 .join("\n")
167 .trim(),
168 severity: sevMap[alertType] ?? "high",
169 repoHint,
170 action: alertType === "success" ? "resolve" : "trigger",
171 };
172 } catch {
173 return null;
174 }
175}
176
177function normaliseOpsgenie(body: unknown): NormalisedIncident | null {
178 try {
179 const og = body as {
180 alert?: {
181 message?: string;
182 description?: string;
183 priority?: string;
184 tags?: string[];
185 details?: Record<string, string>;
186 };
187 action?: string;
188 };
189 const alert = og?.alert || {};
190 const priMap: Record<string, NormalisedIncident["severity"]> = {
191 P1: "critical",
192 P2: "high",
193 P3: "medium",
194 P4: "low",
195 P5: "low",
196 };
197 const details = alert.details || {};
198 const repoHint = details["repo"] || details["repository"];
199 return {
200 title: alert.message || "Opsgenie alert",
201 description: [
202 alert.description || "",
203 ...(alert.tags?.length ? [`**Tags:** ${alert.tags.join(", ")}`] : []),
204 ...Object.entries(details).map(([k, v]) => `**${k}:** ${v}`),
205 ]
206 .join("\n")
207 .trim(),
208 severity: priMap[alert.priority ?? "P3"] ?? "high",
209 repoHint,
210 action: (og?.action || "").toLowerCase().includes("close")
211 ? "resolve"
212 : "trigger",
213 };
214 } catch {
215 return null;
216 }
217}
218
219function normaliseGeneric(body: unknown): NormalisedIncident | null {
220 try {
221 const g = body as {
222 title?: string;
223 description?: string;
224 severity?: string;
225 source?: string;
226 repo?: string;
227 tags?: string[];
228 };
229 const sevMap: Record<string, NormalisedIncident["severity"]> = {
230 critical: "critical",
231 high: "high",
232 medium: "medium",
233 low: "low",
234 };
235 return {
236 title: g?.title || "Incident alert",
237 description: [
238 g?.description || "",
239 g?.source ? `**Source:** ${g.source}` : "",
240 g?.tags?.length ? `**Tags:** ${g.tags.join(", ")}` : "",
241 ]
242 .join("\n")
243 .trim(),
244 severity: sevMap[g?.severity ?? "high"] ?? "high",
245 repoHint: g?.repo,
246 action: "trigger",
247 };
248 } catch {
249 return null;
250 }
251}
252
253// ─────────────────────────────────────────────────────────────────────────────
254// Resolve the target repo for an incident
255// ─────────────────────────────────────────────────────────────────────────────
256
257type RepoRow = typeof repositories.$inferSelect;
258type UserRow = typeof users.$inferSelect;
259
260async function resolveTargetRepo(
261 provider: string,
262 secret: string,
263 repoHint?: string
264): Promise<{
265 repo: RepoRow;
266 owner: UserRow;
267 config: typeof incidentHookConfigs.$inferSelect;
268} | null> {
269 // Find the config row(s) matching this provider + secret
270 const configs = await db
271 .select()
272 .from(incidentHookConfigs)
273 .where(eq(incidentHookConfigs.provider, provider));
274
275 const secretHashInput = await sha256Hex(secret);
276
277 for (const cfg of configs) {
278 if (!safeEqual(cfg.secretHash, secretHashInput)) continue;
279
280 // Try repo hint first (if provider gave us owner/repo)
281 if (repoHint) {
282 const parts = repoHint.split("/");
283 if (parts.length === 2) {
284 const [ownerName, repoName] = parts;
285 const rows = await db
286 .select()
287 .from(repositories)
288 .innerJoin(users, eq(repositories.ownerId, users.id))
289 .where(
290 and(
291 eq(users.username, ownerName),
292 eq(repositories.name, repoName)
293 )
294 )
295 .limit(1);
296 if (rows[0]) {
297 return {
298 repo: rows[0].repositories,
299 owner: rows[0].users,
300 config: cfg,
301 };
302 }
303 }
304 }
305
306 // Use the configured repo
307 const rows = await db
308 .select()
309 .from(repositories)
310 .innerJoin(users, eq(repositories.ownerId, users.id))
311 .where(eq(repositories.id, cfg.repoId))
312 .limit(1);
313 if (rows[0]) {
314 return {
315 repo: rows[0].repositories,
316 owner: rows[0].users,
317 config: cfg,
318 };
319 }
320 }
321
322 // Fallback: no config found — cannot target a repo without one.
323 return null;
324}
325
326// ─────────────────────────────────────────────────────────────────────────────
327// Core incident processing
328// ─────────────────────────────────────────────────────────────────────────────
329
330const SEVERITY_EMOJI: Record<string, string> = {
331 critical: "🔴",
332 high: "🟠",
333 medium: "🟡",
334 low: "🟢",
335};
336
337async function processIncident(
338 provider: string,
339 incident: NormalisedIncident,
340 secret: string
341): Promise<{ ok: boolean; message: string }> {
342 if (incident.action === "resolve") {
343 return { ok: true, message: "resolve event acknowledged — no issue opened" };
344 }
345
346 // 1. Resolve repo
347 const target = await resolveTargetRepo(
348 provider,
349 secret,
350 incident.repoHint
351 );
352 if (!target) {
353 return { ok: false, message: "no matching incident_hook_config for this secret" };
354 }
355 const { repo, owner } = target;
356
357 // 2. AI analysis
358 let analysis;
359 try {
360 analysis = await analyzeIncident({
361 title: incident.title,
362 description: incident.description,
363 owner: owner.username,
364 repo: repo.name,
365 repoId: repo.id,
366 });
367 } catch (err) {
368 console.error("[incident-hooks] analyzeIncident failed:", err);
369 // Produce minimal fallback
370 analysis = {
371 likelyFiles: [],
372 suggestedFix: "",
373 issueTitle: `Incident: ${incident.title}`,
374 issueBody: incident.description,
375 branchName: `fix/incident-${Date.now()}`,
376 };
377 }
378
379 const emoji = SEVERITY_EMOJI[incident.severity] ?? "🚨";
380
381 // 3. Ensure "incident" label exists
382 let incidentLabelId: string | null = null;
383 try {
384 const existing = await db
385 .select()
386 .from(labels)
387 .where(and(eq(labels.repositoryId, repo.id), eq(labels.name, "incident")))
388 .limit(1);
389 if (existing[0]) {
390 incidentLabelId = existing[0].id;
391 } else {
392 const [created] = await db
393 .insert(labels)
394 .values({ repositoryId: repo.id, name: "incident", color: "#e11d48" })
395 .returning();
396 incidentLabelId = created?.id ?? null;
397 }
398 } catch {
399 /* best-effort */
400 }
401
402 // 4. Insert issue
403 const issueTitle = `${emoji} [INCIDENT] ${analysis.issueTitle || incident.title}`;
404 const issueBodyMd = [
405 analysis.issueBody || incident.description,
406 "",
407 "---",
408 "*Opened automatically by Gluecron Incident Monitor*",
409 ].join("\n");
410
411 let issueRow: typeof issues.$inferSelect | null = null;
412 try {
413 const [inserted] = await db
414 .insert(issues)
415 .values({
416 repositoryId: repo.id,
417 authorId: owner.id,
418 title: issueTitle.slice(0, 255),
419 body: issueBodyMd,
420 state: "open",
421 })
422 .returning();
423 issueRow = inserted ?? null;
424
425 if (issueRow?.id && incidentLabelId) {
426 await db
427 .insert(issueLabels)
428 .values({ issueId: issueRow.id, labelId: incidentLabelId })
429 .catch(() => {});
430 }
431
432 // Bump issue count
433 await db
434 .update(repositories)
435 .set({ issueCount: (repo.issueCount || 0) + 1 })
436 .where(eq(repositories.id, repo.id))
437 .catch(() => {});
438 } catch (err) {
439 console.error("[incident-hooks] issue insert failed:", err);
440 return { ok: false, message: "issue insert failed" };
441 }
442
443 // 5. If AI produced a fix, create branch + fix file + draft PR
444 let prNumber: number | null = null;
445 if (analysis.suggestedFix && analysis.branchName) {
446 try {
447 const timestamp = Date.now();
448 const fixFileName = `INCIDENT_FIX_${timestamp}.md`;
449 const fixFileContent = [
450 `# Incident Fix — ${incident.title}`,
451 "",
452 `**Severity:** ${incident.severity}`,
453 `**Opened:** ${new Date().toISOString()}`,
454 "",
455 "## Description",
456 incident.description,
457 "",
458 "## AI-Suggested Fix",
459 analysis.suggestedFix,
460 "",
461 analysis.likelyFiles.length > 0
462 ? [
463 "## Likely Affected Files",
464 ...analysis.likelyFiles.map(
465 (f) => `- \`${f.path}\` — ${f.reason}`
466 ),
467 ].join("\n")
468 : "",
469 ]
470 .join("\n")
471 .trim();
472
473 const bytes = new TextEncoder().encode(fixFileContent);
474 const result = await createOrUpdateFileOnBranch({
475 owner: owner.username,
476 name: repo.name,
477 branch: analysis.branchName,
478 filePath: fixFileName,
479 bytes,
480 message: `fix: incident response for ${incident.title.slice(0, 72)}`,
481 authorName: "Gluecron Incident Bot",
482 authorEmail: "incident-bot@gluecron.local",
483 });
484
485 if (!("error" in result)) {
486 const defaultBranch =
487 (await getDefaultBranch(owner.username, repo.name).catch(
488 () => null
489 )) ||
490 repo.defaultBranch ||
491 "main";
492
493 const prBody = [
494 `Resolves #${issueRow?.number ?? "?"}`,
495 "",
496 `## Summary`,
497 `Auto-generated draft PR for incident: **${incident.title}**`,
498 "",
499 analysis.suggestedFix,
500 "",
501 "---",
502 "_This PR was opened automatically by Gluecron Incident Monitor. Review before merging._",
503 ].join("\n");
504
505 const [insertedPr] = await db
506 .insert(pullRequests)
507 .values({
508 repositoryId: repo.id,
509 authorId: owner.id,
510 title: `fix: ${incident.title.slice(0, 120)}`,
511 body: prBody,
512 state: "open",
513 baseBranch: defaultBranch,
514 headBranch: analysis.branchName,
515 isDraft: true,
516 })
517 .returning();
518 prNumber = insertedPr?.number ?? null;
519 }
520 } catch (err) {
521 console.error("[incident-hooks] draft PR creation failed:", err);
522 // non-fatal — issue was already opened
523 }
524 }
525
526 // 6. Notify repo owner + collaborators
527 const notifyTitle = issueTitle.slice(0, 180);
528 const notifyBody = prNumber
529 ? `Issue #${issueRow?.number} opened. Draft PR #${prNumber} ready for review.`
530 : `Issue #${issueRow?.number} opened.`;
531 const notifyUrl = `/${owner.username}/${repo.name}/issues/${issueRow?.number}`;
532
533 // Always notify owner
534 try {
535 await notify(owner.id, {
536 kind: "security_alert",
537 title: notifyTitle,
538 body: notifyBody,
539 url: notifyUrl,
540 repositoryId: repo.id,
541 });
542 } catch {
543 /* best-effort */
544 }
545
546 // Notify collaborators
547 try {
548 const collabs = await db
549 .select({ userId: repoCollaborators.userId })
550 .from(repoCollaborators)
551 .where(
552 and(
553 eq(repoCollaborators.repositoryId, repo.id),
554 isNotNull(repoCollaborators.acceptedAt)
555 )
556 );
557 for (const c of collabs) {
558 if (c.userId === owner.id) continue;
559 await notify(c.userId, {
560 kind: "security_alert",
561 title: notifyTitle,
562 body: notifyBody,
563 url: notifyUrl,
564 repositoryId: repo.id,
565 }).catch(() => {});
566 }
567 } catch {
568 /* best-effort */
569 }
570
571 return {
572 ok: true,
573 message: [
574 `issue #${issueRow?.number ?? "?"} opened`,
575 prNumber ? `draft PR #${prNumber} created` : "no PR (AI fix unavailable)",
576 ].join(", "),
577 };
578}
579
580// ─────────────────────────────────────────────────────────────────────────────
581// Webhook endpoint factory
582// ─────────────────────────────────────────────────────────────────────────────
583
584function makeWebhookHandler(
585 provider: string,
586 normalise: (body: unknown) => NormalisedIncident | null
587) {
588 return async (c: Context<AuthEnv>) => {
589 const secret = c.req.query("secret") || "";
590 if (!secret) {
591 return c.json({ error: "missing ?secret= query param" }, 400);
592 }
593
594 let body: unknown;
595 try {
596 body = await c.req.json();
597 } catch {
598 return c.json({ error: "invalid JSON body" }, 400);
599 }
600
601 const incident = normalise(body);
602 if (!incident) {
603 return c.json({ error: "could not parse provider payload" }, 400);
604 }
605
606 // Fire async so the webhook caller gets a fast 200 ACK
607 void processIncident(provider, incident, secret).then((r) => {
608 console.log(
609 `[incident-hooks] ${provider}: ${r.ok ? "ok" : "err"} — ${r.message}`
610 );
611 });
612
613 return c.json({ received: true }, 200);
614 };
615}
616
617// ─────────────────────────────────────────────────────────────────────────────
618// Route registration
619// ─────────────────────────────────────────────────────────────────────────────
620
621incidentHookRoutes.post(
622 "/hooks/pagerduty",
623 makeWebhookHandler("pagerduty", normalisePagerDuty)
624);
625
626incidentHookRoutes.post(
627 "/hooks/datadog",
628 makeWebhookHandler("datadog", normaliseDatadog)
629);
630
631incidentHookRoutes.post(
632 "/hooks/opsgenie",
633 makeWebhookHandler("opsgenie", normaliseOpsgenie)
634);
635
636incidentHookRoutes.post(
637 "/hooks/incident",
638 makeWebhookHandler("generic", normaliseGeneric)
639);
640
641// ─────────────────────────────────────────────────────────────────────────────
642// Config page styles
643// ─────────────────────────────────────────────────────────────────────────────
644
645const ihStyles = `
646 .ih-wrap { max-width: 1040px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
647
648 .ih-hero {
649 position: relative;
650 margin-bottom: var(--space-6);
651 padding: var(--space-5) var(--space-6);
652 background: var(--bg-elevated);
653 border: 1px solid var(--border);
654 border-radius: 16px;
655 overflow: hidden;
656 }
657 .ih-hero::before {
658 content: '';
659 position: absolute;
660 top: 0; left: 0; right: 0;
661 height: 2px;
662 background: linear-gradient(90deg, transparent 0%, #f43f5e 30%, #fb923c 70%, transparent 100%);
663 opacity: 0.75;
664 }
665 .ih-hero-orb {
666 position: absolute;
667 inset: -30% -5% auto auto;
668 width: 400px; height: 400px;
669 background: radial-gradient(circle, rgba(244,63,94,0.14), rgba(251,146,60,0.08) 45%, transparent 70%);
670 filter: blur(80px);
671 opacity: 0.6;
672 pointer-events: none;
673 }
674 .ih-hero-inner { position: relative; z-index: 1; }
675 .ih-eyebrow {
676 font-size: 11.5px;
677 text-transform: uppercase;
678 letter-spacing: 0.04em;
679 color: #fb923c;
680 margin-bottom: var(--space-2);
681 display: flex;
682 align-items: center;
683 gap: 8px;
684 }
685 .ih-title {
686 font-family: var(--font-display);
687 font-size: clamp(22px, 3vw, 30px);
688 font-weight: 800;
689 letter-spacing: -0.024em;
690 line-height: 1.1;
691 margin: 0 0 var(--space-2);
692 color: var(--text-strong);
693 }
694 .ih-title-grad {
695 background: linear-gradient(135deg, #f43f5e 0%, #fb923c 100%);
696 -webkit-background-clip: text;
697 background-clip: text;
698 -webkit-text-fill-color: transparent;
699 color: transparent;
700 }
701 .ih-sub {
702 font-size: 14px;
703 color: var(--text-muted);
704 max-width: 600px;
705 line-height: 1.55;
706 margin: 0;
707 }
708
709 /* timeline */
710 .ih-timeline {
711 display: flex;
712 gap: 0;
713 margin: var(--space-4) 0 0;
714 flex-wrap: wrap;
715 row-gap: var(--space-2);
716 }
717 .ih-step {
718 display: flex;
719 align-items: center;
720 gap: 8px;
721 font-size: 12px;
722 color: var(--text-muted);
723 }
724 .ih-step-num {
725 display: inline-flex;
726 align-items: center;
727 justify-content: center;
728 width: 20px; height: 20px;
729 border-radius: 9999px;
730 background: rgba(244,63,94,0.15);
731 color: #f43f5e;
732 font-size: 10px;
733 font-weight: 700;
734 flex-shrink: 0;
735 }
736 .ih-step-arrow {
737 color: var(--text-muted);
738 margin: 0 6px;
739 font-size: 11px;
740 }
741
742 .ih-section {
743 margin-bottom: var(--space-5);
744 }
745 .ih-section-title {
746 font-family: var(--font-display);
747 font-size: 15px;
748 font-weight: 700;
749 color: var(--text-strong);
750 margin: 0 0 var(--space-3);
751 letter-spacing: -0.015em;
752 display: flex;
753 align-items: center;
754 gap: 8px;
755 }
756
757 /* provider cards */
758 .ih-providers {
759 display: grid;
760 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
761 gap: var(--space-3);
762 margin-bottom: var(--space-5);
763 }
764 .ih-card {
765 background: var(--bg-elevated);
766 border: 1px solid var(--border);
767 border-radius: 14px;
768 padding: var(--space-4);
769 transition: border-color 140ms ease;
770 }
771 .ih-card:hover { border-color: var(--border-strong); }
772 .ih-card-head {
773 display: flex;
774 align-items: center;
775 gap: 10px;
776 margin-bottom: var(--space-3);
777 }
778 .ih-provider-icon {
779 display: inline-flex;
780 align-items: center;
781 justify-content: center;
782 width: 32px; height: 32px;
783 border-radius: 8px;
784 background: rgba(244,63,94,0.10);
785 color: #f43f5e;
786 font-size: 17px;
787 flex-shrink: 0;
788 box-shadow: inset 0 0 0 1px rgba(244,63,94,0.22);
789 }
790 .ih-card-name {
791 font-family: var(--font-display);
792 font-weight: 700;
793 font-size: 14px;
794 color: var(--text-strong);
795 letter-spacing: -0.015em;
796 }
797 .ih-card-desc {
798 font-size: 12.5px;
799 color: var(--text-muted);
800 margin: 0 0 var(--space-3);
801 line-height: 1.5;
802 }
803 .ih-url-label {
804 font-size: 11px;
805 color: var(--text-muted);
806 text-transform: uppercase;
807 letter-spacing: 0.04em;
808 margin-bottom: 6px;
809 font-weight: 600;
810 }
811 .ih-url-box {
812 font-family: var(--font-mono);
813 font-size: 11.5px;
814 background: var(--bg);
815 border: 1px solid var(--border-strong);
816 border-radius: 6px;
817 padding: 7px 10px;
818 color: var(--text);
819 word-break: break-all;
820 line-height: 1.45;
821 }
822 .ih-url-note {
823 font-size: 11px;
824 color: var(--text-muted);
825 margin-top: 6px;
826 line-height: 1.45;
827 }
828 .ih-url-note code {
829 font-family: var(--font-mono);
830 font-size: 10.5px;
831 background: var(--bg-tertiary);
832 padding: 1px 4px;
833 border-radius: 4px;
834 }
835
836 /* form card */
837 .ih-form-card {
838 background: var(--bg-elevated);
839 border: 1px solid var(--border);
840 border-radius: 14px;
841 overflow: hidden;
842 }
843 .ih-form-head {
844 padding: var(--space-4) var(--space-5);
845 border-bottom: 1px solid var(--border);
846 display: flex;
847 align-items: center;
848 gap: 10px;
849 }
850 .ih-form-title {
851 font-family: var(--font-display);
852 font-size: 15px;
853 font-weight: 700;
854 color: var(--text-strong);
855 letter-spacing: -0.015em;
856 margin: 0;
857 }
858 .ih-form-body { padding: var(--space-4) var(--space-5); }
859 .ih-field { margin-bottom: var(--space-4); }
860 .ih-field:last-of-type { margin-bottom: 0; }
861 .ih-label {
862 display: block;
863 font-size: 12.5px;
864 font-weight: 600;
865 color: var(--text-strong);
866 margin-bottom: 6px;
867 }
868 .ih-input, .ih-select {
869 width: 100%;
870 padding: 9px 12px;
871 font-size: 13.5px;
872 color: var(--text);
873 background: var(--bg);
874 border: 1px solid var(--border-strong);
875 border-radius: 8px;
876 outline: none;
877 font-family: var(--font-mono);
878 transition: border-color 120ms ease, box-shadow 120ms ease;
879 box-sizing: border-box;
880 }
881 .ih-select { font-family: inherit; }
882 .ih-input:focus, .ih-select:focus {
883 border-color: #f43f5e;
884 box-shadow: 0 0 0 3px rgba(244,63,94,0.15);
885 }
886 .ih-hint {
887 font-size: 11.5px;
888 color: var(--text-muted);
889 margin-top: 5px;
890 line-height: 1.45;
891 }
892 .ih-form-foot {
893 padding: var(--space-3) var(--space-5);
894 border-top: 1px solid var(--border);
895 background: rgba(255,255,255,0.012);
896 display: flex;
897 justify-content: flex-end;
898 gap: var(--space-2);
899 align-items: center;
900 }
901 .ih-btn {
902 display: inline-flex;
903 align-items: center;
904 gap: 6px;
905 padding: 8px 18px;
906 font-size: 13px;
907 font-weight: 600;
908 border-radius: 8px;
909 cursor: pointer;
910 font-family: inherit;
911 text-decoration: none;
912 border: 1px solid transparent;
913 transition: background 120ms ease, transform 120ms ease;
914 }
915 .ih-btn-primary {
916 background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
917 color: #fff;
918 border-color: rgba(244,63,94,0.55);
919 box-shadow: 0 6px 18px -6px rgba(244,63,94,0.45);
920 }
921 .ih-btn-primary:hover {
922 transform: translateY(-1px);
923 box-shadow: 0 10px 24px -8px rgba(244,63,94,0.55);
924 }
925 .ih-btn-danger {
926 background: rgba(248,113,113,0.08);
927 border-color: rgba(248,113,113,0.30);
928 color: #fca5a5;
929 }
930 .ih-btn-danger:hover { background: rgba(248,113,113,0.14); }
931
932 /* existing configs table */
933 .ih-configs-list {
934 display: flex;
935 flex-direction: column;
936 gap: var(--space-2);
937 margin-bottom: var(--space-5);
938 }
939 .ih-config-row {
940 display: flex;
941 align-items: center;
942 gap: var(--space-3);
943 padding: var(--space-3) var(--space-4);
944 background: var(--bg-elevated);
945 border: 1px solid var(--border);
946 border-radius: 10px;
947 flex-wrap: wrap;
948 }
949 .ih-config-provider {
950 font-size: 12px;
951 font-weight: 700;
952 text-transform: uppercase;
953 letter-spacing: 0.04em;
954 padding: 3px 8px;
955 border-radius: 6px;
956 background: rgba(244,63,94,0.12);
957 color: #f43f5e;
958 }
959 .ih-config-repo {
960 font-family: var(--font-mono);
961 font-size: 13px;
962 color: var(--text-strong);
963 flex: 1;
964 min-width: 160px;
965 }
966 .ih-config-actions { margin-left: auto; }
967
968 /* banner */
969 .ih-banner {
970 padding: 10px 14px;
971 border-radius: 10px;
972 font-size: 13.5px;
973 border: 1px solid;
974 margin-bottom: var(--space-4);
975 display: flex;
976 align-items: center;
977 gap: 10px;
978 }
979 .ih-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
980 .ih-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
981`;
982
983// ─────────────────────────────────────────────────────────────────────────────
984// Settings page: GET /settings/incident-hooks
985// ─────────────────────────────────────────────────────────────────────────────
986
987incidentHookRoutes.use("/settings/incident-hooks", softAuth, requireAuth);
988
989incidentHookRoutes.get("/settings/incident-hooks", async (c) => {
990 const user = c.get("user")!;
991 const success = c.req.query("success");
992 const error = c.req.query("error");
993 const baseUrl = process.env.APP_BASE_URL || "https://gluecron.com";
994
995 // Load all configs for this user + their associated repos
996 const rows = await db
997 .select({
998 config: incidentHookConfigs,
999 repo: repositories,
1000 })
1001 .from(incidentHookConfigs)
1002 .innerJoin(repositories, eq(incidentHookConfigs.repoId, repositories.id))
1003 .where(eq(incidentHookConfigs.userId, user.id))
1004 .orderBy(desc(incidentHookConfigs.createdAt));
1005
1006 // Load user's repos for the form selector
1007 const userRepos = await db
1008 .select({ id: repositories.id, name: repositories.name })
1009 .from(repositories)
1010 .where(eq(repositories.ownerId, user.id))
1011 .orderBy(repositories.name);
1012
1013 const providers = [
1014 {
1015 id: "pagerduty",
1016 name: "PagerDuty",
1017 icon: "🚨",
1018 desc: "PagerDuty V2 webhooks. Point 'Extensions → Webhooks V2' at this URL.",
1019 endpoint: "/hooks/pagerduty",
1020 },
1021 {
1022 id: "datadog",
1023 name: "Datadog",
1024 icon: "📊",
1025 desc: "Datadog event webhooks. Add a webhook integration in your Datadog account.",
1026 endpoint: "/hooks/datadog",
1027 },
1028 {
1029 id: "opsgenie",
1030 name: "Opsgenie",
1031 icon: "🔔",
1032 desc: "Opsgenie webhook integration. Configure under Integrations → Webhook.",
1033 endpoint: "/hooks/opsgenie",
1034 },
1035 {
1036 id: "generic",
1037 name: "Generic",
1038 icon: "⚡",
1039 desc: "Custom monitoring tools. POST the generic JSON payload to this URL.",
1040 endpoint: "/hooks/incident",
1041 },
1042 ];
1043
1044 return c.html(
1045 <Layout title="Incident Auto-Fix — Settings" user={user}>
9b776daccantynz-alt1046 <style dangerouslySetInnerHTML={{ __html: settingsNavStyles }} />
1047 <div class="settings-shell" style="max-width:1500px;margin:0 auto;padding:var(--space-4)">
1048 <SettingsNav active="incident-hooks" />
1049 <div class="ih-wrap settings-content" style="max-width:none;margin:0;padding:0">
70e3293Claude1050 <div class="ih-hero">
1051 <div class="ih-hero-orb" aria-hidden="true" />
1052 <div class="ih-hero-inner">
1053 <div class="ih-eyebrow">
1054 <span>⚡</span> Incident Response
1055 </div>
1056 <h1 class="ih-title">
1057 Alert fires. <span class="ih-title-grad">Fix PR appears.</span>
1058 </h1>
1059 <p class="ih-sub">
1060 Connect PagerDuty, Datadog, or Opsgenie and Gluecron will
1061 automatically open an issue and a draft fix PR within ~30 seconds
1062 of an alert firing — before you've even picked up the phone.
1063 </p>
1064 <div class="ih-timeline">
1065 {[
1066 "Alert fires",
1067 "Webhook received",
1068 "AI analyses commits",
1069 "Issue opened",
1070 "Draft PR created",
1071 "You're paged",
1072 ].map((step, i, arr) => (
1073 <>
1074 <div class="ih-step">
1075 <span class="ih-step-num">{i + 1}</span>
1076 {step}
1077 </div>
1078 {i < arr.length - 1 && (
1079 <span class="ih-step-arrow" aria-hidden="true">→</span>
1080 )}
1081 </>
1082 ))}
1083 </div>
1084 </div>
1085 </div>
1086
1087 {success && (
1088 <div class="ih-banner is-ok" role="status">
1089 ✓ {decodeURIComponent(success)}
1090 </div>
1091 )}
1092 {error && (
1093 <div class="ih-banner is-error" role="alert">
1094 ✕ {decodeURIComponent(error)}
1095 </div>
1096 )}
1097
1098 {/* Webhook URLs */}
1099 <div class="ih-section">
1100 <h2 class="ih-section-title">
1101 <span>🔗</span> Webhook URLs
1102 </h2>
1103 <div class="ih-providers">
1104 {providers.map((p) => (
1105 <div class="ih-card">
1106 <div class="ih-card-head">
1107 <span class="ih-provider-icon" aria-hidden="true">
1108 {p.icon}
1109 </span>
1110 <span class="ih-card-name">{p.name}</span>
1111 </div>
1112 <p class="ih-card-desc">{p.desc}</p>
1113 <div class="ih-url-label">Webhook URL</div>
1114 <div class="ih-url-box">
1115 {baseUrl}{p.endpoint}?secret=YOUR_SECRET
1116 </div>
1117 <p class="ih-url-note">
1118 Replace <code>YOUR_SECRET</code> with the secret you enter
1119 below. Use the same secret in your monitoring provider's
1120 webhook settings.
1121 </p>
1122 </div>
1123 ))}
1124 </div>
1125 </div>
1126
1127 {/* Existing configs */}
1128 {rows.length > 0 && (
1129 <div class="ih-section">
1130 <h2 class="ih-section-title">
1131 <span>⚙️</span> Active Configurations
1132 </h2>
1133 <div class="ih-configs-list">
1134 {rows.map(({ config, repo }) => (
1135 <div class="ih-config-row">
1136 <span class="ih-config-provider">{config.provider}</span>
1137 <span class="ih-config-repo">
1138 {user.username}/{repo.name}
1139 </span>
1140 <div class="ih-config-actions">
1141 <form
1142 method="post"
1143 action="/settings/incident-hooks/delete"
1144 >
1145 <input type="hidden" name="id" value={config.id} />
1146 <button type="submit" class="ih-btn ih-btn-danger">
1147 Remove
1148 </button>
1149 </form>
1150 </div>
1151 </div>
1152 ))}
1153 </div>
1154 </div>
1155 )}
1156
1157 {/* Add new config */}
1158 <div class="ih-section">
1159 <div class="ih-form-card">
1160 <header class="ih-form-head">
1161 <h2 class="ih-form-title">Map a provider to a repo</h2>
1162 </header>
1163 <form method="post" action="/settings/incident-hooks">
1164 <div class="ih-form-body">
1165 <div class="ih-field">
1166 <label class="ih-label" for="ih-provider">
1167 Monitoring provider
1168 </label>
1169 <select id="ih-provider" class="ih-select" name="provider" required>
1170 {providers.map((p) => (
1171 <option value={p.id}>{p.name}</option>
1172 ))}
1173 </select>
1174 </div>
1175 <div class="ih-field">
1176 <label class="ih-label" for="ih-repo">
1177 Target repository
1178 </label>
1179 <select id="ih-repo" class="ih-select" name="repoId" required>
1180 <option value="">— choose a repo —</option>
1181 {userRepos.map((r) => (
1182 <option value={r.id}>{r.name}</option>
1183 ))}
1184 </select>
1185 <p class="ih-hint">
1186 Incidents from this provider will open issues + PRs in the
1187 selected repo.
1188 </p>
1189 </div>
1190 <div class="ih-field">
1191 <label class="ih-label" for="ih-secret">
1192 Webhook secret
1193 </label>
1194 <input
1195 id="ih-secret"
1196 class="ih-input"
1197 type="text"
1198 name="secret"
1199 required
1200 placeholder="your-secret-token"
1201 autocomplete="off"
1202 spellcheck={false}
1203 />
1204 <p class="ih-hint">
1205 Use this same value in the <code>?secret=</code> query
1206 parameter when configuring your monitoring provider. It's
1207 stored as a SHA-256 hash — never in plain text.
1208 </p>
1209 </div>
1210 </div>
1211 <div class="ih-form-foot">
1212 <button type="submit" class="ih-btn ih-btn-primary">
1213 Save configuration
1214 </button>
1215 </div>
1216 </form>
1217 </div>
1218 </div>
1219 </div>
9b776daccantynz-alt1220 </div>
70e3293Claude1221 <style dangerouslySetInnerHTML={{ __html: ihStyles }} />
1222 </Layout>
1223 );
1224});
1225
1226// POST /settings/incident-hooks — create config
1227incidentHookRoutes.post(
1228 "/settings/incident-hooks",
1229 softAuth,
1230 requireAuth,
1231 async (c) => {
1232 const user = c.get("user")!;
1233 const body = await c.req.parseBody();
1234 const provider = String(body.provider || "").trim();
1235 const repoId = String(body.repoId || "").trim();
1236 const secret = String(body.secret || "").trim();
1237
1238 if (!provider || !repoId || !secret) {
1239 return c.redirect(
1240 "/settings/incident-hooks?error=All+fields+are+required"
1241 );
1242 }
1243
1244 // Verify the user owns this repo
1245 const [repo] = await db
1246 .select()
1247 .from(repositories)
1248 .where(and(eq(repositories.id, repoId), eq(repositories.ownerId, user.id)))
1249 .limit(1);
1250 if (!repo) {
1251 return c.redirect(
1252 "/settings/incident-hooks?error=Repository+not+found+or+not+owned+by+you"
1253 );
1254 }
1255
1256 const secretHash = await sha256Hex(secret);
1257
1258 await db
1259 .insert(incidentHookConfigs)
1260 .values({
1261 userId: user.id,
1262 repoId,
1263 provider,
1264 secretHash,
1265 })
1266 .onConflictDoUpdate({
1267 target: [incidentHookConfigs.repoId, incidentHookConfigs.provider],
1268 set: { secretHash, userId: user.id },
1269 });
1270
1271 return c.redirect(
1272 `/settings/incident-hooks?success=${encodeURIComponent(
1273 `${provider} → ${repo.name} configured`
1274 )}`
1275 );
1276 }
1277);
1278
1279// POST /settings/incident-hooks/delete — remove config
1280incidentHookRoutes.post(
1281 "/settings/incident-hooks/delete",
1282 softAuth,
1283 requireAuth,
1284 async (c) => {
1285 const user = c.get("user")!;
1286 const body = await c.req.parseBody();
1287 const id = String(body.id || "").trim();
1288 if (!id) {
1289 return c.redirect("/settings/incident-hooks?error=Missing+id");
1290 }
1291 await db
1292 .delete(incidentHookConfigs)
1293 .where(
1294 and(
1295 eq(incidentHookConfigs.id, id),
1296 eq(incidentHookConfigs.userId, user.id)
1297 )
1298 );
1299 return c.redirect(
1300 "/settings/incident-hooks?success=Configuration+removed"
1301 );
1302 }
1303);
1304
1305export default incidentHookRoutes;