Compare commits
No commits in common. "main" and "probablycorey/fix-crash-log-path" have entirely different histories.
main
...
probablyco
|
|
@ -10,15 +10,7 @@ Discord-Gitea bridge bot. When someone opens a PR or leaves a comment on [git.no
|
|||
4. `bun run subdomain:dev` to start the server.
|
||||
5. Visit `localhost:3000/discord/auth` to authorize the bot to your Discord server.
|
||||
6. Use [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) or ngrok to expose your local server to the internet.
|
||||
7. Add a Gitea webhook (see below).
|
||||
|
||||
### Gitea webhook
|
||||
|
||||
Spike needs a webhook so Gitea sends PR and comment events to it. You have two options:
|
||||
|
||||
**System webhook (covers all repos)** — Requires Gitea admin access. Go to **Site Administration > System Webhooks > Add Webhook > Gitea**. Set the target URL to `https://spike.theworkshop.cc/gitea/webhook`. Under "Trigger On", select **Custom Events** and enable Pull Request, Issue Comment, and Pull Request Comment. This fires for every repo on the instance.
|
||||
|
||||
**Org/repo webhook (covers one org or repo)** — Go to the org or repo settings, then **Webhooks > Add Webhook > Gitea**. Same target URL and event configuration as above. Only fires for that org or repo.
|
||||
7. Add a Gitea webhook at `https://git.nose.space/<org>/settings/hooks` pointing to `https://<your-tunnel>/gitea/webhook`.
|
||||
|
||||
### Environment variables
|
||||
|
||||
|
|
|
|||
|
|
@ -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, title: pullRequest.title })
|
||||
log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login })
|
||||
}
|
||||
|
||||
static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
|
||||
|
|
@ -112,16 +112,12 @@ class PRHandler {
|
|||
}
|
||||
|
||||
static formatPrBody(pullRequest: Gitea.PullRequest, repositoryFullName: string): string {
|
||||
// Strip the "🤖 Generated with Claude Code" footer that gets appended to PR bodies
|
||||
const claudeFooter = /\n*🤖.*Claude Code[^\n]*$/
|
||||
const body = (pullRequest.body || "_empty_")
|
||||
.replace(claudeFooter, "")
|
||||
.trim() || "_empty_"
|
||||
const body = pullRequest.body || "_empty_"
|
||||
let message = `
|
||||
> ### [${pullRequest.title}](<${pullRequest.html_url}>)
|
||||
> **${repositoryFullName}**
|
||||
>
|
||||
${body
|
||||
>
|
||||
${body
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}
|
||||
|
|
@ -157,7 +153,7 @@ class CommentHandler {
|
|||
await this.handleDeleted(comment)
|
||||
}
|
||||
|
||||
log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login, body: comment.body.slice(0, 200) })
|
||||
log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login })
|
||||
}
|
||||
|
||||
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,18 @@
|
|||
import { log } from "../log"
|
||||
import type { Gitea } from "./types"
|
||||
|
||||
const giteaUrl = "https://git.nose.space"
|
||||
|
||||
function tokenPrefix(): string {
|
||||
const token = process.env.GITEA_API_TOKEN
|
||||
if (!token) return "MISSING"
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)} (${token.length} chars)`
|
||||
}
|
||||
|
||||
async function giteaFetch(url: string): Promise<Response> {
|
||||
export async function fetchPR(fullname: string, prNumber: number): Promise<Gitea.PullRequest> {
|
||||
const url = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
log({ type: "gitea-api-error", status: response.status, url, body: body.slice(0, 500), tokenPrefix: tokenPrefix() })
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function fetchPR(fullname: string, prNumber: number): Promise<Gitea.PullRequest> {
|
||||
const url = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}`
|
||||
const response = await giteaFetch(url)
|
||||
return response.json() as Promise<Gitea.PullRequest>
|
||||
}
|
||||
|
||||
|
|
@ -35,8 +20,16 @@ export async function fetchReviewComments(
|
|||
fullname: string,
|
||||
prNumber: number
|
||||
): Promise<Gitea.ReviewComment[]> {
|
||||
// First, fetch all reviews
|
||||
const reviewsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews`
|
||||
const reviewsResponse = await giteaFetch(reviewsUrl)
|
||||
const reviewsResponse = await fetch(reviewsUrl, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
if (!reviewsResponse.ok) {
|
||||
throw new Error(`Gitea API error: ${reviewsResponse.status} ${reviewsResponse.statusText}`)
|
||||
}
|
||||
|
||||
const reviews = (await reviewsResponse.json()) as Array<{
|
||||
id: number
|
||||
|
|
@ -47,12 +40,21 @@ export async function fetchReviewComments(
|
|||
comments_count: number
|
||||
}>
|
||||
|
||||
// For each review, fetch its comments
|
||||
const allComments: Gitea.ReviewComment[] = []
|
||||
for (const review of reviews) {
|
||||
if (review.comments_count === 0) continue
|
||||
|
||||
const commentsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews/${review.id}/comments`
|
||||
const commentsResponse = await giteaFetch(commentsUrl)
|
||||
const commentsResponse = await fetch(commentsUrl, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { basename } from "node:path"
|
|||
import { getConfig } from "./config"
|
||||
|
||||
export type LogEvent =
|
||||
| { type: "webhook"; eventType: string; repo: string; sender?: string; ref?: string }
|
||||
| { type: "webhook"; eventType: string; repo: string }
|
||||
| { type: "webhook-ignored"; eventType: string; repo: 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: "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 }
|
||||
|
|
@ -16,7 +16,6 @@ export type LogEvent =
|
|||
| { type: "crash-log-found" }
|
||||
| { type: "startup"; detail: string }
|
||||
| { type: "discord-ready" }
|
||||
| { type: "gitea-api-error"; status: number; url: string; body: string; tokenPrefix: string }
|
||||
| { type: "error"; error: unknown; context?: string }
|
||||
|
||||
export type StoredLogEvent = LogEvent & { ts: string }
|
||||
|
|
@ -48,40 +47,29 @@ mkdirSync(logsDir, { recursive: true })
|
|||
|
||||
function createLogFile(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
return `${logsDir}/${timestamp}_${releaseSha}.jsonl`
|
||||
}
|
||||
|
||||
function extractTimestamp(filename: string): string {
|
||||
const stem = filename.replace(".jsonl", "")
|
||||
// New format: timestamp_sha, Old format: sha_timestamp
|
||||
if (/^\d{4}-/.test(stem)) return stem.split("_").slice(0, -1).join("_")
|
||||
return stem.split("_").pop() || ""
|
||||
return `${logsDir}/${releaseSha}_${timestamp}.jsonl`
|
||||
}
|
||||
|
||||
export function listLogFiles(): string[] {
|
||||
return readdirSync(logsDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort((a, b) => extractTimestamp(b).localeCompare(extractTimestamp(a)))
|
||||
.sort()
|
||||
.reverse()
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
export function readLogFile(filename: string): StoredLogEvent[] {
|
||||
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")
|
||||
// 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
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,9 +96,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { serve } from "bun"
|
||||
import { handleGiteaWebhook } from "../bridge"
|
||||
import { startDiscord } from "../discord"
|
||||
import { listLogFiles, log, readLogFileRaw } from "../log"
|
||||
import { log } from "../log"
|
||||
import { LogsPage } from "./logs"
|
||||
|
||||
await startDiscord()
|
||||
|
|
@ -18,9 +18,7 @@ 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"
|
||||
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 })
|
||||
log({ type: "webhook", eventType, repo })
|
||||
|
||||
try {
|
||||
await handleGiteaWebhook(payload, eventType as any)
|
||||
|
|
@ -32,24 +30,7 @@ const server = serve({
|
|||
},
|
||||
},
|
||||
"/logs": {
|
||||
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 })
|
||||
}
|
||||
},
|
||||
GET: (req) => LogsPage(req),
|
||||
},
|
||||
"/discord/auth": async () => {
|
||||
const permissions = 536870912 // from https://discord.com/developers/applications
|
||||
|
|
|
|||
|
|
@ -17,36 +17,7 @@ const typeColors: Record<string, string> = {
|
|||
error: "#f87171",
|
||||
}
|
||||
|
||||
const summaryFields = ["type", "ts"] as const
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error && "message" in error) return String((error as any).message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function errorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack
|
||||
if (typeof error === "object" && error && "stack" in error) return String((error as any).stack)
|
||||
}
|
||||
|
||||
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
|
||||
if (key === "error") {
|
||||
meta[key] = errorMessage(value)
|
||||
const stack = errorStack(value)
|
||||
if (stack) meta.stack = stack
|
||||
continue
|
||||
}
|
||||
meta[key] = typeof value === "string" ? value : String(value)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
function EventRow({ event, index }: { event: StoredLogEvent; index: number }) {
|
||||
function EventRow({ event }: { event: StoredLogEvent }) {
|
||||
const color = typeColors[event.type] || "#9ca3af"
|
||||
const time = event.ts.slice(11, 19)
|
||||
|
||||
|
|
@ -60,98 +31,42 @@ function EventRow({ event, index }: { event: StoredLogEvent; index: number }) {
|
|||
if ("eventType" in event) details.push(event.eventType)
|
||||
if (event.type === "error" && event.context) details.push(event.context)
|
||||
if (event.type === "error") {
|
||||
details.push(errorMessage(event.error))
|
||||
const msg = event.error instanceof Error ? event.error.message : String(event.error)
|
||||
details.push(msg)
|
||||
}
|
||||
|
||||
const meta = getEventMeta(event)
|
||||
const hasExtra = Object.keys(meta).length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<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]) => (
|
||||
key === "stack" ? (
|
||||
<div style="width: 100%">
|
||||
<span style="color: #6b7280">{key}</span>
|
||||
<pre style="color: #d1d5db; margin: 4px 0 0; font-size: 11px; white-space: pre-wrap; opacity: 0.8">{value}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
<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 parseLogFilename(file: string): { sha: string; isoStr: string } {
|
||||
const stem = file.replace(".jsonl", "")
|
||||
// New format: timestamp_sha (e.g. 2026-03-10T18-06-23-561Z_cb10121)
|
||||
// Old format: sha_timestamp (e.g. cb10121_2026-03-10T18-06-23-561Z)
|
||||
const isNewFormat = /^\d{4}-/.test(stem)
|
||||
const parts = stem.split("_")
|
||||
|
||||
let sha: string
|
||||
let rawTimestamp: string
|
||||
if (isNewFormat) {
|
||||
sha = parts.pop() || "unknown"
|
||||
rawTimestamp = parts.join("_")
|
||||
} else {
|
||||
rawTimestamp = parts.pop() || ""
|
||||
sha = parts.join("_") || "unknown"
|
||||
}
|
||||
|
||||
const isoStr = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d+)Z/, "T$1:$2:$3.$4Z")
|
||||
return { sha, isoStr }
|
||||
}
|
||||
|
||||
function Sidebar({ files, selectedFile }: { files: string[]; selectedFile?: string }) {
|
||||
// Group files by sha, sorted by most recent file in each group
|
||||
// Group files by sha
|
||||
const grouped: Record<string, string[]> = {}
|
||||
for (const file of files) {
|
||||
const { sha } = parseLogFilename(file)
|
||||
const sha = file.split("_")[0] || "unknown"
|
||||
const list = grouped[sha] || (grouped[sha] = [])
|
||||
list.push(file)
|
||||
}
|
||||
const sortedGroups = Object.entries(grouped).sort(([, a], [, b]) => {
|
||||
return b[0]!.localeCompare(a[0]!)
|
||||
})
|
||||
|
||||
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>
|
||||
{sortedGroups.map(([sha, shaFiles]) => (
|
||||
{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
|
||||
const { isoStr } = parseLogFilename(file)
|
||||
// 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
|
||||
|
|
@ -239,8 +154,8 @@ export function LogsPage(req: Request) {
|
|||
{/* Events table */}
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<tbody>
|
||||
{events.map((event, i) => (
|
||||
<EventRow event={event} index={i} />
|
||||
{events.map((event) => (
|
||||
<EventRow event={event} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user