diff --git a/packages/spike/src/bridge/webhook-handler.ts b/packages/spike/src/bridge/webhook-handler.ts index c58601b..98291b5 100644 --- a/packages/spike/src/bridge/webhook-handler.ts +++ b/packages/spike/src/bridge/webhook-handler.ts @@ -60,7 +60,7 @@ class PRHandler { await this.handleStateChange(pullRequest, repository) } - log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login }) + log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login, title: pullRequest.title }) } static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) { @@ -116,8 +116,8 @@ class PRHandler { let message = ` > ### [${pullRequest.title}](<${pullRequest.html_url}>) > **${repositoryFullName}** - > - ${body +> +${body .split("\n") .map((line) => `> ${line}`) .join("\n")} @@ -153,7 +153,7 @@ class CommentHandler { await this.handleDeleted(comment) } - log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login }) + log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login, body: comment.body.slice(0, 200) }) } static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) { diff --git a/packages/spike/src/discord/crash.ts b/packages/spike/src/discord/crash.ts index 470b87d..d7bda1a 100644 --- a/packages/spike/src/discord/crash.ts +++ b/packages/spike/src/discord/crash.ts @@ -1,5 +1,12 @@ +import { mkdirSync } from "node:fs" +import { getConfig } from "../config" import { log } from "../log" +const crashLogDir = getConfig("dataDir") +mkdirSync(crashLogDir, { recursive: true }) + +const crashLogPath = `${crashLogDir}/crash.log` + export const logCrash = async (error: unknown) => { try { const stack = error instanceof Error ? error.stack : "" @@ -8,7 +15,7 @@ export const logCrash = async (error: unknown) => { const crashLog = `Spike crashed at ${new Date().toISOString()}:\n${message}\n${stack ?? ""}\n` // overwrite the crash log file - const file = Bun.file(`${process.env.DATA_DIR}/crash.log`) + const file = Bun.file(crashLogPath) file.write(crashLog) } catch (writeError) { log({ type: "error", error: writeError, context: "writing crash log" }) @@ -33,7 +40,7 @@ export const alertAboutCrashLog = async (client: any) => { const clearCrashLog = async () => { try { - const file = Bun.file(`${process.env.DATA_DIR}/crash.log`) + const file = Bun.file(crashLogPath) if (!(await file.exists())) return const contents = await file.text() await file.write("") diff --git a/packages/spike/src/log.ts b/packages/spike/src/log.ts index 8310a8c..a0ed52a 100644 --- a/packages/spike/src/log.ts +++ b/packages/spike/src/log.ts @@ -3,10 +3,10 @@ import { basename } from "node:path" import { getConfig } from "./config" export type LogEvent = - | { type: "webhook"; eventType: string; repo: string } + | { type: "webhook"; eventType: string; repo: string; sender?: string; ref?: 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: "pr"; action: string; pr: number; repo: string; user: string; title?: string } + | { type: "comment"; action: string; pr: number; repo: string; user: string; body?: 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 } @@ -59,17 +59,22 @@ export function listLogFiles(): string[] { } export function readLogFile(filename: string): StoredLogEvent[] { - // Reject path traversal attempts (e.g. "../../../etc/passwd") - if (filename !== basename(filename)) return [] + const raw = readLogFileRaw(filename) + if (!raw) return [] + return raw + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as StoredLogEvent) +} + +export function readLogFileRaw(filename: string): string | undefined { + if (filename !== basename(filename)) throw new Error("Invalid filename") const path = `${logsDir}/${filename}` try { return readFileSync(path, "utf-8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as StoredLogEvent) } catch { - return [] + return } } @@ -96,6 +101,9 @@ onLog((event) => { 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 ("sender" in event && event.sender) parts.push(dim(event.sender)) + if ("ref" in event && event.ref) parts.push(dim(event.ref)) + if ("title" in event && event.title) parts.push(event.title) if ("detail" in event) parts.push(event.detail) if ("command" in event) parts.push(event.command) if ("eventType" in event) parts.push(event.eventType) diff --git a/packages/spike/src/server/index.tsx b/packages/spike/src/server/index.tsx index 7ad741e..7f1a7dd 100644 --- a/packages/spike/src/server/index.tsx +++ b/packages/spike/src/server/index.tsx @@ -1,7 +1,7 @@ import { serve } from "bun" import { handleGiteaWebhook } from "../bridge" import { startDiscord } from "../discord" -import { log } from "../log" +import { listLogFiles, log, readLogFileRaw } from "../log" import { LogsPage } from "./logs" await startDiscord() @@ -18,7 +18,9 @@ const server = serve({ const payload = await req.json() const eventType = req.headers.get("X-Gitea-Event") || "unknown" const repo = (payload as any)?.repository?.full_name || "unknown" - log({ type: "webhook", eventType, repo }) + const sender = (payload as any)?.sender?.login as string | undefined + const ref = (payload as any)?.ref as string | undefined + log({ type: "webhook", eventType, repo, sender, ref }) try { await handleGiteaWebhook(payload, eventType as any) @@ -30,7 +32,24 @@ const server = serve({ }, }, "/logs": { - GET: (req) => LogsPage(req), + GET: (req) => { + const accept = req.headers.get("Accept") || "" + const wantsRaw = !accept.includes("text/html") + if (!wantsRaw) return LogsPage(req) + + const file = new URL(req.url).searchParams.get("file") + if (!file) { + const files = listLogFiles() + return Response.json(files) + } + try { + const raw = readLogFileRaw(file) + if (!raw) return new Response("Not found", { status: 404 }) + return new Response(raw, { headers: { "Content-Type": "application/jsonl" } }) + } catch { + return new Response("Internal server error", { status: 500 }) + } + }, }, "/discord/auth": async () => { const permissions = 536870912 // from https://discord.com/developers/applications diff --git a/packages/spike/src/server/logs.tsx b/packages/spike/src/server/logs.tsx index 1b9cbb6..25ff502 100644 --- a/packages/spike/src/server/logs.tsx +++ b/packages/spike/src/server/logs.tsx @@ -17,7 +17,19 @@ const typeColors: Record = { error: "#f87171", } -function EventRow({ event }: { event: StoredLogEvent }) { +const summaryFields = ["type", "ts"] as const + +function getEventMeta(event: StoredLogEvent): Record { + const meta: Record = {} + for (const [key, value] of Object.entries(event)) { + if (summaryFields.includes(key as any)) continue + if (value === undefined) continue + meta[key] = typeof value === "string" ? value : String(value) + } + return meta +} + +function EventRow({ event, index }: { event: StoredLogEvent; index: number }) { const color = typeColors[event.type] || "#9ca3af" const time = event.ts.slice(11, 19) @@ -35,14 +47,41 @@ function EventRow({ event }: { event: StoredLogEvent }) { details.push(msg) } + const meta = getEventMeta(event) + const hasExtra = Object.keys(meta).length > 0 + return ( - - {time} - - {event.type} - - {details.join(" ")} - + <> + + + {hasExtra && +} + + {time} + + {event.type} + + {details.join(" ")} + + {hasExtra && ( + + + +
+ {Object.entries(meta).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ + + )} + ) } @@ -157,8 +196,8 @@ export function LogsPage(req: Request) { {/* Events table */} - {events.map((event) => ( - + {events.map((event, i) => ( + ))}