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

claude-integration.ts

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

claude-integration.tsBlame462 lines · 1 contributor
f5b9ef5Claude1/**
2 * Claude Code Integration Receiver
3 *
4 * Lets any Claude Code session or repository report into Gluecron with zero
5 * config. Bearer token authenticated against api_tokens (SHA-256 hash).
6 *
7 * Routes
8 * POST /api/claude/connect — validate token, auto-create repo, return git remote + MCP URL
9 * GET /api/claude/connect — same auth, return existing connection info
10 * POST /api/claude/session — fire-and-forget session telemetry (no auth required)
11 */
12
13import { Hono } from "hono";
14import { eq, and } from "drizzle-orm";
15import { createHash } from "crypto";
16import { db } from "../db";
da24dfeClaude17import { users, repositories, activityFeed, apiTokens, pullRequests } from "../db/schema";
18import { initBareRepo, getDefaultBranch } from "../git/repository";
f5b9ef5Claude19import { config } from "../lib/config";
20import type { AuthEnv } from "../middleware/auth";
21
22const claudeIntegration = new Hono<AuthEnv>();
23
24// ─── Auth helper ────────────────────────────────────────────────────────────
25
26function sha256hex(value: string): string {
27 return createHash("sha256").update(value).digest("hex");
28}
29
30/**
31 * Extract + validate Bearer token. Returns { user, token } on success,
32 * or { error } if the token is missing / invalid.
33 */
34async function authenticateBearer(
35 authHeader: string | undefined
36): Promise<
37 | { ok: true; user: typeof users.$inferSelect; tokenRow: typeof apiTokens.$inferSelect }
38 | { ok: false; error: string; status: 401 }
39> {
40 if (!authHeader?.startsWith("Bearer ")) {
41 return { ok: false, error: "Missing or malformed Authorization header. Use: Bearer <token>", status: 401 };
42 }
43
44 const raw = authHeader.slice(7).trim();
45 if (!raw) {
46 return { ok: false, error: "Empty bearer token", status: 401 };
47 }
48
49 const tokenHash = sha256hex(raw);
50
51 try {
52 const [tokenRow] = await db
53 .select()
54 .from(apiTokens)
55 .where(eq(apiTokens.tokenHash, tokenHash))
56 .limit(1);
57
58 if (!tokenRow) {
59 return { ok: false, error: "Invalid API token", status: 401 };
60 }
61
62 if (tokenRow.expiresAt && new Date(tokenRow.expiresAt) < new Date()) {
63 return { ok: false, error: "API token has expired", status: 401 };
64 }
65
66 const [user] = await db
67 .select()
68 .from(users)
69 .where(eq(users.id, tokenRow.userId))
70 .limit(1);
71
72 if (!user) {
73 return { ok: false, error: "Token owner not found", status: 401 };
74 }
75
76 // Touch last-used timestamp (best effort — no await)
77 db.update(apiTokens)
78 .set({ lastUsedAt: new Date() })
79 .where(eq(apiTokens.id, tokenRow.id))
80 .catch(() => {});
81
82 return { ok: true, user, tokenRow };
83 } catch (err) {
84 return { ok: false, error: "Authentication failed", status: 401 };
85 }
86}
87
88// ─── POST /api/claude/connect ───────────────────────────────────────────────
89
90claudeIntegration.post("/api/claude/connect", async (c) => {
91 const auth = await authenticateBearer(c.req.header("Authorization"));
92 if (!auth.ok) {
93 return c.json({ ok: false, error: auth.error }, auth.status);
94 }
95
96 const { user } = auth;
97
98 let body: { username?: string; repoName?: string; description?: string };
99 try {
100 body = await c.req.json();
101 } catch {
102 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
103 }
104
105 // repoName is optional — if omitted, return basic connection info without a repo
106 const repoName = body.repoName?.trim();
107 const description = body.description?.trim() || null;
108
109 try {
110 const baseUrl = config.appBaseUrl;
111
112 if (!repoName) {
113 // No repo requested — just confirm the token is valid
114 return c.json({
115 ok: true,
116 username: user.username,
117 mcpUrl: `${baseUrl}/mcp`,
118 message: "Token valid. Provide repoName to auto-create a repository.",
119 });
120 }
121
122 // Validate repo name
123 if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
124 return c.json({ ok: false, error: "Invalid repository name. Use letters, digits, hyphens, dots, or underscores." }, 400);
125 }
126
127 // Check if repo already exists
128 const [existing] = await db
129 .select()
130 .from(repositories)
131 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
132 .limit(1);
133
134 if (existing) {
135 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
136 const mcpUrl = `${baseUrl}/mcp`;
137 return c.json({
138 ok: true,
139 created: false,
140 gitRemote,
141 mcpUrl,
142 repoId: existing.id,
143 message: "Repository already exists.",
144 });
145 }
146
147 // Auto-create bare repo on disk
148 const diskPath = await initBareRepo(user.username, repoName);
149
150 // Insert into repositories table
151 const [repo] = await db
152 .insert(repositories)
153 .values({
154 name: repoName,
155 ownerId: user.id,
156 description,
157 isPrivate: false,
158 diskPath,
159 })
160 .returning();
161
162 if (!repo) {
163 return c.json({ ok: false, error: "Failed to create repository record" }, 500);
164 }
165
166 // Log to activity feed
167 await db.insert(activityFeed).values({
168 repositoryId: repo.id,
169 userId: user.id,
170 action: "repo_created",
171 targetType: "repository",
172 targetId: repo.id,
173 metadata: JSON.stringify({ source: "claude_connect", via: "api" }),
174 }).catch(() => {});
175
176 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
177 const mcpUrl = `${baseUrl}/mcp`;
178
179 return c.json({
180 ok: true,
181 created: true,
182 gitRemote,
183 mcpUrl,
184 repoId: repo.id,
185 message: `Repository '${repoName}' created. Push with: git push gluecron main`,
186 });
187 } catch (err) {
188 const msg = err instanceof Error ? err.message : String(err);
189 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
190 }
191});
192
193// ─── GET /api/claude/connect ────────────────────────────────────────────────
194
195claudeIntegration.get("/api/claude/connect", async (c) => {
196 const auth = await authenticateBearer(c.req.header("Authorization"));
197 if (!auth.ok) {
198 return c.json({ ok: false, error: auth.error }, auth.status);
199 }
200
201 const { user } = auth;
202 const repoName = c.req.query("repo");
203
204 try {
205 const baseUrl = config.appBaseUrl;
206
207 if (!repoName) {
208 // List all repos for this user
209 const repos = await db
210 .select({ id: repositories.id, name: repositories.name, description: repositories.description, createdAt: repositories.createdAt })
211 .from(repositories)
212 .where(eq(repositories.ownerId, user.id));
213
214 return c.json({
215 ok: true,
216 username: user.username,
217 mcpUrl: `${baseUrl}/mcp`,
218 repos: repos.map((r) => ({
219 ...r,
220 gitRemote: `${baseUrl}/${user.username}/${r.name}.git`,
221 })),
222 });
223 }
224
225 const [repo] = await db
226 .select()
227 .from(repositories)
228 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
229 .limit(1);
230
231 if (!repo) {
232 return c.json({ ok: false, error: `Repository '${repoName}' not found` }, 404);
233 }
234
235 const gitRemote = `${baseUrl}/${user.username}/${repoName}.git`;
236 const mcpUrl = `${baseUrl}/mcp`;
237
238 return c.json({
239 ok: true,
240 username: user.username,
241 repoId: repo.id,
242 repoName: repo.name,
243 gitRemote,
244 mcpUrl,
245 });
246 } catch (err) {
247 const msg = err instanceof Error ? err.message : String(err);
248 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
249 }
250});
251
252// ─── POST /api/claude/session ────────────────────────────────────────────────
253// Fire-and-forget telemetry. No auth required — sessions post to this.
254
255claudeIntegration.post("/api/claude/session", async (c) => {
256 let body: {
257 sessionId?: string;
258 repoName?: string;
259 event?: "start" | "push" | "issue" | "pr";
260 payload?: unknown;
261 };
262
263 try {
264 body = await c.req.json();
265 } catch {
266 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
267 }
268
269 const { sessionId, repoName, event, payload } = body;
270
271 if (!event || !["start", "push", "issue", "pr"].includes(event)) {
272 return c.json({ ok: false, error: "event must be one of: start, push, issue, pr" }, 400);
273 }
274
275 // Best-effort: look up the repo if repoName is provided (need owner resolution via query param or body)
276 try {
277 let repositoryId: string | null = null;
278 let userId: string | null = null;
279
280 if (repoName) {
281 // Try to find via owner in payload or query string
282 const ownerHint =
283 (payload && typeof payload === "object" && "owner" in payload
284 ? (payload as Record<string, unknown>).owner
285 : undefined) as string | undefined;
286
287 if (ownerHint) {
288 const [ownerRow] = await db
289 .select({ id: users.id })
290 .from(users)
291 .where(eq(users.username, ownerHint))
292 .limit(1);
293
294 if (ownerRow) {
295 userId = ownerRow.id;
296 const [repo] = await db
297 .select({ id: repositories.id })
298 .from(repositories)
299 .where(and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repoName)))
300 .limit(1);
301 if (repo) repositoryId = repo.id;
302 }
303 }
304 }
305
306 if (repositoryId) {
307 await db.insert(activityFeed).values({
308 repositoryId,
309 userId: userId ?? undefined,
310 action: "claude_session",
311 targetType: "session",
312 targetId: sessionId ?? null,
313 metadata: JSON.stringify({ sessionId, event, repoName, payload }),
314 });
315 }
316 // If no repo found, silently swallow (fire-and-forget)
317 } catch {
318 // Intentionally swallowed — telemetry must not block callers
319 }
320
321 return c.json({ ok: true });
322});
323
da24dfeClaude324// ─── POST /api/claude/push ────────────────────────────────────────────────────
325// Zero-config push-to-PR: after Claude pushes a branch, auto-create a draft PR.
326// If a PR already exists for this branch, returns its info without creating.
327
328claudeIntegration.post("/api/claude/push", async (c) => {
329 const auth = await authenticateBearer(c.req.header("Authorization"));
330 if (!auth.ok) {
331 return c.json({ ok: false, error: auth.error }, auth.status);
332 }
333
334 const { user } = auth;
335
336 let body: {
337 repoName?: string;
338 branch?: string;
339 title?: string;
340 description?: string;
341 baseBranch?: string;
342 };
343 try {
344 body = await c.req.json();
345 } catch {
346 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
347 }
348
349 const repoName = body.repoName?.trim();
350 const branch = body.branch?.trim();
351
352 if (!repoName || !branch) {
353 return c.json({ ok: false, error: "repoName and branch are required" }, 400);
354 }
355
356 try {
357 const [repo] = await db
358 .select()
359 .from(repositories)
360 .where(and(eq(repositories.ownerId, user.id), eq(repositories.name, repoName)))
361 .limit(1);
362
363 if (!repo) {
364 return c.json({ ok: false, error: `Repository '${repoName}' not found` }, 404);
365 }
366
367 // Determine base branch
368 let baseBranch: string = body.baseBranch?.trim() || "";
369 if (!baseBranch) {
370 try {
371 baseBranch = (await getDefaultBranch(user.username, repoName)) ?? "main";
372 } catch {
373 baseBranch = "main";
374 }
375 }
376
377 // If pushing to the base branch itself, no PR needed
378 if (branch === baseBranch) {
379 const baseUrl = config.appBaseUrl;
380 return c.json({
381 ok: true,
382 prCreated: false,
383 message: `Push to default branch '${baseBranch}' — no PR needed.`,
384 pushWatchUrl: `${baseUrl}/${user.username}/${repoName}/push/HEAD`,
385 });
386 }
387
388 // Check for existing open PR for this head branch
389 const [existingPr] = await db
390 .select({ id: pullRequests.id, number: pullRequests.number })
391 .from(pullRequests)
392 .where(
393 and(
394 eq(pullRequests.repositoryId, repo.id),
395 eq(pullRequests.headBranch, branch),
396 eq(pullRequests.state, "open")
397 )
398 )
399 .limit(1);
400
401 const baseUrl = config.appBaseUrl;
402
403 if (existingPr) {
404 return c.json({
405 ok: true,
406 prCreated: false,
407 prNumber: existingPr.number,
408 prUrl: `${baseUrl}/${user.username}/${repoName}/pulls/${existingPr.number}`,
409 message: `PR #${existingPr.number} already exists for branch '${branch}'.`,
410 });
411 }
412
413 // Auto-create a draft PR
414 const title = body.title?.trim() || `Claude: ${branch.replace(/[-_]/g, " ")}`;
415 const prBody = body.description?.trim() ||
416 `Auto-created draft PR from Claude Code session.\n\n` +
417 `Branch: \`${branch}\` → \`${baseBranch}\`\n\n` +
418 `Review and merge when ready, or push more commits to update.`;
419
420 const [newPr] = await db
421 .insert(pullRequests)
422 .values({
423 repositoryId: repo.id,
424 authorId: user.id,
425 title,
426 body: prBody,
427 state: "open",
428 baseBranch,
429 headBranch: branch,
430 isDraft: true,
431 })
432 .returning({ id: pullRequests.id, number: pullRequests.number });
433
434 if (!newPr) {
435 return c.json({ ok: false, error: "Failed to create PR record" }, 500);
436 }
437
438 // Log to activity feed (best effort)
439 await db.insert(activityFeed).values({
440 repositoryId: repo.id,
441 userId: user.id,
442 action: "pull_request.opened",
443 targetType: "pr",
444 targetId: String(newPr.number),
445 metadata: JSON.stringify({ source: "claude_push", branch, baseBranch, draft: true }),
446 }).catch(() => {});
447
448 return c.json({
449 ok: true,
450 prCreated: true,
451 prNumber: newPr.number,
452 prUrl: `${baseUrl}/${user.username}/${repoName}/pulls/${newPr.number}`,
453 pushWatchUrl: `${baseUrl}/${user.username}/${repoName}/push/${branch}`,
454 message: `Draft PR #${newPr.number} created: "${title}"`,
455 });
456 } catch (err) {
457 const msg = err instanceof Error ? err.message : String(err);
458 return c.json({ ok: false, error: `Server error: ${msg}` }, 500);
459 }
460});
461
f5b9ef5Claude462export default claudeIntegration;