Compare commits
2 Commits
f7684cf141
...
abdb81572b
| Author | SHA1 | Date | |
|---|---|---|---|
| abdb81572b | |||
| 6b30715fa1 |
|
|
@ -9,7 +9,9 @@
|
|||
"authServer": "bun run --watch src/discord/auth.ts",
|
||||
"subdomain:start": "bun run src/server.tsx",
|
||||
"subdomain:dev": "bun run --hot src/server.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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
```
|
||||
|
|
|
|||
|
|
@ -15,20 +15,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) => {
|
||||
console.error("💥 Unhandled promise rejection:", error)
|
||||
await logCrash(error)
|
||||
})
|
||||
process.on("unhandledRejection", async (error) => {
|
||||
console.error("💥 Unhandled promise rejection:", error)
|
||||
await logCrash(error)
|
||||
})
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error("💥 Uncaught exception:", error)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { serve } from "bun"
|
||||
import { handleGiteaWebhook } from "./bridge"
|
||||
import "./discord/index" // Make suer the discord client is initialized
|
||||
import { startDiscord } from "./discord"
|
||||
|
||||
await startDiscord()
|
||||
import { getConfig } from "./config"
|
||||
|
||||
interface ErrorLog {
|
||||
|
|
|
|||
42
packages/spike/tests/bridge.test.ts
Normal file
42
packages/spike/tests/bridge.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = {}) {
|
||||
17
packages/spike/tests/setup.ts
Normal file
17
packages/spike/tests/setup.ts
Normal 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())
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue
Block a user