Compare commits
4 Commits
abdb81572b
...
1f386df256
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f386df256 | |||
| 57fcf9616d | |||
| 96b6a4311b | |||
| 04aa7c1c91 |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:unit": "bun test src/",
|
||||
"test:integration": "bun test --preload ./tests/setup.ts tests/",
|
||||
"test": "bun test src/ && bun test --preload ./tests/setup.ts tests/"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<st
|
|||
let result = content
|
||||
for (const match of mentions) {
|
||||
const userId = match[1]!
|
||||
const user = await client.users.fetch(userId).catch(() => 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 })
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type Interaction,
|
||||
type SlashCommandOptionsOnlyBuilder,
|
||||
} from "discord.js"
|
||||
import { log } from "../log"
|
||||
|
||||
export const runCommand = async (interaction: Interaction<CacheType>) => {
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
|
|
@ -16,10 +17,9 @@ export const runCommand = async (interaction: Interaction<CacheType>) => {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
@ -22,12 +23,12 @@ export async function startDiscord() {
|
|||
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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
packages/spike/src/log.ts
Normal file
123
packages/spike/src/log.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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 }
|
||||
|
||||
type Listener = (event: StoredLogEvent) => void
|
||||
const listeners: Listener[] = []
|
||||
|
||||
const releaseSha = process.env.RENDER_GIT_COMMIT?.slice(0, 7) || "dev"
|
||||
|
||||
export function log(event: LogEvent) {
|
||||
const stored = { ...event, ts: new Date().toISOString() } 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}/${releaseSha}_${timestamp}.jsonl`
|
||||
}
|
||||
|
||||
export function listLogFiles(): string[] {
|
||||
return readdirSync(logsDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
export function readLogFile(filename: string): StoredLogEvent[] {
|
||||
// Reject path traversal attempts (e.g. "../../../etc/passwd")
|
||||
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")
|
||||
})
|
||||
18
packages/spike/src/server/README.md
Normal file
18
packages/spike/src/server/README.md
Normal file
|
|
@ -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
|
||||
|
|
@ -1,18 +1,10 @@
|
|||
import { serve } from "bun"
|
||||
import { handleGiteaWebhook } from "./bridge"
|
||||
import { startDiscord } from "./discord"
|
||||
import { handleGiteaWebhook } from "../bridge"
|
||||
import { startDiscord } from "../discord"
|
||||
import { log } from "../log"
|
||||
import { LogsPage } from "./logs"
|
||||
|
||||
await startDiscord()
|
||||
import { getConfig } from "./config"
|
||||
|
||||
interface ErrorLog {
|
||||
timestamp: string
|
||||
message: string
|
||||
stack?: string
|
||||
payload: any
|
||||
}
|
||||
|
||||
const errors: ErrorLog[] = []
|
||||
|
||||
const server = serve({
|
||||
port: parseInt(process.env.PORT || "3000"),
|
||||
|
|
@ -25,31 +17,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
|
||||
|
|
@ -70,4 +51,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}` })
|
||||
184
packages/spike/src/server/logs.tsx
Normal file
184
packages/spike/src/server/logs.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { listLogFiles, readLogFile, type StoredLogEvent } from "../log"
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
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 (
|
||||
<tr style="border-bottom: 1px solid #2a2a2a">
|
||||
<td data-ts={event.ts} style="padding: 6px 12px; color: #6b7280; white-space: nowrap; font-size: 13px">{time}</td>
|
||||
<td style={`padding: 6px 12px; color: ${color}; white-space: nowrap; font-weight: 600; font-size: 13px`}>
|
||||
{event.type}
|
||||
</td>
|
||||
<td style="padding: 6px 12px; color: #d1d5db; font-size: 13px">{details.join(" ")}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({ files, selectedFile }: { files: string[]; selectedFile?: string }) {
|
||||
// Group files by sha
|
||||
const grouped: Record<string, string[]> = {}
|
||||
for (const file of files) {
|
||||
const sha = file.split("_")[0] || "unknown"
|
||||
const list = grouped[sha] || (grouped[sha] = [])
|
||||
list.push(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav style="width: 280px; min-width: 280px; background: #141414; border-right: 1px solid #2a2a2a; overflow-y: auto; padding: 16px 0">
|
||||
<div style="padding: 0 16px 12px; color: #9ca3af; font-size: 11px; text-transform: uppercase; letter-spacing: 1px">
|
||||
Log Files
|
||||
</div>
|
||||
{Object.entries(grouped).map(([sha, shaFiles]) => (
|
||||
<div style="margin-bottom: 8px">
|
||||
<div style="padding: 4px 16px; color: #6b7280; font-size: 11px; font-family: monospace">{sha}</div>
|
||||
{shaFiles.map((file) => {
|
||||
const isSelected = file === selectedFile
|
||||
// Parse ISO timestamp back from filename: sha_2026-03-10T02-56-59-938Z.jsonl
|
||||
const isoStr = file.replace(/^[^_]+_/, "").replace(".jsonl", "").replace(/T(\d{2})-(\d{2})-(\d{2})-(\d+)Z/, "T$1:$2:$3.$4Z")
|
||||
const bg = isSelected ? "#2a2a2a" : "transparent"
|
||||
return (
|
||||
<a
|
||||
href={`/logs?file=${file}`}
|
||||
data-ts={isoStr}
|
||||
style={`display: block; padding: 6px 16px 6px 24px; color: ${isSelected ? "#f3f4f6" : "#9ca3af"}; text-decoration: none; font-size: 12px; background: ${bg}; font-family: monospace`}
|
||||
>
|
||||
{isoStr}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
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 = (
|
||||
<html>
|
||||
<head>
|
||||
<title>Spike Logs</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body style="margin: 0; background: #0a0a0a; color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; height: 100vh">
|
||||
<Sidebar files={files} selectedFile={selectedFile} />
|
||||
|
||||
<main style="flex: 1; overflow-y: auto; padding: 24px">
|
||||
{!selectedFile ? (
|
||||
<div style="color: #6b7280; padding: 40px; text-align: center; font-size: 14px">
|
||||
Select a log file from the sidebar
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
{(types.length > 1 || repos.length > 0) && (
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center">
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; ${!typeFilter && !repoFilter ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
all
|
||||
</a>
|
||||
{types.map((t) => (
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}&type=${t}${repoFilter ? `&repo=${repoFilter}` : ""}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; ${typeFilter === t ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
{t}
|
||||
</a>
|
||||
))}
|
||||
{repos.length > 0 && (
|
||||
<span style="color: #3a3a3a; margin: 0 4px">|</span>
|
||||
)}
|
||||
{repos.map((r) => (
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}&repo=${r}${typeFilter ? `&type=${typeFilter}` : ""}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; font-family: monospace; ${repoFilter === r ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
{r}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events table */}
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<tbody>
|
||||
{events.map((event) => (
|
||||
<EventRow event={event} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{events.length === 0 && (
|
||||
<div style="color: #6b7280; padding: 40px; text-align: center; font-size: 14px">
|
||||
No events{typeFilter ? ` of type "${typeFilter}"` : ""}{repoFilter ? ` for ${repoFilter}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<script dangerouslySetInnerHTML={{__html: `
|
||||
document.querySelectorAll('[data-ts]').forEach(function(el) {
|
||||
var d = new Date(el.dataset.ts);
|
||||
if (isNaN(d)) return;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : n; };
|
||||
el.textContent = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
});
|
||||
`}} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
return new Response(html.toString(), { headers: { "Content-Type": "text/html" } })
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user