diff --git a/packages/spike/CLAUDE.md b/packages/spike/CLAUDE.md index 9ceab71..87df787 100644 --- a/packages/spike/CLAUDE.md +++ b/packages/spike/CLAUDE.md @@ -6,8 +6,9 @@ Discord-Gitea bridge bot. Syncs PRs, comments, and code reviews between a Gitea ``` src/ -├── server.tsx — HTTP server (webhook endpoint, health check, error log) +├── server/ — HTTP server, routes, and web pages (logs viewer, auth) ├── config.ts — Dev/prod environment config (DB paths, channel IDs, username mappings) +├── log.ts — Typed event logging (pub/sub, console + JSONL file listeners) ├── discord/ — Discord bot client, event listeners, slash commands ├── gitea/ — Gitea API calls, types, username conversion └── bridge/ — Wiring between Gitea and Discord (webhook handler, DB, Discord helpers) @@ -19,6 +20,7 @@ Dependencies flow one way: `bridge/ → gitea/`, `bridge/ → discord/`. Neither Each directory with an `index.ts` barrel is a standalone lib. Read the lib's README for its public API — you don't need to read internals to use it. +- **[server/](src/server/README.md)** — HTTP server, webhook route, log viewer page, auth page. - **[gitea/](src/gitea/README.md)** — Pure Gitea API client and types. No side effects, no Discord, no DB. - **[discord/](src/discord/README.md)** — Discord bot client, events, slash commands. Hands off to bridge for Gitea integration. - **[bridge/](src/bridge/README.md)** — The glue. Webhook handler, Discord helpers, SQLite DB for ID mappings. diff --git a/packages/spike/package.json b/packages/spike/package.json index 94f8267..f931ea8 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -7,8 +7,8 @@ "bot:cli": "bun run --watch src/cli", "bot:discord": "bun run --watch src/discord", "authServer": "bun run --watch src/discord/auth.ts", - "subdomain:start": "bun run src/server.tsx", - "subdomain:dev": "bun run --hot src/server.tsx", + "subdomain:start": "bun run src/server/index.tsx", + "subdomain:dev": "bun run --hot src/server/index.tsx", "test": "bun test src/gitea/test/" }, "prettier": { diff --git a/packages/spike/src/bridge/db.ts b/packages/spike/src/bridge/db.ts index 9272299..6acd23a 100644 --- a/packages/spike/src/bridge/db.ts +++ b/packages/spike/src/bridge/db.ts @@ -1,8 +1,10 @@ import { Database } from "bun:sqlite" import { getConfig } from "../config" +import { log } from "../log" const dbPath = getConfig("dbPath") export const db = new Database(dbPath) +log({ type: "startup", detail: `Database opened at ${dbPath}` }) // Create tables if they don't exist db.run(` @@ -23,8 +25,6 @@ db.run(` ); `) -console.log(`📊 Database initialized at ${dbPath}`) - // PR operations export function insertPR( giteaPrId: number, diff --git a/packages/spike/src/bridge/discord-helpers.ts b/packages/spike/src/bridge/discord-helpers.ts index 31109d1..dbc2f50 100644 --- a/packages/spike/src/bridge/discord-helpers.ts +++ b/packages/spike/src/bridge/discord-helpers.ts @@ -3,6 +3,7 @@ import { getPRByGiteaId, insertComment } from "./db" import { getConfig } from "../config" import { ChannelType, TextChannel } from "discord.js" import { convertUsername } from "../gitea" +import { log } from "../log" export async function getThread(pullRequest: { id: number; number: number }) { const row = getPRByGiteaId(pullRequest.id) @@ -55,7 +56,10 @@ export async function convertDiscordMentionsToGitea(content: string): Promise null) + const user = await client.users.fetch(userId).catch((error) => { + log({ type: "error", error, context: `fetch Discord user ${userId}` }) + return null + }) if (!user) continue const giteaUsername = convertUsername({ discordUsername: user.displayName }) diff --git a/packages/spike/src/bridge/webhook-handler.ts b/packages/spike/src/bridge/webhook-handler.ts index 82a771d..c58601b 100644 --- a/packages/spike/src/bridge/webhook-handler.ts +++ b/packages/spike/src/bridge/webhook-handler.ts @@ -3,10 +3,13 @@ import { getDiscordUser, getWebhook, getThread, getDiscordAvatarUrl } from "./di import { convertUsername, threadName, fetchPR, fetchReviewComments } from "../gitea" import type { Gitea } from "../gitea" import { getConfig } from "../config" +import { log } from "../log" type EventType = "issue_comment" | "pull_request" | "pull_request_comment" export async function handleGiteaWebhook(payload: unknown, eventType: EventType) { if (ignorePayload(payload)) { + const repo = (payload as any)?.repository?.full_name || "unknown" + log({ type: "webhook-ignored", eventType, repo }) return } else if (PRHandler.canHandle(payload, eventType)) { await PRHandler.handle(payload) @@ -36,6 +39,7 @@ async function ensureThreadExists( const exists = getPRByGiteaId(pullRequest.id) if (exists) return + log({ type: "thread-auto-created", pr: pullRequest.number, repo: repository.full_name }) const pr = await fetchPR(repository.full_name, pullRequest.number) await PRHandler.handleOpened(pr, repository) } @@ -56,7 +60,7 @@ class PRHandler { await this.handleStateChange(pullRequest, repository) } - console.log(`✅ pull request webhook action: ${action} #${payload.number}`) + log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login }) } static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { @@ -140,22 +144,23 @@ class CommentHandler { if (action === "created") { await ensureThreadExists(issue, repository) - await this.handleCreated(issue, comment) + await this.handleCreated(issue, comment, repository.full_name) } else if (action === "edited") { await ensureThreadExists(issue, repository) - await this.handleEdited(issue, comment) + await this.handleEdited(issue, comment, repository.full_name) } else if (action === "deleted") { await ensureThreadExists(issue, repository) await this.handleDeleted(comment) } - console.log(`✅ comment webhook action: ${action} on #${issue.number}`) + log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login }) } - static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment) { + static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) { const commentRow = getCommentByGiteaId(comment.id) if (commentRow) { - return // Comment already exists, skip + log({ type: "comment-skipped", pr: issue.number, repo, commentId: comment.id }) + return } const discordUsername = convertUsername({ giteaUsername: comment.user.login }) @@ -175,10 +180,10 @@ class CommentHandler { insertComment(comment.id, message.id, thread.id) } - static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment) { + static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) { const commentRow = getCommentByGiteaId(comment.id) if (!commentRow) { - this.handleCreated(issue, comment) + this.handleCreated(issue, comment, repo) return } @@ -237,7 +242,7 @@ class ReviewHandler { // Only handle new reviews, ignore edits if (action !== "reviewed") { - console.log(`✅ review webhook action: ${action} on #${pullRequest.number} (ignored)`) + log({ type: "review", action, pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login }) return } @@ -286,7 +291,7 @@ class ReviewHandler { insertComment(comment.id, message.id, thread.id) } - console.log(`✅ review webhook action: reviewed on #${pullRequest.number}`) + log({ type: "review", action: "reviewed", pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login }) } static async formatReviewComment(comment: Gitea.ReviewComment): Promise { diff --git a/packages/spike/src/discord/commands.ts b/packages/spike/src/discord/commands.ts index 7ecd832..9d3a9d1 100644 --- a/packages/spike/src/discord/commands.ts +++ b/packages/spike/src/discord/commands.ts @@ -6,6 +6,7 @@ import { type Interaction, type SlashCommandOptionsOnlyBuilder, } from "discord.js" +import { log } from "../log" export const runCommand = async (interaction: Interaction) => { if (!interaction.isChatInputCommand()) return @@ -16,10 +17,9 @@ export const runCommand = async (interaction: Interaction) => { try { await command.execute(interaction) } catch (error) { - const content = `❌ Error executing command ${interaction.commandName}: ${ - error instanceof Error ? error.message : String(error) - }` - console.error(content) + const errorMessage = error instanceof Error ? error.message : String(error) + log({ type: "discord-command-error", command: interaction.commandName, error: errorMessage }) + const content = `❌ Error executing command ${interaction.commandName}: ${errorMessage}` if (interaction.deferred) { await interaction.editReply({ content }) } else { diff --git a/packages/spike/src/discord/crash.ts b/packages/spike/src/discord/crash.ts index 15820fe..470b87d 100644 --- a/packages/spike/src/discord/crash.ts +++ b/packages/spike/src/discord/crash.ts @@ -1,16 +1,17 @@ +import { log } from "../log" + export const logCrash = async (error: unknown) => { try { const stack = error instanceof Error ? error.stack : "" const message = error instanceof Error ? error.message : String(error) const crashLog = `Spike crashed at ${new Date().toISOString()}:\n${message}\n${stack ?? ""}\n` - console.error(crashLog) // overwrite the crash log file const file = Bun.file(`${process.env.DATA_DIR}/crash.log`) file.write(crashLog) - } catch (error) { - console.error("Failed to write crash log:", error) + } catch (writeError) { + log({ type: "error", error: writeError, context: "writing crash log" }) } } @@ -19,14 +20,14 @@ export const alertAboutCrashLog = async (client: any) => { const channelId = process.env.CHANNEL_ID ?? "1382121375619223594" const crashLog = await clearCrashLog() if (crashLog) { - console.warn("⚠️ Previous crash log found:") + log({ type: "crash-log-found" }) const channel = await client.channels.fetch(channelId) if (channel?.isSendable()) { channel.send(`⚠️ Previous crash log found:\n\`\`\`${crashLog}\`\`\``) } } } catch (error) { - console.error("Failed to alert about crash log:", error) + log({ type: "error", error, context: "alerting about crash log" }) } } @@ -41,6 +42,6 @@ const clearCrashLog = async () => { return contents } } catch (error) { - console.error("Failed to read crash log:", error) + log({ type: "error", error, context: "reading crash log" }) } } diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts index 30d5bf6..793f9f6 100644 --- a/packages/spike/src/discord/events.ts +++ b/packages/spike/src/discord/events.ts @@ -2,6 +2,7 @@ import { ActivityType, type Client } from "discord.js" import { runCommand } from "./commands" import { createPRComment, convertDiscordMentionsToGitea, getPRByDiscordThreadId } from "../bridge" import { convertUsername } from "../gitea" +import { log } from "../log" export const listenForEvents = (client: Client) => { client.on("interactionCreate", async (interaction) => { @@ -19,6 +20,7 @@ export const listenForEvents = (client: Client) => { const messageContent = await convertDiscordMentionsToGitea(msg.content) const message = `**${username}**: ${messageContent}` await createPRComment(prData, message, msg.id) + log({ type: "discord-relay", pr: prData.pr_number, repo: prData.repo, user: msg.author.displayName }) } // if it is a dm always respond @@ -28,12 +30,13 @@ export const listenForEvents = (client: Client) => { // await msg.channel.send(`You said: ${msg.content}`) } } catch (error) { - console.error("Error handling messageCreate event:", error) + log({ type: "error", error, context: "messageCreate" }) msg.channel.send("An error occurred 💥.") } }) client.on("ready", () => { + log({ type: "discord-ready" }) // set the bots description const branch = process.env.RENDER_GIT_BRANCH || "unknown" const commit = process.env.RENDER_GIT_COMMIT?.slice(0, 7) || "deadbeef" @@ -45,10 +48,10 @@ export const listenForEvents = (client: Client) => { }) client.on("error", (error) => { - console.error("Discord client error:", error) + log({ type: "error", error, context: "discord client" }) }) client.on("warn", (info) => { - console.warn("Discord client warning:", info) + log({ type: "discord-warning", detail: info }) }) } diff --git a/packages/spike/src/discord/index.ts b/packages/spike/src/discord/index.ts index 37f603b..3144562 100644 --- a/packages/spike/src/discord/index.ts +++ b/packages/spike/src/discord/index.ts @@ -2,6 +2,7 @@ import { Client, GatewayIntentBits, Partials } from "discord.js" import { listenForEvents } from "./events" import { alertAboutCrashLog, logCrash } from "./crash" import { registerCommands } from "./commands" +import { log } from "../log" export const client = new Client({ intents: [ @@ -21,12 +22,12 @@ listenForEvents(client) await registerCommands(client) process.on("unhandledRejection", async (error) => { - console.error("💥 Unhandled promise rejection:", error) + log({ type: "error", error, context: "unhandled rejection" }) await logCrash(error) }) process.on("uncaughtException", async (error) => { - console.error("💥 Uncaught exception:", error) + log({ type: "error", error, context: "uncaught exception" }) await logCrash(error) }) diff --git a/packages/spike/src/gitea/api.ts b/packages/spike/src/gitea/api.ts index cc8301d..aa30477 100644 --- a/packages/spike/src/gitea/api.ts +++ b/packages/spike/src/gitea/api.ts @@ -52,10 +52,11 @@ export async function fetchReviewComments( }, }) - if (commentsResponse.ok) { - const comments = (await commentsResponse.json()) as Gitea.ReviewComment[] - allComments.push(...comments) + if (!commentsResponse.ok) { + throw new Error(`Gitea API error fetching review ${review.id} comments: ${commentsResponse.status} ${commentsResponse.statusText}`) } + const comments = (await commentsResponse.json()) as Gitea.ReviewComment[] + allComments.push(...comments) } return allComments diff --git a/packages/spike/src/log.ts b/packages/spike/src/log.ts new file mode 100644 index 0000000..5f9b28b --- /dev/null +++ b/packages/spike/src/log.ts @@ -0,0 +1,121 @@ +import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs" +import { basename } from "node:path" +import { getConfig } from "./config" + +export type LogEvent = + | { type: "webhook"; eventType: string; repo: string } + | { type: "webhook-ignored"; eventType: string; repo: string } + | { type: "pr"; action: string; pr: number; repo: string; user: string } + | { type: "comment"; action: string; pr: number; repo: string; user: string } + | { type: "comment-skipped"; pr: number; repo: string; commentId: number } + | { type: "review"; action: string; pr: number; repo: string; user: string } + | { type: "thread-auto-created"; pr: number; repo: string } + | { type: "discord-relay"; pr: number; repo: string; user: string } + | { type: "discord-command-error"; command: string; error: string } + | { type: "discord-warning"; detail: string } + | { type: "crash-log-found" } + | { type: "startup"; detail: string } + | { type: "discord-ready" } + | { type: "error"; error: unknown; context?: string } + +export type StoredLogEvent = LogEvent & { ts: string; sha: string } + +type Listener = (event: StoredLogEvent) => void +const listeners: Listener[] = [] + +const sha = process.env.RENDER_GIT_COMMIT?.slice(0, 7) || "dev" + +export function log(event: LogEvent) { + const stored = { ...event, ts: new Date().toISOString(), sha } as StoredLogEvent + for (const listener of listeners) { + listener(stored) + } +} + +export function onLog(listener: Listener) { + listeners.push(listener) + return () => { + const index = listeners.indexOf(listener) + if (index !== -1) listeners.splice(index, 1) + } +} + +// --- Log files --- + +const logsDir = `${getConfig("dataDir")}/spike/logs` +mkdirSync(logsDir, { recursive: true }) + +function createLogFile(): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + return `${logsDir}/${sha}_${timestamp}.jsonl` +} + +export function listLogFiles(): string[] { + return readdirSync(logsDir) + .filter((f) => f.endsWith(".jsonl")) + .sort() + .reverse() +} + +export function readLogFile(filename: string): StoredLogEvent[] { + if (filename !== basename(filename)) return [] + const path = `${logsDir}/${filename}` + try { + return readFileSync(path, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as StoredLogEvent) + } catch { + return [] + } +} + +// --- Built-in listeners --- + +// Console +const dim = (s: string) => `\x1b[2m${s}\x1b[0m` +const red = (s: string) => `\x1b[31m${s}\x1b[0m` +const green = (s: string) => `\x1b[32m${s}\x1b[0m` +const yellow = (s: string) => `\x1b[33m${s}\x1b[0m` +const cyan = (s: string) => `\x1b[36m${s}\x1b[0m` + +onLog((event) => { + const time = dim(event.ts.slice(11, 19)) + + if (event.type === "error") { + const context = event.context ? ` ${event.context}` : "" + console.error(`${time} ${red("error")}${context}`, event.error) + return + } + + const parts: string[] = [] + if ("action" in event) parts.push(event.action) + if ("repo" in event) parts.push(dim(event.repo)) + if ("pr" in event) parts.push(cyan(`#${event.pr}`)) + if ("user" in event) parts.push(dim(event.user)) + if ("detail" in event) parts.push(event.detail) + if ("command" in event) parts.push(event.command) + if ("eventType" in event) parts.push(event.eventType) + + const isSkipped = event.type === "webhook-ignored" || event.type === "comment-skipped" + const typeColor = isSkipped ? yellow : green + console.log(`${time} ${typeColor(event.type)} ${parts.join(" ")}`) +}) + +// JSONL file +const currentLogFile = createLogFile() + +function serializeEvent(event: StoredLogEvent): string { + if (event.type === "error") { + const error = event.error instanceof Error + ? { message: event.error.message, stack: event.error.stack } + : event.error + return JSON.stringify({ ...event, error }) + } + return JSON.stringify(event) +} + +onLog((event) => { + appendFileSync(currentLogFile, serializeEvent(event) + "\n") +}) diff --git a/packages/spike/src/server/README.md b/packages/spike/src/server/README.md new file mode 100644 index 0000000..5259799 --- /dev/null +++ b/packages/spike/src/server/README.md @@ -0,0 +1,18 @@ +# Server + +HTTP server and web pages for Spike. + +## Entry point + +`index.tsx` — Bun HTTP server with routes for webhooks and web pages. + +## Routes + +- `GET /` — Health check +- `POST /gitea/webhook` — Receives Gitea webhook payloads, dispatches to bridge +- `GET /logs` — Log viewer (HTML). Supports `?file=`, `?type=`, `?repo=` query params +- `GET /discord/auth` — Discord bot OAuth authorize page + +## Pages + +- `logs.tsx` — Server-rendered log viewer with sidebar (files grouped by sha) and filterable event table diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server/index.tsx similarity index 57% rename from packages/spike/src/server.tsx rename to packages/spike/src/server/index.tsx index 8e1d8a5..086972c 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server/index.tsx @@ -1,16 +1,8 @@ import { serve } from "bun" -import { handleGiteaWebhook } from "./bridge" -import "./discord/index" // Make suer the discord client is initialized -import { getConfig } from "./config" - -interface ErrorLog { - timestamp: string - message: string - stack?: string - payload: any -} - -const errors: ErrorLog[] = [] +import { handleGiteaWebhook } from "../bridge" +import "../discord/index" // Make sure the discord client is initialized +import { log } from "../log" +import { LogsPage } from "./logs" const server = serve({ port: parseInt(process.env.PORT || "3000"), @@ -23,31 +15,20 @@ const server = serve({ POST: async (req) => { const payload = await req.json() const eventType = req.headers.get("X-Gitea-Event") || "unknown" - console.log(`🌵 Received Gitea webhook ${eventType}`) + const repo = (payload as any)?.repository?.full_name || "unknown" + log({ type: "webhook", eventType, repo }) try { await handleGiteaWebhook(payload, eventType as any) return new Response("OK", { status: 200 }) } catch (error) { - const errorLog: ErrorLog = { - timestamp: new Date().toISOString(), - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - payload, - } - errors.push(errorLog) - console.error("💥 Webhook error 💥") - console.error(error) + log({ type: "error", error, context: `webhook ${eventType} ${repo}` }) return new Response("Error", { status: 500 }) } }, }, - "/errors": { - GET: () => { - return new Response(JSON.stringify(errors, null, 2), { - headers: { "Content-Type": "application/json" }, - }) - }, + "/logs": { + GET: (req) => LogsPage(req), }, "/discord/auth": async () => { const permissions = 536870912 // from https://discord.com/developers/applications @@ -68,4 +49,4 @@ const server = serve({ development: process.env.NODE_ENV !== "production" && { hmr: true, console: true }, }) -console.log(`Spike running at ${server.url}:${server.port}`) +log({ type: "startup", detail: `Spike running at ${server.url}:${server.port}` }) diff --git a/packages/spike/src/server/logs.tsx b/packages/spike/src/server/logs.tsx new file mode 100644 index 0000000..0a2975f --- /dev/null +++ b/packages/spike/src/server/logs.tsx @@ -0,0 +1,184 @@ +import { listLogFiles, readLogFile, type StoredLogEvent } from "../log" + +const typeColors: Record = { + webhook: "#4ade80", + "webhook-ignored": "#facc15", + pr: "#4ade80", + comment: "#4ade80", + "comment-skipped": "#facc15", + review: "#4ade80", + "thread-auto-created": "#60a5fa", + "discord-relay": "#60a5fa", + "discord-command-error": "#f87171", + "discord-warning": "#facc15", + "crash-log-found": "#f87171", + startup: "#60a5fa", + "discord-ready": "#60a5fa", + error: "#f87171", +} + +function EventRow({ event }: { event: StoredLogEvent }) { + const color = typeColors[event.type] || "#9ca3af" + const time = event.ts.slice(11, 19) + + const details: string[] = [] + if ("action" in event) details.push(event.action) + if ("repo" in event) details.push(event.repo) + if ("pr" in event) details.push(`#${event.pr}`) + if ("user" in event) details.push(event.user) + if ("detail" in event) details.push(event.detail) + if ("command" in event) details.push(event.command) + if ("eventType" in event) details.push(event.eventType) + if (event.type === "error" && event.context) details.push(event.context) + if (event.type === "error") { + const msg = event.error instanceof Error ? event.error.message : String(event.error) + details.push(msg) + } + + return ( + + {time} + + {event.type} + + {details.join(" ")} + + ) +} + +function Sidebar({ files, selectedFile }: { files: string[]; selectedFile?: string }) { + // Group files by sha + const grouped: Record = {} + for (const file of files) { + const sha = file.split("_")[0] || "unknown" + const list = grouped[sha] || (grouped[sha] = []) + list.push(file) + } + + return ( + + ) +} + +export function LogsPage(req: Request) { + const url = new URL(req.url) + const selectedFile = url.searchParams.get("file") || undefined + const typeFilter = url.searchParams.get("type") || undefined + const repoFilter = url.searchParams.get("repo") || undefined + + const files = listLogFiles() + + let events: StoredLogEvent[] = [] + if (selectedFile) { + events = readLogFile(selectedFile) + if (typeFilter) events = events.filter((e) => e.type === typeFilter) + if (repoFilter) events = events.filter((e) => "repo" in e && e.repo === repoFilter) + } + + // Collect unique types and repos for filter links + const allEvents = selectedFile ? readLogFile(selectedFile) : [] + const types = [...new Set(allEvents.map((e) => e.type))] + const repos = [...new Set(allEvents.filter((e): e is StoredLogEvent & { repo: string } => "repo" in e).map((e) => e.repo))] + + const html = ( + + + Spike Logs + + + + + +
+ {!selectedFile ? ( +
+ Select a log file from the sidebar +
+ ) : ( +
+ {/* Filters */} + {(types.length > 1 || repos.length > 0) && ( +
+ + all + + {types.map((t) => ( + + {t} + + ))} + {repos.length > 0 && ( + | + )} + {repos.map((r) => ( + + {r} + + ))} +
+ )} + + {/* Events table */} + + + {events.map((event) => ( + + ))} + +
+ + {events.length === 0 && ( +
+ No events{typeFilter ? ` of type "${typeFilter}"` : ""}{repoFilter ? ` for ${repoFilter}` : ""} +
+ )} +
+ )} +
+