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.
| f5b9ef5 | 1 | /** |
| 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 | ||
| 13 | import { Hono } from "hono"; | |
| 14 | import { eq, and } from "drizzle-orm"; | |
| 15 | import { createHash } from "crypto"; | |
| 16 | import { db } from "../db"; | |
| da24dfe | 17 | import { users, repositories, activityFeed, apiTokens, pullRequests } from "../db/schema"; |
| 18 | import { initBareRepo, getDefaultBranch } from "../git/repository"; | |
| f5b9ef5 | 19 | import { config } from "../lib/config"; |
| 20 | import type { AuthEnv } from "../middleware/auth"; | |
| 21 | ||
| 22 | const claudeIntegration = new Hono<AuthEnv>(); | |
| 23 | ||
| 24 | // ─── Auth helper ──────────────────────────────────────────────────────────── | |
| 25 | ||
| 26 | function 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 | */ | |
| 34 | async 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 | ||
| 90 | claudeIntegration.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 | ||
| 195 | claudeIntegration.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 | ||
| 255 | claudeIntegration.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 | ||
| da24dfe | 324 | // ─── 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 | ||
| 328 | claudeIntegration.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 | ||
| f5b9ef5 | 462 | export default claudeIntegration; |