Compare commits

..

10 Commits

Author SHA1 Message Date
c3000e954b Merge branch 'main' into probablycorey/pr-to-discord-flow
Some checks failed
CI / test (pull_request) Has been cancelled
2026-03-10 18:02:44 +00:00
cb10121fb5 Merge pull request 'Add content negotiation for raw JSONL logs' (#16) from probablycorey/raw-jsonl-endpoint into main
Some checks are pending
CI / test (push) Waiting to run
Reviewed-on: #16
2026-03-10 18:02:08 +00:00
f750663eca Add content negotiation for raw JSONL logs endpoint
Some checks failed
CI / test (pull_request) Has been cancelled
The /logs endpoint now returns different formats based on Accept headers:
- Browsers (Accept: text/html) get the interactive HTML log viewer
- Agents/fetch requests get raw JSONL when no Accept header includes text/html
- Without a file parameter, agents get a JSON array of available log files

Also improved security: invalid filenames now throw errors (500) instead of silently failing (404).
2026-03-10 10:58:58 -07:00
b5eb553d74 Merge pull request 'Add richer metadata to logs and expandable detail panel' (#14) from probablycorey/richer-log-metadata into main
Some checks are pending
CI / test (push) Waiting to run
Reviewed-on: #14
2026-03-10 17:34:00 +00:00
926b9b17c9 Merge branch 'main' into probablycorey/richer-log-metadata
Some checks failed
CI / test (pull_request) Has been cancelled
2026-03-10 17:33:30 +00:00
6555be012a Add richer metadata to logs and expandable detail panel
Some checks failed
CI / test (pull_request) Has been cancelled
Enriches webhook logs with sender and ref for better visibility into who triggered events and what they affected (e.g. which branch). PR logs now include the title, comment logs include the first 200 characters of the comment body.

Adds an expandable detail row in the log viewer UI that shows all metadata as key-value pairs when clicked, keeping the summary line clean.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-10 10:25:47 -07:00
4fba215f21 Merge pull request 'Fix Discord message rendering' (#13) from probablycorey/fix-discord-message into main
Some checks are pending
CI / test (push) Waiting to run
Reviewed-on: #13
2026-03-10 17:09:28 +00:00
d12ee5c52d Fix Discord message rendering: remove stray blockquote character
Some checks failed
CI / test (pull_request) Has been cancelled
The empty blockquote line after the repo name was rendering as a literal ">" in Discord. Add a trailing space so it renders as a blank line within the blockquote instead.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-10 10:02:17 -07:00
726c76cf96 Merge pull request 'Fix crash log file not found error in spike' (#12) from probablycorey/fix-crash-log-path into main
Some checks are pending
CI / test (push) Waiting to run
Reviewed-on: #12
2026-03-10 16:54:32 +00:00
c9fc834f4e Fix crash log file not found error in spike
Some checks failed
CI / test (pull_request) Has been cancelled
Use getConfig("dataDir") and ensure directory exists before writing crash log, fixing ENOENT error when spike crashes.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-10 09:53:24 -07:00
5 changed files with 102 additions and 29 deletions

View File

@ -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) {

View File

@ -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("")

View File

@ -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)

View File

@ -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

View File

@ -17,7 +17,19 @@ const typeColors: Record<string, string> = {
error: "#f87171",
}
function EventRow({ event }: { event: StoredLogEvent }) {
const summaryFields = ["type", "ts"] as const
function getEventMeta(event: StoredLogEvent): Record<string, string> {
const meta: Record<string, string> = {}
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 (
<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>
<>
<tr
style={`border-bottom: 1px solid #2a2a2a${hasExtra ? "; cursor: pointer" : ""}`}
data-row={index}
onclick={hasExtra ? `(function(e){var d=document.getElementById('detail-${index}');if(d){d.style.display=d.style.display==='none'?'table-row':'none';e.currentTarget.querySelector('.expand-icon').textContent=d.style.display==='none'?'+':'-'}})(event)` : undefined}
>
<td style="padding: 6px 4px 6px 12px; color: #4a4a4a; font-size: 11px; width: 16px; user-select: none">
{hasExtra && <span class="expand-icon">+</span>}
</td>
<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>
{hasExtra && (
<tr id={`detail-${index}`} style="display: none; background: #141414">
<td></td>
<td colspan="3" style="padding: 8px 12px 12px">
<div style="display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 12px">
{Object.entries(meta).map(([key, value]) => (
<div style="display: flex; gap: 6px">
<span style="color: #6b7280">{key}</span>
<span style="color: #d1d5db; max-width: 500px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{value}</span>
</div>
))}
</div>
</td>
</tr>
)}
</>
)
}
@ -157,8 +196,8 @@ export function LogsPage(req: Request) {
{/* Events table */}
<table style="width: 100%; border-collapse: collapse">
<tbody>
{events.map((event) => (
<EventRow event={event} />
{events.map((event, i) => (
<EventRow event={event} index={i} />
))}
</tbody>
</table>