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