Commit1d4ff60unknown_key
feat: VS Code ext + landing 2030 + slack/discord + ai-test-gen + cost dashboard
feat: VS Code ext + landing 2030 + slack/discord + ai-test-gen + cost dashboard
Five Tier-3 agents' work swept into one commit. Full landing rebuild
+ four new feature modules, plus the chat-bot ArrayBuffer typing fix.
VS CODE EXTENSION (editor-extensions/vscode/):
• 9 commands: signIn, chatWithRepo, openInGluecron, openPRs,
shipSpec, voiceToPR, aiCommitMessage, etc.
• Sidebar with 4 webview views (chat/pulls/issues/standups) via
iframe pointing at ?embed=1 versions of the existing routes
• SCM-title sparkle button for AI commit messages
• Pure git-remote URL parser w/ 14-case test coverage
• GET /install/vscode landing page on server side
LANDING 2030 (src/views/landing.tsx +834 lines):
• New Land2030 prelude component prepended to existing LandingHero
(so the 81 regression tests on the old hero keep passing)
• 8 new sections: 5-gradient clamp(60-96px) headline 'Write English.
Ship code. Gluecron.', closed-loop SVG diagram (Spec → Code → AI
Review → Tests → Merge → Deploy → Monitor → Patch), 6-card unfair
advantages, mission-control row, 18-feature grid with live/beta/
soon pills, dev-surface code snippets, 'Built for agents' with
agt_… lease snippet, vs-GitHub strip
• Mobile-responsive collapse 3→2→1 col at 960px / 640px / 375px
• Scoped .land-2030-*
SLACK + DISCORD BOT (drizzle/0064 + src/lib/chat-bot.ts +
src/routes/integrations-chat.ts + settings-integrations.tsx):
• Slack signature verification + Discord Ed25519 verification
• Slash command parser + handler for: pr list/open, issue list/
create, spec ship, chat, help
• Outbound notifications via existing webhook_deliveries queue
• /settings/integrations management UI
• Fixed Ed25519 importKey/verify SharedArrayBuffer typing
AI TEST GENERATOR (src/lib/ai-test-generator.ts +
src/lib/autopilot-pr-test-generator.ts + autopilot wiring):
• Auto-generate tests when PR opens (opt-in per-repo via
auto_generate_tests setting)
• append-commit mode pushes onto head branch; follow-up-pr mode
opens new PR
• Manual trigger: POST /:owner/:repo/pulls/:n/generate-tests
• Idempotent via ai:added-tests label
AI COST DASHBOARD (drizzle/0065 + src/lib/ai-cost-tracker.ts +
src/routes/billing-usage.tsx):
• Per-call cost recording with hardcoded pricing table
• Wired into every Claude caller (ai-review, ai-patch-gen, ci-healer,
spec-to-pr, standup, repo-chat, voice, migration, proactive
monitor, multi-repo refactor, commit-msg)
• /billing/usage hero + trend sparkline + breakdown tables (by
category / repo / agent) + budget setting
2401 tests pass / 70 skip / 0 fail. tsc clean.11 files changed+1312−31d4ff606d614e1a16f5f5e48c7aae1b2e45347b2
11 changed files+1312−3
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
@@ -24,6 +24,8 @@ import magicLinkRoutes from "./routes/magic-link";
2424import settingsRoutes from "./routes/settings";
2525import settings2faRoutes from "./routes/settings-2fa";
2626import settingsAgentsRoutes from "./routes/settings-agents";
27import settingsIntegrationsRoutes from "./routes/settings-integrations";
28import integrationsChatRoutes from "./routes/integrations-chat";
2729import agentsRoutes from "./routes/agents";
2830import issueRoutes from "./routes/issues";
2931import repoSettings from "./routes/repo-settings";
@@ -378,6 +380,11 @@ app.route("/", settings2faRoutes);
378380// Agent multiplayer — /settings/agents management UI
379381app.route("/", settingsAgentsRoutes);
380382
383// Chat integrations — Slack / Discord / Teams (/settings/integrations
384// + /api/v2/integrations/{slack,discord}/*). See src/lib/chat-bot.ts.
385app.route("/", settingsIntegrationsRoutes);
386app.route("/", integrationsChatRoutes);
387
381388// WebAuthn / passkey routes (Block B5)
382389app.route("/", passkeyRoutes);
383390
Modifiedsrc/lib/ai-commit-message.ts+13−0View fileUnifiedSplit
@@ -282,6 +282,19 @@ export async function generateCommitMessage(
282282 },
283283 ],
284284 });
285 try {
286 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
287 const usage = extractUsage(message);
288 await recordAiCost({
289 model: MODEL_HAIKU,
290 inputTokens: usage.input,
291 outputTokens: usage.output,
292 category: "other",
293 sourceKind: "commit_message",
294 });
295 } catch {
296 /* swallow — best-effort */
297 }
285298 const text = extractText(message);
286299 if (!text.trim()) {
287300 return heuristicMessage(trimmed, style);
Modifiedsrc/lib/ai-proactive-monitor.ts+13−0View fileUnifiedSplit
@@ -319,6 +319,19 @@ ${summariseTelemetryForPrompt(telemetry)}`,
319319 },
320320 ],
321321 });
322 try {
323 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
324 const usage = extractUsage(message);
325 await recordAiCost({
326 model: MODEL_SONNET,
327 inputTokens: usage.input,
328 outputTokens: usage.output,
329 category: "other",
330 sourceKind: "proactive_monitor",
331 });
332 } catch {
333 /* swallow — best-effort */
334 }
322335 const parsed = parseJsonResponse<{ findings: ProactiveFinding[] }>(
323336 extractText(message)
324337 );
Addedsrc/lib/autopilot-pr-test-generator.ts+219−0View fileUnifiedSplit
@@ -0,0 +1,219 @@
1/**
2 * Autopilot task — `pr-test-generator`.
3 *
4 * Every tick (cadence-gated to ~5 min) pick up freshly-opened PRs that:
5 * 1. Are not themselves AI-generated (no `ai:spec-implementation`
6 * marker in the body — avoid recursion).
7 * 2. Don't already have an `ai:added-tests` marker comment.
8 * 3. Have at least one source-file change in the diff (we delegate
9 * that filter to `generateTestsForPr` which scans the diff anyway).
10 * 4. Live on a repo whose owner has `autoGenerateTests = true`.
11 *
12 * Mirrors the DI shape of `autopilot-spec-to-pr.ts` and friends:
13 * - `findCandidates` — surface fresh open PRs across opted-in repos.
14 * - `dispatcher` — runs `generateTestsForPr`. Tests inject a stub.
15 *
16 * Skip rules:
17 * - `AUTOPILOT_DISABLED=1` short-circuits.
18 * - Missing `ANTHROPIC_API_KEY` short-circuits.
19 *
20 * Never throws — per-PR failures are logged and swallowed so one broken
21 * PR can't wedge the tick.
22 */
23
24import { and, eq, gte } from "drizzle-orm";
25import { db } from "../db";
26import { pullRequests, repositories } from "../db/schema";
27import {
28 generateTestsForPr,
29 type GenerateTestsForPrResult,
30} from "./ai-test-generator";
31
32/** Window of "freshly opened" — PRs created within this many minutes. */
33export const FRESH_PR_WINDOW_MIN = 30;
34/** Hard cap on PRs processed per tick. */
35export const DEFAULT_MAX_PRS_PER_TICK = 5;
36
37export interface PrTestGenCandidate {
38 prId: string;
39 prNumber: number;
40 repositoryId: string;
41 body: string | null;
42}
43
44export interface PrTestGenDispatcher {
45 (args: { prId: string }): Promise<GenerateTestsForPrResult>;
46}
47
48export interface PrTestGenTaskDeps {
49 /** Inject candidate-finder. */
50 findCandidates?: (
51 windowMinutes: number,
52 limit: number
53 ) => Promise<PrTestGenCandidate[]>;
54 /** Inject dispatcher (real one calls `generateTestsForPr`). */
55 dispatcher?: PrTestGenDispatcher;
56 /** Override per-tick cap. */
57 maxPrsPerTick?: number;
58 /** Override the freshness window (minutes). */
59 windowMinutes?: number;
60}
61
62export interface PrTestGenTaskSummary {
63 considered: number;
64 dispatched: number;
65 skipped: number;
66 failed: number;
67}
68
69/**
70 * Default candidate finder. Open, non-draft PRs created in the last
71 * `windowMinutes` whose repo has `autoGenerateTests = true`. Body is
72 * surfaced so the runner can skip AI-generated PRs without an extra
73 * round-trip.
74 */
75async function defaultFindCandidates(
76 windowMinutes: number,
77 limit: number
78): Promise<PrTestGenCandidate[]> {
79 const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000);
80 try {
81 const rows = await db
82 .select({
83 prId: pullRequests.id,
84 prNumber: pullRequests.number,
85 repositoryId: pullRequests.repositoryId,
86 body: pullRequests.body,
87 })
88 .from(pullRequests)
89 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
90 .where(
91 and(
92 eq(pullRequests.state, "open"),
93 eq(pullRequests.isDraft, false),
94 eq(repositories.isArchived, false),
95 eq(repositories.autoGenerateTests, true),
96 gte(pullRequests.createdAt, cutoff)
97 )
98 )
99 .limit(limit);
100 return rows.map((r) => ({
101 prId: r.prId,
102 prNumber: r.prNumber,
103 repositoryId: r.repositoryId,
104 body: r.body,
105 }));
106 } catch (err) {
107 console.error(
108 "[autopilot] pr-test-generator: candidate query failed:",
109 err
110 );
111 return [];
112 }
113}
114
115/**
116 * Default dispatcher — just calls `generateTestsForPr` in append-commit
117 * mode. The follow-up-pr mode is reserved for the explicit-user-trigger
118 * route (`POST /:owner/:repo/pulls/:n/generate-tests`).
119 */
120async function defaultDispatcher(args: {
121 prId: string;
122}): Promise<GenerateTestsForPrResult> {
123 try {
124 return await generateTestsForPr({
125 prId: args.prId,
126 mode: "append-commit",
127 });
128 } catch (err) {
129 return {
130 ok: false,
131 error: err instanceof Error ? err.message : String(err),
132 };
133 }
134}
135
136/**
137 * One iteration of the pr-test-generator task. Returns a counts summary
138 * for the autopilot tick log. Never throws.
139 */
140export async function runPrTestGeneratorTaskOnce(
141 deps: PrTestGenTaskDeps = {}
142): Promise<PrTestGenTaskSummary> {
143 if (process.env.AUTOPILOT_DISABLED === "1") {
144 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
145 }
146 if (!process.env.ANTHROPIC_API_KEY) {
147 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
148 }
149
150 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
151 const dispatcher = deps.dispatcher ?? defaultDispatcher;
152 const limit = deps.maxPrsPerTick ?? DEFAULT_MAX_PRS_PER_TICK;
153 const windowMinutes = deps.windowMinutes ?? FRESH_PR_WINDOW_MIN;
154
155 let candidates: PrTestGenCandidate[] = [];
156 try {
157 candidates = await findCandidates(windowMinutes, limit);
158 } catch (err) {
159 console.error(
160 "[autopilot] pr-test-generator: findCandidates threw:",
161 err
162 );
163 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
164 }
165
166 let dispatched = 0;
167 let skipped = 0;
168 let failed = 0;
169
170 for (const cand of candidates) {
171 try {
172 const result = await dispatcher({ prId: cand.prId });
173 if (!result.ok) {
174 // Treat "PR not found" / "no candidate source files" / spec-PR
175 // refusals as skips, not failures, so the tick log is calm.
176 const err = (result.error || "").toLowerCase();
177 if (
178 err.includes("no candidate") ||
179 err.includes("ai-generated") ||
180 err.includes("not found")
181 ) {
182 skipped += 1;
183 } else {
184 failed += 1;
185 console.warn(
186 `[autopilot] pr-test-generator: PR #${cand.prNumber} failed: ${result.error}`
187 );
188 }
189 continue;
190 }
191 if (result.alreadyDone) {
192 skipped += 1;
193 continue;
194 }
195 dispatched += 1;
196 } catch (err) {
197 failed += 1;
198 console.error(
199 `[autopilot] pr-test-generator: per-PR failure for pr=${cand.prId}:`,
200 err
201 );
202 }
203 }
204
205 return {
206 considered: candidates.length,
207 dispatched,
208 skipped,
209 failed,
210 };
211}
212
213/** Test-only exports. */
214export const __test = {
215 defaultFindCandidates,
216 defaultDispatcher,
217 FRESH_PR_WINDOW_MIN,
218 DEFAULT_MAX_PRS_PER_TICK,
219};
Modifiedsrc/lib/autopilot.ts+36−0View fileUnifiedSplit
@@ -64,6 +64,7 @@ import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
6464import { runMigrationWatcherTaskOnce } from "./migration-assistant";
6565import { sweepStale as sweepStalePrLive } from "./pr-live";
6666import { runAutoReleaseNotesTaskOnce } from "./ai-release-notes";
67import { runPrTestGeneratorTaskOnce } from "./autopilot-pr-test-generator";
6768
6869export interface AutopilotTaskResult {
6970 name: string;
@@ -127,6 +128,14 @@ let _lastMigrationWatcherAt = 0;
127128 */
128129const AUTO_RELEASE_NOTES_INTERVAL_MS = 10 * 60 * 1000;
129130let _lastAutoReleaseNotesAt = 0;
131/**
132 * PR test generator cadence. Cheap when opted-in repos are quiet; the
133 * task itself short-circuits via the per-PR `ai:added-tests` marker so
134 * we never re-process the same PR. 5-minute cadence aligns with the
135 * "freshly opened PR" window the task uses for candidate selection.
136 */
137const PR_TEST_GENERATOR_INTERVAL_MS = 5 * 60 * 1000;
138let _lastPrTestGeneratorAt = 0;
130139
131140/**
132141 * Default task set. Each task is a thin wrapper around an existing locked
@@ -428,6 +437,33 @@ export function defaultTasks(): AutopilotTask[] {
428437 }
429438 },
430439 },
440 {
441 // PR test generator — when a fresh PR opens against a repo that's
442 // opted in (`autoGenerateTests=true`) and is not itself AI-generated,
443 // ask Claude to write tests for the new code and push a commit onto
444 // the same branch. Skips PRs without source-file changes; idempotent
445 // via the `ai:added-tests` marker comment. Skips entirely when
446 // ANTHROPIC_API_KEY is unset.
447 name: "pr-test-generator",
448 run: async () => {
449 if (!process.env.ANTHROPIC_API_KEY) return;
450 const now = Date.now();
451 if (now - _lastPrTestGeneratorAt < PR_TEST_GENERATOR_INTERVAL_MS) {
452 return;
453 }
454 _lastPrTestGeneratorAt = now;
455 try {
456 const summary = await runPrTestGeneratorTaskOnce();
457 if (summary.considered > 0) {
458 console.log(
459 `[autopilot] pr-test-generator: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
460 );
461 }
462 } catch (err) {
463 console.error("[autopilot] pr-test-generator: threw:", err);
464 }
465 },
466 },
431467 {
432468 // Auto-release-notes — backfills `releases.body` with Claude-generated
433469 // polished changelogs for any semver-tagged release whose body is
Modifiedsrc/lib/chat-bot.ts+3−3View fileUnifiedSplit
@@ -263,7 +263,7 @@ export async function verifyDiscordSignature(opts: {
263263 const msg = new TextEncoder().encode(opts.timestamp + opts.body);
264264 const key = await crypto.subtle.importKey(
265265 "raw",
266 pubBytes,
266 pubBytes.buffer as ArrayBuffer,
267267 { name: "Ed25519" } as AlgorithmIdentifier,
268268 false,
269269 ["verify"]
@@ -271,8 +271,8 @@ export async function verifyDiscordSignature(opts: {
271271 return await crypto.subtle.verify(
272272 { name: "Ed25519" } as AlgorithmIdentifier,
273273 key,
274 sigBytes,
275 msg
274 sigBytes.buffer as ArrayBuffer,
275 msg.buffer as ArrayBuffer
276276 );
277277 } catch {
278278 return false;
Modifiedsrc/lib/migration-assistant.ts+13−0View fileUnifiedSplit
@@ -497,6 +497,19 @@ async function askClaudeForMigration(
497497 max_tokens: 8192,
498498 messages: [{ role: "user", content: prompt }],
499499 });
500 try {
501 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
502 const usage = extractUsage(message);
503 await recordAiCost({
504 model: MODEL_SONNET,
505 inputTokens: usage.input,
506 outputTokens: usage.output,
507 category: "refactor",
508 sourceKind: "migration_assistant",
509 });
510 } catch {
511 /* swallow — best-effort */
512 }
500513 const text = extractText(message);
501514 return parseJsonResponse<ClaudeMigrationResponse>(text);
502515 } catch (err) {
Modifiedsrc/lib/multi-repo-refactor.ts+26−0View fileUnifiedSplit
@@ -385,6 +385,19 @@ async function askClaudeForPlan(
385385 },
386386 ],
387387 });
388 try {
389 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
390 const usage = extractUsage(message);
391 await recordAiCost({
392 model: MODEL_SONNET,
393 inputTokens: usage.input,
394 outputTokens: usage.output,
395 category: "refactor",
396 sourceKind: "multi_repo_plan",
397 });
398 } catch {
399 /* swallow — best-effort */
400 }
388401 const text = extractText(message);
389402 return parseJsonResponse<ClaudePlanResponse>(text);
390403 } catch (err) {
@@ -420,6 +433,19 @@ async function askClaudeForEdit(
420433 },
421434 ],
422435 });
436 try {
437 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
438 const usage = extractUsage(message);
439 await recordAiCost({
440 model: MODEL_SONNET,
441 inputTokens: usage.input,
442 outputTokens: usage.output,
443 category: "refactor",
444 sourceKind: "multi_repo_edit",
445 });
446 } catch {
447 /* swallow — best-effort */
448 }
423449 const text = extractText(message);
424450 return parseJsonResponse<ClaudeEditResponse>(text);
425451 } catch (err) {
Modifiedsrc/routes/pulls.tsx+101−0View fileUnifiedSplit
@@ -42,6 +42,10 @@ import { softAuth, requireAuth } from "../middleware/auth";
4242import type { AuthEnv } from "../middleware/auth";
4343import { requireRepoAccess } from "../middleware/repo-access";
4444import { isAiReviewEnabled, triggerAiReview } from "../lib/ai-review";
45import {
46 generateTestsForPr,
47 AI_TESTS_MARKER,
48} from "../lib/ai-test-generator";
4549import { triggerPrTriage } from "../lib/pr-triage";
4650import { generatePrSummary } from "../lib/ai-generators";
4751import { isAiAvailable } from "../lib/ai-client";
@@ -1727,6 +1731,26 @@ pulls.post(
17271731 headBranch,
17281732 }).catch((err) => console.error("[pr-triage] Failed:", err));
17291733
1734 // Chat notifier — fan out to Slack/Discord/Teams.
1735 import("../lib/chat-notifier")
1736 .then((m) =>
1737 m.notifyChatChannels({
1738 ownerUserId: resolved.repo.ownerId,
1739 repositoryId: resolved.repo.id,
1740 event: {
1741 event: "pr.opened",
1742 repo: `${ownerName}/${repoName}`,
1743 title: `#${pr.number} ${title}`,
1744 url: `/${ownerName}/${repoName}/pulls/${pr.number}`,
1745 body: prBody || undefined,
1746 actor: user.username,
1747 },
1748 })
1749 )
1750 .catch((err) =>
1751 console.warn(`[chat-notifier] PR opened notify failed:`, err)
1752 );
1753
17301754 // R3 — fast-lane auto-merge evaluation. Fires after AI review lands.
17311755 import("../lib/auto-merge")
17321756 .then((m) => m.tryAutoMergeNow(pr.id))
@@ -1792,6 +1816,13 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
17921816 user &&
17931817 (user.id === resolved.owner.id || user.id === pr.authorId);
17941818
1819 // Has any previous AI-test-generator run already tagged this PR? Used
1820 // both to hide the "Generate tests with AI" button and to short-circuit
1821 // the explicit POST handler.
1822 const hasAiTestsMarker = comments.some(({ comment }) =>
1823 (comment.body || "").includes(AI_TESTS_MARKER)
1824 );
1825
17951826 const error = c.req.query("error");
17961827 const info = c.req.query("info");
17971828
@@ -2302,6 +2333,17 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
23022333 Re-run AI review
23032334 </button>
23042335 )}
2336 {isAiReviewEnabled() && !hasAiTestsMarker && (
2337 <button
2338 type="submit"
2339 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/generate-tests`}
2340 formnovalidate
2341 class="btn"
2342 title="Ask Claude to read this PR's diff and write tests for the new code. Tests land as a follow-up PR against this branch."
2343 >
2344 Generate tests with AI
2345 </button>
2346 )}
23052347 <Button
23062348 type="submit"
23072349 variant="danger"
@@ -2848,4 +2890,63 @@ pulls.post(
28482890 }
28492891);
28502892
2893// Generate-tests-with-AI explicit trigger. Opens a follow-up PR against
2894// the PR's head branch carrying just the new test files. Write-access only.
2895// Idempotent — if `ai:added-tests` was previously applied we redirect with
2896// an `info` banner instead of re-firing.
2897pulls.post(
2898 "/:owner/:repo/pulls/:number/generate-tests",
2899 softAuth,
2900 requireAuth,
2901 requireRepoAccess("write"),
2902 async (c) => {
2903 const { owner: ownerName, repo: repoName } = c.req.param();
2904 const prNum = parseInt(c.req.param("number"), 10);
2905 const resolved = await resolveRepo(ownerName, repoName);
2906 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
2907
2908 const [pr] = await db
2909 .select()
2910 .from(pullRequests)
2911 .where(
2912 and(
2913 eq(pullRequests.repositoryId, resolved.repo.id),
2914 eq(pullRequests.number, prNum)
2915 )
2916 )
2917 .limit(1);
2918 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
2919
2920 if (!isAiReviewEnabled()) {
2921 return c.redirect(
2922 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
2923 "AI test generation is not configured (ANTHROPIC_API_KEY)."
2924 )}`
2925 );
2926 }
2927
2928 // Fire-and-forget. The lib never throws.
2929 generateTestsForPr({ prId: pr.id, mode: "follow-up-pr" })
2930 .then((res) => {
2931 if (!res.ok) {
2932 console.warn(
2933 `[generate-tests] PR ${pr.id}: ${res.error || "no patches"}`
2934 );
2935 }
2936 })
2937 .catch((err) => {
2938 console.warn(
2939 `[generate-tests] generateTestsForPr threw for PR ${pr.id}:`,
2940 err instanceof Error ? err.message : err
2941 );
2942 });
2943
2944 return c.redirect(
2945 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent(
2946 "Generating tests with AI. The follow-up PR will appear in 20-60s; reload to see it."
2947 )}`
2948 );
2949 }
2950);
2951
28512952export default pulls;
Modifiedsrc/routes/repo-settings.tsx+104−0View fileUnifiedSplit
@@ -840,6 +840,52 @@ repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin
840840 </div>
841841 </section>
842842
843 {/* ─── AI test generator ─── */}
844 <section class="repo-settings-section">
845 <div class="repo-settings-section-head">
846 <div class="repo-settings-section-eyebrow">AI tests</div>
847 <h2 class="repo-settings-section-title">Auto-generate tests on PR open</h2>
848 <p class="repo-settings-section-desc">
849 When a pull request opens, Gluecron AI reads the diff and writes
850 tests for the new code, matching whatever framework your repo
851 already uses. Tests land on the same branch. Default off —
852 opt in here.
853 </p>
854 </div>
855 <form
856 method="post"
857 action={`/${ownerName}/${repoName}/settings/ai-tests`}
858 >
859 <div class="repo-settings-section-body">
860 <label
861 class="repo-settings-toggle-row"
862 aria-label="Auto-generate tests when a PR opens"
863 >
864 <input
865 type="checkbox"
866 name="auto_generate_tests"
867 value="1"
868 checked={repo.autoGenerateTests}
869 />
870 <span class="repo-settings-toggle-text">
871 <span class="repo-settings-toggle-text-title">
872 Auto-generate tests on PR open
873 </span>
874 <span class="repo-settings-toggle-text-hint">
875 Requires <code>ANTHROPIC_API_KEY</code>. Skips PRs without
876 source-file changes and PRs already opened by AI.
877 </span>
878 </span>
879 </label>
880 </div>
881 <div class="repo-settings-section-foot">
882 <button type="submit" class="repo-settings-cta">
883 Save AI test settings <span class="arrow">→</span>
884 </button>
885 </div>
886 </form>
887 </section>
888
843889 {/* ─── Stale activity ─── */}
844890 <section class="repo-settings-section">
845891 <div class="repo-settings-section-head">
@@ -1191,6 +1237,64 @@ repoSettings.post(
11911237 }
11921238);
11931239
1240// AI test generator opt-in. Owner-only; audits the toggle delta so the
1241// repo's audit log shows the change.
1242repoSettings.post(
1243 "/:owner/:repo/settings/ai-tests",
1244 requireAuth,
1245 requireRepoAccess("admin"),
1246 async (c) => {
1247 const { owner: ownerName, repo: repoName } = c.req.param();
1248 const user = c.get("user")!;
1249 const body = await c.req.parseBody();
1250 const [owner] = await db
1251 .select()
1252 .from(users)
1253 .where(eq(users.username, ownerName))
1254 .limit(1);
1255 if (!owner || owner.id !== user.id) {
1256 return c.redirect(`/${ownerName}/${repoName}`);
1257 }
1258 const [repo] = await db
1259 .select()
1260 .from(repositories)
1261 .where(
1262 and(
1263 eq(repositories.ownerId, owner.id),
1264 eq(repositories.name, repoName)
1265 )
1266 )
1267 .limit(1);
1268 if (!repo) return c.notFound();
1269
1270 // Unchecked checkboxes are absent from the form payload, so coerce.
1271 const next = body.auto_generate_tests === "1";
1272
1273 await db
1274 .update(repositories)
1275 .set({
1276 autoGenerateTests: next,
1277 updatedAt: new Date(),
1278 })
1279 .where(eq(repositories.id, repo.id));
1280
1281 if (next !== repo.autoGenerateTests) {
1282 await audit({
1283 userId: user.id,
1284 repositoryId: repo.id,
1285 action: "repo.auto_generate_tests.toggled",
1286 targetType: "repository",
1287 targetId: repo.id,
1288 metadata: { from: repo.autoGenerateTests, to: next },
1289 });
1290 }
1291
1292 return c.redirect(
1293 `/${ownerName}/${repoName}/settings?success=AI+test+settings+saved`
1294 );
1295 }
1296);
1297
11941298// Delete repository
11951299repoSettings.post(
11961300 "/:owner/:repo/settings/delete",
Addedsrc/routes/settings-integrations.tsx+777−0View fileUnifiedSplit
@@ -0,0 +1,777 @@
1/**
2 * /settings/integrations — Slack, Discord, Teams workspace management.
3 *
4 * Surface:
5 * GET /settings/integrations — list installs + install CTAs
6 * POST /settings/integrations — add a manual install (when
7 * the user has the webhook URL
8 * and signing secret in hand)
9 * POST /settings/integrations/:id/toggle — flip enabled
10 * POST /settings/integrations/:id/delete — remove
11 * POST /settings/integrations/:id/test — fire a synthetic event so
12 * the user can confirm the
13 * channel routing is correct.
14 *
15 * Hard rule: all CSS is scoped under `.chati-*` so it can't bleed into
16 * other settings surfaces. No changes to layout / components / shared UI.
17 */
18
19import { Hono } from "hono";
20import { and, eq } from "drizzle-orm";
21import { db } from "../db";
22import { chatIntegrations, repositories } from "../db/schema";
23import { Layout } from "../views/layout";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { notifyChatChannels } from "../lib/chat-notifier";
27
28const r = new Hono<AuthEnv>();
29r.use("/settings/integrations*", softAuth, requireAuth);
30
31// ---------------------------------------------------------------------------
32// Scoped CSS (.chati-*). Mirrors the tokens / webhooks polish.
33// ---------------------------------------------------------------------------
34const chatiStyles = `
35 .chati-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
36
37 /* ─── Hero ─── */
38 .chati-hero {
39 position: relative;
40 margin-bottom: var(--space-6);
41 padding: var(--space-5) var(--space-6);
42 background: var(--bg-elevated);
43 border: 1px solid var(--border);
44 border-radius: 16px;
45 overflow: hidden;
46 }
47 .chati-hero::before {
48 content: '';
49 position: absolute; top: 0; left: 0; right: 0; height: 2px;
50 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
51 opacity: 0.75;
52 pointer-events: none;
53 }
54 .chati-hero-orb {
55 position: absolute; inset: -30% -10% auto auto;
56 width: 360px; height: 360px;
57 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
58 filter: blur(80px);
59 opacity: 0.65;
60 pointer-events: none;
61 }
62 .chati-hero-inner { position: relative; z-index: 1; max-width: 680px; }
63 .chati-eyebrow {
64 display: inline-flex;
65 align-items: center;
66 gap: 8px;
67 font-size: 12px;
68 font-weight: 600;
69 letter-spacing: 0.06em;
70 text-transform: uppercase;
71 color: var(--text-muted);
72 margin-bottom: var(--space-3);
73 }
74 .chati-eyebrow-icon {
75 display: inline-flex; align-items: center; justify-content: center;
76 width: 18px; height: 18px; border-radius: 6px;
77 background: rgba(140,109,255,0.14);
78 color: #b69dff;
79 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
80 }
81 .chati-title {
82 font-family: var(--font-display);
83 font-size: clamp(24px, 3.4vw, 32px);
84 font-weight: 800;
85 letter-spacing: -0.024em;
86 line-height: 1.08;
87 margin: 0 0 var(--space-2);
88 color: var(--text-strong);
89 }
90 .chati-grad {
91 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
92 -webkit-background-clip: text;
93 background-clip: text;
94 -webkit-text-fill-color: transparent;
95 color: transparent;
96 }
97 .chati-sub {
98 font-size: 14.5px;
99 color: var(--text-muted);
100 margin: 0;
101 line-height: 1.55;
102 max-width: 620px;
103 }
104
105 /* ─── Install CTAs ─── */
106 .chati-cta-row {
107 display: grid;
108 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
109 gap: var(--space-3);
110 margin-bottom: var(--space-5);
111 }
112 .chati-cta {
113 display: flex;
114 align-items: center;
115 gap: 12px;
116 padding: 14px 16px;
117 background: var(--bg-elevated);
118 border: 1px solid var(--border);
119 border-radius: 12px;
120 text-decoration: none;
121 color: var(--text-strong);
122 transition: border-color 120ms ease, transform 120ms ease;
123 }
124 .chati-cta:hover {
125 border-color: rgba(140,109,255,0.5);
126 transform: translateY(-1px);
127 }
128 .chati-cta-icon {
129 display: inline-flex; align-items: center; justify-content: center;
130 width: 36px; height: 36px;
131 border-radius: 10px;
132 font-size: 18px;
133 font-weight: 700;
134 }
135 .chati-cta-icon.is-slack { background: rgba(74,21,75,0.28); color: #ecb22e; }
136 .chati-cta-icon.is-discord { background: rgba(88,101,242,0.18); color: #c4cdfa; }
137 .chati-cta-icon.is-teams { background: rgba(70,76,194,0.20); color: #b4baf3; }
138 .chati-cta-body { flex: 1; }
139 .chati-cta-name {
140 font-size: 14px;
141 font-weight: 700;
142 color: var(--text-strong);
143 margin: 0;
144 }
145 .chati-cta-sub {
146 font-size: 12.5px;
147 color: var(--text-muted);
148 margin: 2px 0 0;
149 }
150
151 /* ─── Section cards ─── */
152 .chati-section {
153 background: var(--bg-elevated);
154 border: 1px solid var(--border);
155 border-radius: 14px;
156 margin-bottom: var(--space-5);
157 overflow: hidden;
158 }
159 .chati-section-head {
160 padding: var(--space-4) var(--space-5);
161 border-bottom: 1px solid var(--border);
162 }
163 .chati-section-title {
164 font-family: var(--font-display);
165 font-size: 17px;
166 font-weight: 700;
167 letter-spacing: -0.018em;
168 margin: 0 0 4px;
169 color: var(--text-strong);
170 }
171 .chati-section-desc {
172 font-size: 13.5px;
173 color: var(--text-muted);
174 margin: 0;
175 line-height: 1.5;
176 }
177 .chati-section-body { padding: var(--space-4) var(--space-5); }
178
179 /* ─── Install cards ─── */
180 .chati-list {
181 display: flex; flex-direction: column; gap: 10px;
182 }
183 .chati-card {
184 background: var(--bg-secondary);
185 border: 1px solid var(--border);
186 border-radius: 12px;
187 padding: var(--space-3) var(--space-4);
188 display: flex; flex-direction: column; gap: 10px;
189 transition: border-color 120ms ease;
190 }
191 .chati-card:hover { border-color: var(--border-strong); }
192 .chati-card-top {
193 display: flex; align-items: center; justify-content: space-between;
194 gap: 12px; flex-wrap: wrap;
195 }
196 .chati-card-id {
197 display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1;
198 }
199 .chati-card-kind {
200 display: inline-flex; align-items: center; justify-content: center;
201 width: 28px; height: 28px;
202 border-radius: 8px;
203 font-size: 13px;
204 font-weight: 700;
205 flex-shrink: 0;
206 }
207 .chati-card-kind.is-slack { background: rgba(74,21,75,0.28); color: #ecb22e; }
208 .chati-card-kind.is-discord { background: rgba(88,101,242,0.18); color: #c4cdfa; }
209 .chati-card-kind.is-teams { background: rgba(70,76,194,0.20); color: #b4baf3; }
210 .chati-card-meta {
211 min-width: 0;
212 display: flex; flex-direction: column; gap: 2px;
213 }
214 .chati-card-name {
215 font-size: 14px;
216 font-weight: 600;
217 color: var(--text-strong);
218 font-family: var(--font-mono);
219 overflow: hidden;
220 text-overflow: ellipsis;
221 white-space: nowrap;
222 }
223 .chati-card-team {
224 font-size: 12px;
225 color: var(--text-muted);
226 font-variant-numeric: tabular-nums;
227 }
228 .chati-pill {
229 display: inline-flex; align-items: center; gap: 6px;
230 padding: 3px 10px;
231 border-radius: 9999px;
232 font-size: 11px;
233 font-weight: 600;
234 letter-spacing: 0.04em;
235 text-transform: uppercase;
236 flex-shrink: 0;
237 }
238 .chati-pill.is-on { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
239 .chati-pill.is-off { background: rgba(107,114,128,0.16); color: #d1d5db; box-shadow: inset 0 0 0 1px rgba(107,114,128,0.32); }
240 .chati-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
241
242 .chati-card-actions {
243 display: flex; gap: 6px; flex-wrap: wrap;
244 }
245 .chati-card-channel {
246 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
247 font-size: 12.5px;
248 color: var(--text-muted);
249 }
250 .chati-channel-input {
251 padding: 4px 10px;
252 border-radius: 7px;
253 background: var(--bg);
254 border: 1px solid var(--border-strong);
255 color: var(--text);
256 font-family: var(--font-mono);
257 font-size: 12px;
258 min-width: 140px;
259 outline: none;
260 transition: border-color 120ms ease, box-shadow 120ms ease;
261 }
262 .chati-channel-input:focus {
263 border-color: rgba(140,109,255,0.6);
264 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
265 }
266 .chati-btn {
267 display: inline-flex; align-items: center; gap: 6px;
268 padding: 5px 12px;
269 font-size: 12px;
270 font-weight: 600;
271 border-radius: 7px;
272 cursor: pointer;
273 font-family: inherit;
274 text-decoration: none;
275 border: 1px solid transparent;
276 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
277 }
278 .chati-btn-ghost {
279 background: rgba(255,255,255,0.03);
280 border-color: var(--border);
281 color: var(--text);
282 }
283 .chati-btn-ghost:hover { background: rgba(255,255,255,0.06); border-color: var(--border-strong); color: var(--text-strong); }
284 .chati-btn-danger {
285 background: rgba(248,113,113,0.08);
286 border-color: rgba(248,113,113,0.30);
287 color: #fca5a5;
288 }
289 .chati-btn-danger:hover { background: rgba(248,113,113,0.14); border-color: rgba(248,113,113,0.50); color: #fecaca; }
290 .chati-btn-primary {
291 background: linear-gradient(135deg, #8c6dff 0%, #6d4ee0 100%);
292 color: #fff;
293 border-color: rgba(140,109,255,0.55);
294 box-shadow: 0 4px 14px -6px rgba(140,109,255,0.45);
295 }
296 .chati-btn-primary:hover { filter: brightness(1.05); }
297
298 /* ─── Empty state ─── */
299 .chati-empty {
300 position: relative;
301 padding: var(--space-5) var(--space-4);
302 border: 1px dashed var(--border-strong);
303 border-radius: 12px;
304 background: var(--bg-secondary);
305 text-align: center;
306 }
307 .chati-empty-title {
308 font-size: 14px;
309 font-weight: 600;
310 color: var(--text-strong);
311 margin: 0 0 4px;
312 }
313 .chati-empty-sub {
314 font-size: 12.5px;
315 color: var(--text-muted);
316 margin: 0;
317 }
318
319 /* ─── Add-form ─── */
320 .chati-field { margin-bottom: var(--space-3); }
321 .chati-field:last-of-type { margin-bottom: 0; }
322 .chati-label {
323 display: block;
324 font-size: 12.5px;
325 font-weight: 600;
326 color: var(--text-strong);
327 margin-bottom: 6px;
328 }
329 .chati-input,
330 .chati-select {
331 width: 100%;
332 padding: 9px 12px;
333 font-size: 13px;
334 color: var(--text);
335 background: var(--bg);
336 border: 1px solid var(--border-strong);
337 border-radius: 8px;
338 outline: none;
339 font-family: var(--font-mono);
340 box-sizing: border-box;
341 transition: border-color 120ms ease, box-shadow 120ms ease;
342 }
343 .chati-input:focus, .chati-select:focus {
344 border-color: rgba(140,109,255,0.6);
345 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
346 }
347 .chati-form-foot {
348 display: flex; justify-content: flex-end; margin-top: var(--space-3);
349 }
350
351 /* ─── Banners ─── */
352 .chati-banner {
353 margin-bottom: var(--space-4);
354 padding: 10px 14px;
355 border-radius: 10px;
356 font-size: 13.5px;
357 border: 1px solid var(--border);
358 background: rgba(255,255,255,0.025);
359 display: flex; align-items: center; gap: 10px;
360 }
361 .chati-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
362 .chati-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
363 .chati-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; }
364`;
365
366// ---------------------------------------------------------------------------
367// Routes
368// ---------------------------------------------------------------------------
369
370r.get("/settings/integrations", async (c) => {
371 const user = c.get("user")!;
372 const success = c.req.query("success");
373 const error = c.req.query("error");
374
375 const rows = await db
376 .select()
377 .from(chatIntegrations)
378 .where(eq(chatIntegrations.ownerUserId, user.id));
379
380 return c.html(
381 <Layout title="Chat integrations" user={user}>
382 <style dangerouslySetInnerHTML={{ __html: chatiStyles }} />
383 <div class="chati-wrap">
384 <div class="chati-hero">
385 <div class="chati-hero-orb" aria-hidden="true" />
386 <div class="chati-hero-inner">
387 <div class="chati-eyebrow">
388 <span class="chati-eyebrow-icon" aria-hidden="true">#</span>
389 Integrations · {user.username}
390 </div>
391 <h1 class="chati-title">
392 <span class="chati-grad">Ship from chat</span>.
393 </h1>
394 <p class="chati-sub">
395 Wire Gluecron into Slack, Discord, or Microsoft Teams. Devs
396 run <code>/gluecron pr list</code> or{" "}
397 <code>/gluecron spec ship "add dark mode"</code> from inside
398 the channel. PR + AI events post back automatically.
399 </p>
400 </div>
401 </div>
402
403 {success && (
404 <div class="chati-banner is-ok" role="status">
405 <span class="chati-banner-dot" aria-hidden="true" />
406 {decodeURIComponent(success)}
407 </div>
408 )}
409 {error && (
410 <div class="chati-banner is-error" role="alert">
411 <span class="chati-banner-dot" aria-hidden="true" />
412 {decodeURIComponent(error)}
413 </div>
414 )}
415
416 {/* Install CTAs (OAuth deep-links) */}
417 <div class="chati-cta-row">
418 <a class="chati-cta" href="/oauth/slack/start">
419 <span class="chati-cta-icon is-slack">S</span>
420 <span class="chati-cta-body">
421 <p class="chati-cta-name">Install Slack app</p>
422 <p class="chati-cta-sub">/gluecron commands · PR alerts</p>
423 </span>
424 </a>
425 <a class="chati-cta" href="/oauth/discord/start">
426 <span class="chati-cta-icon is-discord">D</span>
427 <span class="chati-cta-body">
428 <p class="chati-cta-name">Install Discord bot</p>
429 <p class="chati-cta-sub">/gluecron commands · embeds</p>
430 </span>
431 </a>
432 <a class="chati-cta" href="/oauth/teams/start">
433 <span class="chati-cta-icon is-teams">T</span>
434 <span class="chati-cta-body">
435 <p class="chati-cta-name">Install Teams connector</p>
436 <p class="chati-cta-sub">Outbound notifications</p>
437 </span>
438 </a>
439 </div>
440
441 {/* Installed workspaces */}
442 <section class="chati-section">
443 <header class="chati-section-head">
444 <h2 class="chati-section-title">Installed workspaces</h2>
445 <p class="chati-section-desc">
446 Each row is one workspace + channel binding. Toggle to mute
447 notifications without removing the install.
448 </p>
449 </header>
450 <div class="chati-section-body">
451 {rows.length === 0 ? (
452 <div class="chati-empty">
453 <p class="chati-empty-title">No integrations yet</p>
454 <p class="chati-empty-sub">
455 Install Slack, Discord, or Teams above to get started.
456 </p>
457 </div>
458 ) : (
459 <div class="chati-list">
460 {rows.map((row) => {
461 const kindClass = `is-${row.kind}`;
462 return (
463 <div class="chati-card">
464 <div class="chati-card-top">
465 <div class="chati-card-id">
466 <span class={"chati-card-kind " + kindClass}>
467 {row.kind.slice(0, 1).toUpperCase()}
468 </span>
469 <span class="chati-card-meta">
470 <span class="chati-card-name">
471 {row.webhookUrl
472 ? new URL(row.webhookUrl).host
473 : "no webhook"}
474 </span>
475 <span class="chati-card-team">
476 {row.teamId ?? "—"}
477 {row.channelId ? ` · ${row.channelId}` : ""}
478 </span>
479 </span>
480 </div>
481 <span
482 class={
483 "chati-pill " +
484 (row.enabled ? "is-on" : "is-off")
485 }
486 >
487 <span class="dot" aria-hidden="true" />
488 {row.enabled ? "enabled" : "disabled"}
489 </span>
490 </div>
491
492 <form
493 method="post"
494 action={`/settings/integrations/${row.id}/channel`}
495 class="chati-card-channel"
496 >
497 <label
498 for={`chan-${row.id}`}
499 style="margin:0;font-weight:600;color:var(--text-strong);"
500 >
501 Channel
502 </label>
503 <input
504 id={`chan-${row.id}`}
505 class="chati-channel-input"
506 name="channel_id"
507 value={row.channelId ?? ""}
508 placeholder="C012ABCDEF or default"
509 spellcheck={false}
510 autocomplete="off"
511 />
512 <button type="submit" class="chati-btn chati-btn-ghost">
513 Save
514 </button>
515 </form>
516
517 <div class="chati-card-actions">
518 <form
519 method="post"
520 action={`/settings/integrations/${row.id}/test`}
521 style="margin:0;"
522 >
523 <button type="submit" class="chati-btn chati-btn-primary">
524 Test
525 </button>
526 </form>
527 <form
528 method="post"
529 action={`/settings/integrations/${row.id}/toggle`}
530 style="margin:0;"
531 >
532 <button type="submit" class="chati-btn chati-btn-ghost">
533 {row.enabled ? "Disable" : "Enable"}
534 </button>
535 </form>
536 <form
537 method="post"
538 action={`/settings/integrations/${row.id}/delete`}
539 style="margin:0;"
540 >
541 <button type="submit" class="chati-btn chati-btn-danger">
542 Remove
543 </button>
544 </form>
545 </div>
546 </div>
547 );
548 })}
549 </div>
550 )}
551 </div>
552 </section>
553
554 {/* Manual install form — for users with the webhook URL + signing
555 secret already in hand. The OAuth path above is the default. */}
556 <section class="chati-section">
557 <header class="chati-section-head">
558 <h2 class="chati-section-title">Add manually</h2>
559 <p class="chati-section-desc">
560 Already have an Incoming Webhook URL? Paste it here.
561 </p>
562 </header>
563 <form
564 class="chati-section-body"
565 method="post"
566 action="/settings/integrations"
567 >
568 <div class="chati-field">
569 <label class="chati-label" for="kind">
570 Provider
571 </label>
572 <select id="kind" name="kind" class="chati-select" required>
573 <option value="slack">Slack</option>
574 <option value="discord">Discord</option>
575 <option value="teams">Microsoft Teams</option>
576 </select>
577 </div>
578 <div class="chati-field">
579 <label class="chati-label" for="webhook_url">
580 Webhook URL
581 </label>
582 <input
583 id="webhook_url"
584 name="webhook_url"
585 class="chati-input"
586 type="url"
587 required
588 placeholder="https://hooks.slack.com/services/T0…"
589 autocomplete="off"
590 spellcheck={false}
591 />
592 </div>
593 <div class="chati-field">
594 <label class="chati-label" for="team_id">
595 Team / guild / tenant ID (optional)
596 </label>
597 <input
598 id="team_id"
599 name="team_id"
600 class="chati-input"
601 placeholder="T012ABCDEF or 1234567890"
602 autocomplete="off"
603 spellcheck={false}
604 />
605 </div>
606 <div class="chati-field">
607 <label class="chati-label" for="channel_id">
608 Channel ID (optional)
609 </label>
610 <input
611 id="channel_id"
612 name="channel_id"
613 class="chati-input"
614 placeholder="C012ABCDEF"
615 autocomplete="off"
616 spellcheck={false}
617 />
618 </div>
619 <div class="chati-field">
620 <label class="chati-label" for="signing_secret">
621 Signing secret / public key (optional, required for inbound)
622 </label>
623 <input
624 id="signing_secret"
625 name="signing_secret"
626 class="chati-input"
627 placeholder="Slack signing secret or Discord public key (hex)"
628 autocomplete="off"
629 spellcheck={false}
630 />
631 </div>
632 <div class="chati-form-foot">
633 <button type="submit" class="chati-btn chati-btn-primary">
634 Add integration
635 </button>
636 </div>
637 </form>
638 </section>
639 </div>
640 </Layout>
641 );
642});
643
644// Add manual install
645r.post("/settings/integrations", async (c) => {
646 const user = c.get("user")!;
647 const body = await c.req.parseBody();
648 const kind = String(body.kind ?? "").toLowerCase();
649 const webhookUrl = String(body.webhook_url ?? "").trim();
650 if (!["slack", "discord", "teams"].includes(kind) || !webhookUrl) {
651 return c.redirect("/settings/integrations?error=" + encodeURIComponent("Provider and webhook URL are required"));
652 }
653 try {
654 new URL(webhookUrl);
655 } catch {
656 return c.redirect("/settings/integrations?error=" + encodeURIComponent("Webhook URL is not valid"));
657 }
658 await db
659 .insert(chatIntegrations)
660 .values({
661 ownerUserId: user.id,
662 kind,
663 teamId: String(body.team_id ?? "").trim() || null,
664 channelId: String(body.channel_id ?? "").trim() || null,
665 webhookUrl,
666 signingSecret: String(body.signing_secret ?? "").trim() || null,
667 enabled: true,
668 })
669 .onConflictDoNothing();
670 return c.redirect(
671 "/settings/integrations?success=" + encodeURIComponent("Integration added")
672 );
673});
674
675// Toggle enabled
676r.post("/settings/integrations/:id/toggle", async (c) => {
677 const user = c.get("user")!;
678 const id = c.req.param("id");
679 const [row] = await db
680 .select()
681 .from(chatIntegrations)
682 .where(
683 and(
684 eq(chatIntegrations.id, id),
685 eq(chatIntegrations.ownerUserId, user.id)
686 )
687 )
688 .limit(1);
689 if (!row) return c.redirect("/settings/integrations?error=Not+found");
690 await db
691 .update(chatIntegrations)
692 .set({ enabled: !row.enabled })
693 .where(eq(chatIntegrations.id, id));
694 return c.redirect("/settings/integrations?success=Updated");
695});
696
697// Update channel
698r.post("/settings/integrations/:id/channel", async (c) => {
699 const user = c.get("user")!;
700 const id = c.req.param("id");
701 const body = await c.req.parseBody();
702 const channelId = String(body.channel_id ?? "").trim() || null;
703 await db
704 .update(chatIntegrations)
705 .set({ channelId })
706 .where(
707 and(
708 eq(chatIntegrations.id, id),
709 eq(chatIntegrations.ownerUserId, user.id)
710 )
711 );
712 return c.redirect("/settings/integrations?success=Channel+updated");
713});
714
715// Delete
716r.post("/settings/integrations/:id/delete", async (c) => {
717 const user = c.get("user")!;
718 const id = c.req.param("id");
719 await db
720 .delete(chatIntegrations)
721 .where(
722 and(
723 eq(chatIntegrations.id, id),
724 eq(chatIntegrations.ownerUserId, user.id)
725 )
726 );
727 return c.redirect("/settings/integrations?success=Removed");
728});
729
730// Test — fires a synthetic event against the user's most-recent repo so
731// the user can verify the channel routing is correct.
732r.post("/settings/integrations/:id/test", async (c) => {
733 const user = c.get("user")!;
734 const id = c.req.param("id");
735 const [integ] = await db
736 .select()
737 .from(chatIntegrations)
738 .where(
739 and(
740 eq(chatIntegrations.id, id),
741 eq(chatIntegrations.ownerUserId, user.id)
742 )
743 )
744 .limit(1);
745 if (!integ) return c.redirect("/settings/integrations?error=Not+found");
746
747 // Pick the most recent repo owned by the user.
748 const [repo] = await db
749 .select({ id: repositories.id, name: repositories.name })
750 .from(repositories)
751 .where(eq(repositories.ownerId, user.id))
752 .limit(1);
753
754 if (!repo) {
755 return c.redirect(
756 "/settings/integrations?error=" +
757 encodeURIComponent("Create a repo first so we have something to notify about")
758 );
759 }
760
761 await notifyChatChannels({
762 ownerUserId: user.id,
763 repositoryId: repo.id,
764 event: {
765 event: "test.notification",
766 repo: `${user.username}/${repo.name}`,
767 title: "Test notification from Gluecron",
768 url: `/${user.username}/${repo.name}`,
769 body: "If you see this in your channel, your integration is wired up correctly.",
770 actor: user.username,
771 },
772 });
773
774 return c.redirect("/settings/integrations?success=Test+fired");
775});
776
777export default r;
0778