Add typed event logging and restructure server #11

Merged
probablycorey merged 3 commits from probablycorey/simple-logging into main 2026-03-10 16:28:41 +00:00
10 changed files with 127 additions and 71 deletions
Showing only changes of commit 57fcf9616d - Show all commits

View File

@ -9,7 +9,9 @@
"authServer": "bun run --watch src/discord/auth.ts",
"subdomain:start": "bun run src/server/index.tsx",
"subdomain:dev": "bun run --hot src/server/index.tsx",
"test": "bun test src/gitea/test/"
"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/"
},
"prettier": {
"printWidth": 110,

View File

@ -8,11 +8,12 @@ type Config = {
const devConfig: Config = {
dbPath: ".//local-spike.db",
channelId: "1384275245174620370",
channelId: "1480720354325561505",
discordClientId: "1384271480119885977",
dataDir: "/Users/corey/code/tmp/data",
giteaToDiscordUserMappings: {
probablycorey: "corey",
Spike: "Spike",
},
}
@ -24,6 +25,7 @@ const prodConfig: Config = {
giteaToDiscordUserMappings: {
probablycorey: "corey",
defunkt: "defunkt",
Spike: "Spike",
},
}

View File

@ -7,14 +7,9 @@ Discord bot client for the Gitea-Discord bridge. Initializes the bot, registers
```ts
import { Client } from "discord.js"
/**
* The logged-in Discord bot client.
*
* Importing this module has side effects:
* 1. Creates and logs in the Discord client
* 2. Registers event listeners (message relay to Gitea, slash commands, status display)
* 3. Sets up crash logging (unhandled rejections/exceptions → crash.log)
* 4. Alerts the channel about any previous crash log
*/
// The Discord bot client instance. Not logged in until startDiscord() is called.
const client: Client
// Logs in the bot, registers event listeners and slash commands, sets up crash logging.
async function startDiscord(): Promise<void>
```

View File

@ -16,20 +16,22 @@ export const client = new Client({
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
})
await client.login(process.env.DISCORD_TOKEN)
export async function startDiscord() {
await client.login(process.env.DISCORD_TOKEN)
listenForEvents(client)
await registerCommands(client)
listenForEvents(client)
await registerCommands(client)
process.on("unhandledRejection", async (error) => {
log({ type: "error", error, context: "unhandled rejection" })
await logCrash(error)
})
process.on("unhandledRejection", async (error) => {
log({ type: "error", error, context: "unhandled rejection" })
await logCrash(error)
})
process.on("uncaughtException", async (error) => {
log({ type: "error", error, context: "uncaught exception" })
await logCrash(error)
})
process.on("uncaughtException", async (error) => {
log({ type: "error", error, context: "uncaught exception" })
await logCrash(error)
})
await alertAboutCrashLog(client)
// startAuthServer() this is handy if you make a new bot
await alertAboutCrashLog(client)
// startAuthServer() this is handy if you make a new bot
}

View File

@ -1,6 +1,6 @@
import { describe, test, expect } from "bun:test"
import { convertUsername, threadName } from "../utils"
import type { Gitea } from "../types"
import { convertUsername, threadName } from "./utils"
import type { Gitea } from "./types"
describe("convertUsername", () => {
test("converts gitea username to discord username", () => {

View File

@ -1,9 +1,11 @@
import { serve } from "bun"
import { handleGiteaWebhook } from "../bridge"
import "../discord/index" // Make sure the discord client is initialized
import { startDiscord } from "../discord"
import { log } from "../log"
import { LogsPage } from "./logs"
await startDiscord()
const server = serve({
port: parseInt(process.env.PORT || "3000"),
routes: {

View File

@ -0,0 +1,42 @@
import { describe, test, expect, beforeAll, afterEach, setDefaultTimeout } from "bun:test"
import { spike, spikeRepo } from "./setup"
import { type TestPR, expectPullRequestWebhook, openTestPR, cleanupTestPR } from "./helpers"
import { handleGiteaWebhook } from "../src/bridge"
import { client, startDiscord } from "../src/discord"
setDefaultTimeout(30_000)
beforeAll(async () => {
await startDiscord()
})
const prsToCleanup: TestPR[] = []
afterEach(async () => {
while (prsToCleanup.length) {
await cleanupTestPR(prsToCleanup.pop()!)
}
})
describe("bridge: Spike-owned repo", () => {
test("Spike creating a PR on their own repo creates a Discord thread", async () => {
const branch = `test-${crypto.randomUUID().slice(0, 8)}-bridge`
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
const pr = await openTestPR(spikeRepo, spike, branch)
prsToCleanup.push(pr)
const payload = await webhook
// Process the webhook through the bridge (Gitea → Discord)
await handleGiteaWebhook(payload, "pull_request")
// Verify a Discord thread was created
const channelId = (await import("../src/config")).getConfig("channelId")
const channel = await client.channels.fetch(channelId)
if (!channel || !("threads" in channel)) throw new Error("Could not fetch channel")
const threads = await channel.threads.fetchActive()
const thread = threads.threads.find((t) => t.name.includes(branch))
expect(thread).toBeDefined()
})
})
})

View File

@ -1,5 +1,4 @@
import { serve } from "bun"
import { afterAll } from "bun:test"
import { ensure } from "@workshop/shared/utils"
import { mkdtemp, rm } from "fs/promises"
import { join } from "path"
@ -18,7 +17,6 @@ type Expectation = {
timer: ReturnType<typeof setTimeout>
}
const captured: any[] = []
const expectations: Expectation[] = []
function matchIncoming(eventType: string, payload: any) {
@ -37,7 +35,6 @@ const captureServer = serve({
POST: async (req) => {
const payload = await req.json()
const eventType = req.headers.get("X-Gitea-Event") || "unknown"
captured.push(payload)
matchIncoming(eventType, payload)
return new Response("OK")
},
@ -45,14 +42,7 @@ const captureServer = serve({
},
})
// Registers an expectation before calling the callback, so no webhook can
// slip through the crack. The callback receives a promise that resolves
// to the webhook payload once it arrives.
function awaitWebhook(
eventType: string,
match: (payload: any) => boolean,
timeoutMs = 15000,
): Promise<any> {
function awaitWebhook(eventType: string, match: (payload: any) => boolean, timeoutMs = 15000): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const idx = expectations.indexOf(expectation)
@ -103,18 +93,28 @@ export async function setupWebhooks(corey: User, spike: User, coreyRepo: string,
ensure(tsResult.success, `Failed to get tailscale status: ${tsResult.stderr.toString()}`)
const tailscaleUrl = `https://${JSON.parse(tsResult.stdout.toString()).Self.DNSName.replace(/\.$/, "")}`
// Clear any stale funnel/serve config from previous test runs
Bun.spawnSync(["pkill", "-f", "tailscale funnel"])
Bun.spawnSync(["tailscale", "serve", "reset"])
await Bun.sleep(500)
const funnel = Bun.spawn(["tailscale", "funnel", String(captureServer.port)], { stdout: "ignore", stderr: "ignore" })
const funnelUrl = `${tailscaleUrl}/gitea/webhook`
// Wait for funnel to be reachable
let funnelReachable = false
const start = Date.now()
while (Date.now() - start < 10000) {
try {
const res = await fetch(funnelUrl, { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } })
if (res.ok) break
if (res.ok) {
funnelReachable = true
break
}
} catch {}
await Bun.sleep(500)
}
ensure(funnelReachable, `Tailscale funnel not reachable at ${funnelUrl} after 10s`)
await ensureRepo(spikeRepo, spike)
await addCollaborator(spikeRepo, spike, corey.username)
@ -124,7 +124,6 @@ export async function setupWebhooks(corey: User, spike: User, coreyRepo: string,
// Flush stale webhook deliveries that Gitea retried when our funnel came
// back up. Even with branch-scoped matching these can saturate the queue.
await Bun.sleep(3000)
captured.length = 0
expectations.length = 0
return function teardown() {
@ -147,15 +146,14 @@ export type TestPR = {
export async function openTestPR(repo: string, author: User, branch = `test-${Date.now()}`): Promise<TestPR> {
const { dir, filename } = await pushBranch(repo, branch, author)
const pr = await createPR(repo, `Test PR (${branch})`, branch, author)
afterAll(async () => {
try { git(["push", "origin", "--delete", branch], dir) } catch {}
await rm(dir, { recursive: true, force: true })
})
return { repo, number: pr.number, branch, filename, dir, author }
}
export async function cleanupTestPR(pr: TestPR) {
try { git(["push", "origin", "--delete", pr.branch], pr.dir) } catch {}
await rm(pr.dir, { recursive: true, force: true })
}
// --- Gitea API ---
async function giteaFetch(path: string, user: User, options: RequestInit = {}) {

View File

@ -0,0 +1,17 @@
import { ensure } from "@workshop/shared/utils"
import { setupWebhooks, type User } from "./helpers"
function getEnv(name: string): string {
const value = process.env[name]
ensure(value, `Missing env var: ${name}. See .env.example`)
return value
}
export const corey: User = { token: getEnv("TEST_GITEA_API_TOKEN_COREY"), username: "probablycorey" }
export const spike: User = { token: getEnv("TEST_GITEA_API_TOKEN_SPIKE"), username: "Spike" }
export const coreyRepo = getEnv("TEST_REPO_COREY")
export const spikeRepo = getEnv("TEST_REPO_SPIKE")
const teardown = await setupWebhooks(corey, spike, coreyRepo, spikeRepo)
process.on("beforeExit", () => teardown())

View File

@ -1,49 +1,38 @@
import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"
setDefaultTimeout(30_000)
import { ensure } from "@workshop/shared/utils"
import { describe, test, expect, afterEach, setDefaultTimeout } from "bun:test"
import { corey, spike, coreyRepo, spikeRepo } from "./setup"
import {
type User,
setupWebhooks,
type TestPR,
expectPullRequestWebhook,
expectIssueCommentWebhook,
expectPullRequestCommentWebhook,
openTestPR,
cleanupTestPR,
commentOnPR,
createReview,
mergePR,
} from "./helpers"
setDefaultTimeout(30_000)
function testBranch(label: string) {
return `test-${crypto.randomUUID().slice(0, 8)}-${label}`
}
function getEnv(name: string): string {
const value = process.env[name]
ensure(value, `Missing env var: ${name}. See .env.example`)
return value
}
const corey: User = { token: getEnv("TEST_GITEA_API_TOKEN_COREY"), username: "probablycorey" }
const spike: User = { token: getEnv("TEST_GITEA_API_TOKEN_SPIKE"), username: "Spike" }
const coreyRepo = getEnv("TEST_REPO_COREY")
const spikeRepo = getEnv("TEST_REPO_SPIKE")
let teardown: () => void
beforeAll(async () => {
teardown = await setupWebhooks(corey, spike, coreyRepo, spikeRepo)
const prsToCleanup: TestPR[] = []
afterEach(async () => {
while (prsToCleanup.length) {
await cleanupTestPR(prsToCleanup.pop()!)
}
})
afterAll(() => teardown())
// --- Tests for Corey's repo (Spike opens PR, Corey comments/reviews/merges) ---
describe(`${coreyRepo}`, () => {
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
const branch = testBranch("open")
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
await openTestPR(coreyRepo, spike, branch)
const pr = await openTestPR(coreyRepo, spike, branch)
prsToCleanup.push(pr)
const payload = await webhook
expect(payload.pull_request.user.login).toBe(spike.username)
expect(payload.repository.full_name).toBe(coreyRepo)
@ -53,6 +42,7 @@ describe(`${coreyRepo}`, () => {
test("commenting sends issue_comment webhook with correct user", async () => {
const branch = testBranch("comment")
const pr = await openTestPR(coreyRepo, spike, branch)
prsToCleanup.push(pr)
await expectIssueCommentWebhook({ action: "created", username: corey.username, prNumber: pr.number }, async (webhook) => {
await commentOnPR(coreyRepo, pr.number, "Looks good!", corey)
const payload = await webhook
@ -63,6 +53,7 @@ describe(`${coreyRepo}`, () => {
test("review with line comment sends pull_request_comment webhook", async () => {
const branch = testBranch("review")
const pr = await openTestPR(coreyRepo, spike, branch)
prsToCleanup.push(pr)
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
await createReview(coreyRepo, pr.number, corey, pr.filename)
const payload = await webhook
@ -74,6 +65,7 @@ describe(`${coreyRepo}`, () => {
test("merging sends pull_request closed webhook", async () => {
const branch = testBranch("merge")
const pr = await openTestPR(coreyRepo, spike, branch)
prsToCleanup.push(pr)
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
await mergePR(coreyRepo, pr.number, corey)
const payload = await webhook
@ -88,7 +80,8 @@ describe(`${spikeRepo}`, () => {
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
const branch = testBranch("open")
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
await openTestPR(spikeRepo, corey, branch)
const pr = await openTestPR(spikeRepo, corey, branch)
prsToCleanup.push(pr)
const payload = await webhook
expect(payload.pull_request.user.login).toBe(corey.username)
expect(payload.repository.full_name).toBe(spikeRepo)
@ -98,6 +91,7 @@ describe(`${spikeRepo}`, () => {
test("commenting sends issue_comment webhook with correct user", async () => {
const branch = testBranch("comment")
const pr = await openTestPR(spikeRepo, corey, branch)
prsToCleanup.push(pr)
await expectIssueCommentWebhook({ action: "created", username: spike.username, prNumber: pr.number }, async (webhook) => {
await commentOnPR(spikeRepo, pr.number, "On it!", spike)
const payload = await webhook
@ -108,6 +102,7 @@ describe(`${spikeRepo}`, () => {
test("review with line comment sends pull_request_comment webhook", async () => {
const branch = testBranch("review")
const pr = await openTestPR(spikeRepo, corey, branch)
prsToCleanup.push(pr)
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
await createReview(spikeRepo, pr.number, spike, pr.filename)
const payload = await webhook
@ -119,6 +114,7 @@ describe(`${spikeRepo}`, () => {
test("merging sends pull_request closed webhook", async () => {
const branch = testBranch("merge")
const pr = await openTestPR(spikeRepo, corey, branch)
prsToCleanup.push(pr)
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
await mergePR(spikeRepo, pr.number, spike)
const payload = await webhook