Compare commits

..

No commits in common. "57fcf9616d308c086ec0df5720a9958a1282a92a" and "96b6a4311b576917cdee3727bf35e347a9113e7f" have entirely different histories.

10 changed files with 71 additions and 127 deletions

View File

@ -9,9 +9,7 @@
"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/"
"test": "bun test src/gitea/test/"
},
"prettier": {
"printWidth": 110,

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

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

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