Compare commits

..

No commits in common. "main" and "probablycorey/ai-project-structure" have entirely different histories.

22 changed files with 154 additions and 669 deletions

View File

@ -6,9 +6,8 @@ Discord-Gitea bridge bot. Syncs PRs, comments, and code reviews between a Gitea
```
src/
├── server/ — HTTP server, routes, and web pages (logs viewer, auth)
├── server.tsx — HTTP server (webhook endpoint, health check, error log)
├── 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)
@ -20,7 +19,6 @@ 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.

View File

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

View File

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

View File

@ -1,10 +1,8 @@
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(`
@ -25,6 +23,8 @@ db.run(`
);
`)
console.log(`📊 Database initialized at ${dbPath}`)
// PR operations
export function insertPR(
giteaPrId: number,

View File

@ -3,7 +3,6 @@ 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)
@ -56,10 +55,7 @@ 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((error) => {
log({ type: "error", error, context: `fetch Discord user ${userId}` })
return null
})
const user = await client.users.fetch(userId).catch(() => null)
if (!user) continue
const giteaUsername = convertUsername({ discordUsername: user.displayName })

View File

@ -3,13 +3,10 @@ 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)
@ -39,7 +36,6 @@ 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)
}
@ -60,7 +56,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 })
console.log(`✅ pull request webhook action: ${action} #${payload.number}`)
}
static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
@ -112,16 +108,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")}
@ -148,23 +140,22 @@ class CommentHandler {
if (action === "created") {
await ensureThreadExists(issue, repository)
await this.handleCreated(issue, comment, repository.full_name)
await this.handleCreated(issue, comment)
} else if (action === "edited") {
await ensureThreadExists(issue, repository)
await this.handleEdited(issue, comment, repository.full_name)
await this.handleEdited(issue, comment)
} else if (action === "deleted") {
await ensureThreadExists(issue, repository)
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) })
console.log(`✅ comment webhook action: ${action} on #${issue.number}`)
}
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) {
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment) {
const commentRow = getCommentByGiteaId(comment.id)
if (commentRow) {
log({ type: "comment-skipped", pr: issue.number, repo, commentId: comment.id })
return
return // Comment already exists, skip
}
const discordUsername = convertUsername({ giteaUsername: comment.user.login })
@ -184,10 +175,10 @@ class CommentHandler {
insertComment(comment.id, message.id, thread.id)
}
static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) {
static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment) {
const commentRow = getCommentByGiteaId(comment.id)
if (!commentRow) {
this.handleCreated(issue, comment, repo)
this.handleCreated(issue, comment)
return
}
@ -246,7 +237,7 @@ class ReviewHandler {
// Only handle new reviews, ignore edits
if (action !== "reviewed") {
log({ type: "review", action, pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login })
console.log(`✅ review webhook action: ${action} on #${pullRequest.number} (ignored)`)
return
}
@ -295,7 +286,7 @@ class ReviewHandler {
insertComment(comment.id, message.id, thread.id)
}
log({ type: "review", action: "reviewed", pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login })
console.log(`✅ review webhook action: reviewed on #${pullRequest.number}`)
}
static async formatReviewComment(comment: Gitea.ReviewComment): Promise<string> {

View File

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

View File

@ -7,9 +7,14 @@ Discord bot client for the Gitea-Discord bridge. Initializes the bot, registers
```ts
import { Client } from "discord.js"
// The Discord bot client instance. Not logged in until startDiscord() is called.
/**
* 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
*/
const client: Client
// Logs in the bot, registers event listeners and slash commands, sets up crash logging.
async function startDiscord(): Promise<void>
```

View File

@ -6,7 +6,6 @@ import {
type Interaction,
type SlashCommandOptionsOnlyBuilder,
} from "discord.js"
import { log } from "../log"
export const runCommand = async (interaction: Interaction<CacheType>) => {
if (!interaction.isChatInputCommand()) return
@ -17,9 +16,10 @@ export const runCommand = async (interaction: Interaction<CacheType>) => {
try {
await command.execute(interaction)
} catch (error) {
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}`
const content = `❌ Error executing command ${interaction.commandName}: ${
error instanceof Error ? error.message : String(error)
}`
console.error(content)
if (interaction.deferred) {
await interaction.editReply({ content })
} else {

View File

@ -1,24 +1,16 @@
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 : ""
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(crashLogPath)
const file = Bun.file(`${process.env.DATA_DIR}/crash.log`)
file.write(crashLog)
} catch (writeError) {
log({ type: "error", error: writeError, context: "writing crash log" })
} catch (error) {
console.error("Failed to write crash log:", error)
}
}
@ -27,20 +19,20 @@ export const alertAboutCrashLog = async (client: any) => {
const channelId = process.env.CHANNEL_ID ?? "1382121375619223594"
const crashLog = await clearCrashLog()
if (crashLog) {
log({ type: "crash-log-found" })
console.warn("⚠️ Previous crash log found:")
const channel = await client.channels.fetch(channelId)
if (channel?.isSendable()) {
channel.send(`⚠️ Previous crash log found:\n\`\`\`${crashLog}\`\`\``)
}
}
} catch (error) {
log({ type: "error", error, context: "alerting about crash log" })
console.error("Failed to alert about crash log:", error)
}
}
const clearCrashLog = async () => {
try {
const file = Bun.file(crashLogPath)
const file = Bun.file(`${process.env.DATA_DIR}/crash.log`)
if (!(await file.exists())) return
const contents = await file.text()
await file.write("")
@ -49,6 +41,6 @@ const clearCrashLog = async () => {
return contents
}
} catch (error) {
log({ type: "error", error, context: "reading crash log" })
console.error("Failed to read crash log:", error)
}
}

View File

@ -2,7 +2,6 @@ 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) => {
@ -20,7 +19,6 @@ 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
@ -30,13 +28,12 @@ export const listenForEvents = (client: Client) => {
// await msg.channel.send(`You said: ${msg.content}`)
}
} catch (error) {
log({ type: "error", error, context: "messageCreate" })
console.error("Error handling messageCreate event:", error)
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"
@ -48,10 +45,10 @@ export const listenForEvents = (client: Client) => {
})
client.on("error", (error) => {
log({ type: "error", error, context: "discord client" })
console.error("Discord client error:", error)
})
client.on("warn", (info) => {
log({ type: "discord-warning", detail: info })
console.warn("Discord client warning:", info)
})
}

View File

@ -2,7 +2,6 @@ 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: [
@ -16,22 +15,20 @@ export const client = new Client({
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
})
export async function startDiscord() {
await client.login(process.env.DISCORD_TOKEN)
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) => {
console.error("💥 Unhandled promise rejection:", error)
await logCrash(error)
})
process.on("uncaughtException", async (error) => {
log({ type: "error", error, context: "uncaught exception" })
await logCrash(error)
})
process.on("uncaughtException", async (error) => {
console.error("💥 Uncaught exception:", error)
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,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,14 +40,22 @@ 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 comments = (await commentsResponse.json()) as Gitea.ReviewComment[]
allComments.push(...comments)
const commentsResponse = await fetch(commentsUrl, {
headers: {
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
},
})
if (commentsResponse.ok) {
const comments = (await commentsResponse.json()) as Gitea.ReviewComment[]
allComments.push(...comments)
}
}
return allComments

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,4 +1,5 @@
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"
@ -17,6 +18,7 @@ type Expectation = {
timer: ReturnType<typeof setTimeout>
}
const captured: any[] = []
const expectations: Expectation[] = []
function matchIncoming(eventType: string, payload: any) {
@ -35,6 +37,7 @@ 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")
},
@ -42,7 +45,14 @@ const captureServer = serve({
},
})
function awaitWebhook(eventType: string, match: (payload: any) => boolean, timeoutMs = 15000): Promise<any> {
// 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> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const idx = expectations.indexOf(expectation)
@ -93,28 +103,18 @@ 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) {
funnelReachable = true
break
}
if (res.ok) 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,6 +124,7 @@ 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() {
@ -146,12 +147,13 @@ 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)
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 })
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 }
}
// --- Gitea API ---

View File

@ -1,38 +1,49 @@
import { describe, test, expect, afterEach, setDefaultTimeout } from "bun:test"
import { corey, spike, coreyRepo, spikeRepo } from "./setup"
import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"
setDefaultTimeout(30_000)
import { ensure } from "@workshop/shared/utils"
import {
type TestPR,
type User,
setupWebhooks,
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}`
}
const prsToCleanup: TestPR[] = []
afterEach(async () => {
while (prsToCleanup.length) {
await cleanupTestPR(prsToCleanup.pop()!)
}
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)
})
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) => {
const pr = await openTestPR(coreyRepo, spike, branch)
prsToCleanup.push(pr)
await openTestPR(coreyRepo, spike, branch)
const payload = await webhook
expect(payload.pull_request.user.login).toBe(spike.username)
expect(payload.repository.full_name).toBe(coreyRepo)
@ -42,7 +53,6 @@ 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
@ -53,7 +63,6 @@ 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
@ -65,7 +74,6 @@ 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
@ -80,8 +88,7 @@ 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) => {
const pr = await openTestPR(spikeRepo, corey, branch)
prsToCleanup.push(pr)
await openTestPR(spikeRepo, corey, branch)
const payload = await webhook
expect(payload.pull_request.user.login).toBe(corey.username)
expect(payload.repository.full_name).toBe(spikeRepo)
@ -91,7 +98,6 @@ 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
@ -102,7 +108,6 @@ 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
@ -114,7 +119,6 @@ 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

View File

@ -1,138 +0,0 @@
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; sender?: string; ref?: 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: "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: "gitea-api-error"; status: number; url: string; body: string; tokenPrefix: string }
| { 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}/${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() || ""
}
export function listLogFiles(): string[] {
return readdirSync(logsDir)
.filter((f) => f.endsWith(".jsonl"))
.sort((a, b) => extractTimestamp(b).localeCompare(extractTimestamp(a)))
.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")
const path = `${logsDir}/${filename}`
try {
return readFileSync(path, "utf-8")
} 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 ("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)
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")
})

View File

@ -1,10 +1,16 @@
import { serve } from "bun"
import { handleGiteaWebhook } from "../bridge"
import { startDiscord } from "../discord"
import { listLogFiles, log, readLogFileRaw } from "../log"
import { LogsPage } from "./logs"
import { handleGiteaWebhook } from "./bridge"
import "./discord/index" // Make suer the discord client is initialized
import { getConfig } from "./config"
await startDiscord()
interface ErrorLog {
timestamp: string
message: string
stack?: string
payload: any
}
const errors: ErrorLog[] = []
const server = serve({
port: parseInt(process.env.PORT || "3000"),
@ -17,38 +23,30 @@ const server = serve({
POST: async (req) => {
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 })
console.log(`🌵 Received Gitea webhook ${eventType}`)
try {
await handleGiteaWebhook(payload, eventType as any)
return new Response("OK", { status: 200 })
} catch (error) {
log({ type: "error", error, context: `webhook ${eventType} ${repo}` })
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)
return new Response("Error", { status: 500 })
}
},
},
"/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 })
}
"/errors": {
GET: () => {
return new Response(JSON.stringify(errors, null, 2), {
headers: { "Content-Type": "application/json" },
})
},
},
"/discord/auth": async () => {
@ -70,4 +68,4 @@ const server = serve({
development: process.env.NODE_ENV !== "production" && { hmr: true, console: true },
})
log({ type: "startup", detail: `Spike running at ${server.url}:${server.port}` })
console.log(`Spike running at ${server.url}:${server.port}`)

View File

@ -1,18 +0,0 @@
# 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

View File

@ -1,269 +0,0 @@
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",
}
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 }) {
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") {
details.push(errorMessage(event.error))
}
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>
)}
</>
)
}
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
const grouped: Record<string, string[]> = {}
for (const file of files) {
const { sha } = parseLogFilename(file)
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]) => (
<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)
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, i) => (
<EventRow event={event} index={i} />
))}
</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" } })
}

View File

@ -1,42 +0,0 @@
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,17 +0,0 @@
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())