Untangle Spike architecture with bridge pattern #9

Merged
probablycorey merged 3 commits from probablycorey/ai-project-structure into main 2026-03-09 23:03:30 +00:00
21 changed files with 904 additions and 261 deletions
Showing only changes of commit 238df92888 - Show all commits

View File

@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "workshop",

View File

@ -0,0 +1,17 @@
# Discord bot
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
# Gitea
GITEA_API_TOKEN=
# Server
PORT=3000
NODE_ENV=
DATA_DIR=
# Testing
TEST_GITEA_API_TOKEN_COREY=
TEST_GITEA_API_TOKEN_SPIKE=
TEST_REPO_COREY=probablycorey/ignore-me
TEST_REPO_SPIKE=Spike/ignore-me-too

46
packages/spike/CLAUDE.md Normal file
View File

@ -0,0 +1,46 @@
# Spike
Discord-Gitea bridge bot. Syncs PRs, comments, and code reviews between a Gitea server (git.nose.space) and Discord.
## Architecture
```
src/
├── server.tsx — HTTP server (webhook endpoint, health check, error log)
├── config.ts — Dev/prod environment config (DB paths, channel IDs, username mappings)
├── 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)
```
Dependencies flow one way: `bridge/ → gitea/`, `bridge/ → discord/`. Neither `gitea/` nor `discord/` imports from the other.
## Libs
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.
- **[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.
**Import rules:**
- External code imports from the barrel only: `import { handleGiteaWebhook } from "./bridge"`
- Never reach into internal files: ~~`import { handleGiteaWebhook } from "./bridge/webhook-handler"`~~
- Internal files within a lib can import from each other freely
This keeps AI context small. When working on code that uses a lib, only read the lib's README — not every internal file.
## Running
- `bun run subdomain:dev` — Start with hot reload
- `bun run subdomain:start` — Start for production
- `bun test` — Run integration tests (requires Tailscale funnel + test env vars)
## Environment Variables
- `DISCORD_TOKEN` — Discord bot token
- `DISCORD_CLIENT_ID` — Discord application client ID
- `GITEA_API_TOKEN` — Gitea API token
- `DATA_DIR` — Data directory (prod only)
- `NODE_ENV``production` or omit for dev
- `PORT` — Server port (default 3000)

View File

@ -1,14 +1,90 @@
# Spike
# Installation
Discord-Gitea bridge bot. When someone opens a PR or leaves a comment on [git.nose.space](https://git.nose.space), Spike posts it to a Discord channel. When someone replies in the Discord thread, Spike posts it back to Gitea.
1. Clone this repository, d'uh.
1. You need to have [bun](https://bun.sh) installed.
1. Install deps with `bun install`.
1. Setup a dummy Discord bot and get your DISCORD*TOKEN and CLIENT_ID at https://discord.com/developers/applications *(Corey doesn't remember how to do this all this, so if pair with him if you run into problems. Then document it in here).
1. `cp .env.example .env` and fill in the values for everything.
1. Find a Discord channel id to use for testing and set it in `.env` as `DISCORD_CLIENT_ID`.
1. Run `bun run subdomain:dev` to start the server.
1. Visit `localhost:3000/discord/auth` to authorize the bot.
1. Use ngrok or tailscale to create a tunnel to your local server.
1. Setup a Gitea webhook to point to your ngrok url at `https://git.nose.space/user/settings/hooks`. **remember the url ends with /gitea/webhook!**
## Setup
1. Install [bun](https://bun.sh) and clone this repo.
2. `bun install` from the repo root.
3. `cp .env.example .env` and fill in the values (see below).
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 at `https://git.nose.space/<org>/settings/hooks` pointing to `https://<your-tunnel>/gitea/webhook`.
### Environment variables
Copy `.env.example` and fill in:
| Variable | What it is |
|---|---|
| `DISCORD_TOKEN` | Bot token from [Discord Developer Portal](https://discord.com/developers/applications) |
| `DISCORD_CLIENT_ID` | Application ID from the same portal |
| `GITEA_API_TOKEN` | Personal access token from git.nose.space (Settings > Applications) |
| `PORT` | Server port (default 3000) |
| `NODE_ENV` | Set to `production` for prod, leave blank for dev |
| `DATA_DIR` | Where to store the SQLite DB in prod |
For running integration tests, you also need `TEST_GITEA_API_TOKEN_COREY`, `TEST_GITEA_API_TOKEN_SPIKE`, `TEST_REPO_COREY`, and `TEST_REPO_SPIKE` — see `.env.example` for defaults.
### Username mappings
Spike converts between Gitea and Discord usernames so @mentions work across platforms. These mappings live in `src/config.ts` under `giteaToDiscordUserMappings`. Add your Gitea username → Discord username pair there.
## Architecture
```
src/
├── server.tsx — HTTP server (webhook endpoint, error log)
├── config.ts — Dev/prod config (DB paths, channel IDs, username mappings)
├── gitea/ — Pure Gitea API client and types
├── discord/ — Discord bot client, events, slash commands
└── bridge/ — Wiring between Gitea and Discord (webhook handler, DB, helpers)
```
Dependencies flow one way: `bridge/ → gitea/` and `bridge/ → discord/`. The gitea and discord modules don't know about each other — bridge is the only thing that connects them.
### How the code is organized
Each directory with an `index.ts` is a standalone lib. The `index.ts` is a barrel — it re-exports the lib's public API. Everything else inside the directory is internal.
**The rule:** external code imports from the barrel only. Never reach into a lib's internal files.
```ts
// Good
import { handleGiteaWebhook } from "./bridge"
// Bad — reaches into internals
import { handleGiteaWebhook } from "./bridge/webhook-handler"
```
Each lib has a `README.md` that documents its barrel exports. This is the contract — if you want to know what a lib does, read its README. You don't need to read every internal file.
This matters for AI-assisted development: when Claude works on code that *uses* a lib, it only needs the README to understand the API. When it works on code *inside* a lib, it reads the internals. This keeps context small and focused.
### The three libs
**[gitea/](src/gitea/README.md)** — Pure Gitea API. Fetches PRs and review comments, converts usernames, formats thread names. No side effects, no Discord, no database. Unit-testable in ~15ms.
**[discord/](src/discord/README.md)** — Discord bot setup. Creates and logs in the client, registers slash commands, listens for messages. When someone types in a PR thread, it hands off to bridge to relay the message to Gitea.
**[bridge/](src/bridge/README.md)** — The glue. Receives Gitea webhooks and creates Discord threads/messages. Receives Discord messages and posts Gitea comments. Owns the SQLite database that maps Gitea PR IDs ↔ Discord thread IDs and Gitea comment IDs ↔ Discord message IDs.
### Data flow
```
Gitea webhook POST → server.tsx → bridge/handleGiteaWebhook → Discord thread/message
Discord message → discord/events → bridge/createPRComment → Gitea comment
```
## Running
- `bun run subdomain:dev` — Start with hot reload
- `bun run subdomain:start` — Production mode
- `bun test` — Run integration tests (requires Tailscale funnel + test env vars)
## Tests
Unit tests for pure functions (username conversion, thread naming) run in ~15ms with no external dependencies.
Integration tests for webhooks require a live Gitea instance and Tailscale funnel. They open real PRs, post real comments, and verify the correct webhooks arrive. See `src/gitea/test/` for details.

View File

@ -8,7 +8,8 @@
"bot:discord": "bun run --watch src/discord",
"authServer": "bun run --watch src/discord/auth.ts",
"subdomain:start": "bun run src/server.tsx",
"subdomain:dev": "bun run --hot src/server.tsx"
"subdomain:dev": "bun run --hot src/server.tsx",
"test": "bun test src/gitea/test/"
},
"prettier": {
"printWidth": 110,

View File

@ -0,0 +1,23 @@
# bridge/
Wiring between Gitea and Discord. Handles webhook events, maps IDs between platforms, and stores the mappings in SQLite.
## Barrel Exports
```ts
function handleGiteaWebhook(
payload: unknown,
eventType: "issue_comment" | "pull_request" | "pull_request_comment",
): Promise<void>
function createPRComment(
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
body: string,
discordMessageId: string,
): Promise<{ id: number; body: string; user: { login: string }; created_at: string; updated_at: string }>
function convertDiscordMentionsToGitea(content: string): Promise<string>
function getPRByDiscordThreadId(discordThreadId: string):
{ gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string } | null
```

View File

@ -1,5 +1,5 @@
import { Database } from "bun:sqlite"
import { getConfig } from "./config"
import { getConfig } from "../config"
const dbPath = getConfig("dbPath")
export const db = new Database(dbPath)
@ -26,13 +26,13 @@ db.run(`
console.log(`📊 Database initialized at ${dbPath}`)
// PR operations
export const insertPR = (
export function insertPR(
giteaPrId: number,
repo: string,
prNumber: number,
discordThreadId: string,
discordMessageId: string
) => {
) {
db.run(
`INSERT INTO prs (gitea_pr_id, repo, pr_number, discord_thread_id, discord_message_id)
VALUES (?, ?, ?, ?, ?)`,
@ -40,30 +40,25 @@ export const insertPR = (
)
}
export const getPRByGiteaId = (giteaPrId: number) => {
const row = db
export function getPRByGiteaId(giteaPrId: number) {
return db
.query<
{ discord_thread_id: string; discord_message_id: string; repo: string; pr_number: number },
number
>(`SELECT discord_thread_id, discord_message_id, repo, pr_number FROM prs WHERE gitea_pr_id = ?`)
.get(giteaPrId)
return row
}
export const getPRByDiscordThreadId = (discordThreadId: string) => {
const row = db
export function getPRByDiscordThreadId(discordThreadId: string) {
return db
.query<{ gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string }, string>(
`SELECT gitea_pr_id, repo, pr_number, discord_thread_id FROM prs WHERE discord_thread_id = ?`
)
.get(discordThreadId)
// Don't throw - we use this to check if it's a PR thread
return row
}
// Comment operations
export const insertComment = (giteaCommentId: number, discordMessageId: string, discordThreadId: string) => {
export function insertComment(giteaCommentId: number, discordMessageId: string, discordThreadId: string) {
db.run(
`INSERT INTO comments (gitea_comment_id, discord_message_id, discord_thread_id)
VALUES (?, ?, ?)`,
@ -71,12 +66,10 @@ export const insertComment = (giteaCommentId: number, discordMessageId: string,
)
}
export const getCommentByGiteaId = (giteaCommentId: number) => {
const row = db
export function getCommentByGiteaId(giteaCommentId: number) {
return db
.query<{ discord_message_id: string; discord_thread_id: string }, number>(
`SELECT discord_message_id, discord_thread_id FROM comments WHERE gitea_comment_id = ?`
)
.get(giteaCommentId)
return row
}

View File

@ -0,0 +1,132 @@
import { client } from "../discord"
import { getPRByGiteaId, insertComment } from "./db"
import { getConfig } from "../config"
import { ChannelType, TextChannel } from "discord.js"
import { convertUsername } from "../gitea"
export async function getThread(pullRequest: { id: number; number: number }) {
const row = getPRByGiteaId(pullRequest.id)
if (!row) return
const thread = await client.channels.fetch(row.discord_thread_id)
if (!thread) return
if (!thread.isThread()) {
throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`)
}
return thread
}
export async function getWebhook() {
const channel = await getChannel()
const webhooks = await channel.fetchWebhooks()
let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id)
if (!webhook) {
webhook = await channel.createWebhook({
name: "Gitea Bridge",
reason: "Proxy Gitea comments to Discord",
})
}
return webhook
}
export async function getDiscordAvatarUrl(discordUsername: string) {
const discordUser = await getDiscordUser(discordUsername)
return discordUser?.avatarURL() || undefined
}
export async function getDiscordUser(discordUsername: string) {
const cachedUser = client.users.cache.find((user) => user.username === discordUsername)
if (cachedUser) return cachedUser
const channel = await getChannel()
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
const member = members.first()
return member?.user ?? undefined
}
export async function convertDiscordMentionsToGitea(content: string): Promise<string> {
const mentionRegex = /<@(\d+)>/g
const mentions = [...content.matchAll(mentionRegex)]
let result = content
for (const match of mentions) {
const userId = match[1]!
const user = await client.users.fetch(userId).catch(() => null)
if (!user) continue
const giteaUsername = convertUsername({ discordUsername: user.displayName })
result = result.replace(match[0], `@${giteaUsername}`)
}
return result
}
// --- Gitea API call + DB insert (bridge between Discord messages and Gitea comments) ---
const giteaUrl = "https://git.nose.space"
type CreateCommentResponse = {
id: number
body: string
user: { login: string }
created_at: string
updated_at: string
}
export async function createPRComment(
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
body: string,
discordMessageId: string
): Promise<CreateCommentResponse> {
const [owner, repo] = prData.repo.split("/")
if (!owner) {
throw new Error(`Could not determine owner from repo string: ${prData.repo}`)
}
if (!repo) {
throw new Error(`Could not determine repo from repo string: ${prData.repo}`)
}
const url = `${giteaUrl}/api/v1/repos/${owner}/${repo}/issues/${prData.pr_number}/comments`
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
})
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
}
const comment = (await response.json()) as CreateCommentResponse
// Store the mapping immediately so webhook handler knows this came from Discord
insertComment(comment.id, discordMessageId, prData.discord_thread_id)
return comment
}
// --- Private ---
let channel: TextChannel | undefined
async function getChannel(): Promise<TextChannel> {
if (channel) return channel
const channelId = getConfig("channelId")
const foundChannel = await client.channels.fetch(channelId)
if (foundChannel?.type !== ChannelType.GuildText) {
throw new Error(
`Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)`
)
}
channel = foundChannel as TextChannel
return channel
}

View File

@ -0,0 +1,3 @@
export { handleGiteaWebhook } from "./webhook-handler"
export { createPRComment, convertDiscordMentionsToGitea } from "./discord-helpers"
export { getPRByDiscordThreadId } from "./db"

View File

@ -1,21 +1,11 @@
import { insertPR, insertComment, getCommentByGiteaId } from "../db"
import {
ensurePRCreatedOnDiscord as ensureThreadExists,
getDiscordUser,
getWebhook,
getThread,
getDiscordAvatarUrl,
convertUsername,
threadName,
} from "./helpers"
import type { Gitea } from "./types"
import { insertPR, insertComment, getCommentByGiteaId, getPRByGiteaId } from "./db"
import { getDiscordUser, getWebhook, getThread, getDiscordAvatarUrl } from "./discord-helpers"
import { convertUsername, threadName, fetchPR, fetchReviewComments } from "../gitea"
import type { Gitea } from "../gitea"
import { getConfig } from "../config"
import { fetchReviewComments } from "./api"
type EventType = "issue_comment" | "pull_request" | "pull_request_comment"
export const handleGiteaWebhook = async (payload: unknown, eventType: EventType) => {
// console.log(`🌭`, JSON.stringify(payload, null, 2))
export async function handleGiteaWebhook(payload: unknown, eventType: EventType) {
if (ignorePayload(payload)) {
return
} else if (PRHandler.canHandle(payload, eventType)) {
@ -27,17 +17,30 @@ export const handleGiteaWebhook = async (payload: unknown, eventType: EventType)
}
}
const ignorePayload = (payload: any) => {
const TEST_REPOS = ["ignore-me", "ignore-me-too"]
function ignorePayload(payload: any) {
const isDev = process.env.NODE_ENV !== "production"
const repositoryName = payload?.repository?.name
if (isDev) {
return repositoryName !== "ignore-me"
return !TEST_REPOS.includes(repositoryName)
} else {
return repositoryName === "ignore-me"
return TEST_REPOS.includes(repositoryName)
}
}
export class PRHandler {
async function ensureThreadExists(
pullRequest: Gitea.PullRequest | Gitea.Issue,
repository: Gitea.Repository
) {
const exists = getPRByGiteaId(pullRequest.id)
if (exists) return
const pr = await fetchPR(repository.full_name, pullRequest.number)
await PRHandler.handleOpened(pr, repository)
}
class PRHandler {
static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestWebhook {
return eventType === "pull_request"
}
@ -109,7 +112,7 @@ export class PRHandler {
let message = `
> ### [${pullRequest.title}](<${pullRequest.html_url}>)
> **${repositoryFullName}**
>
>
${body
.split("\n")
.map((line) => `> ${line}`)
@ -300,16 +303,8 @@ ${formattedBody}
}
static extractCodeFromDiff(diffHunk: string): string {
// diff_hunk format is like:
// @@ -1,3 +1,3 @@
// line1
// -old line
// +new line
// line2
const lines = diffHunk.split("\n")
const codeLines = lines.filter((line) => !line.startsWith("@@"))
return codeLines.join("\n")
}
}

View File

@ -0,0 +1,20 @@
# discord/
Discord bot client for the Gitea-Discord bridge. Initializes the bot, registers slash commands, and listens for messages in PR threads to relay back to Gitea.
## Barrel Exports
```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
*/
const client: Client
```

View File

@ -1,8 +1,7 @@
import { ActivityType, type Client } from "discord.js"
import { runCommand } from "./commands"
import { getPRByDiscordThreadId } from "../db"
import { createPRComment } from "../gitea/api"
import { convertUsername, convertDiscordMentionsToGitea } from "../gitea/helpers"
import { createPRComment, convertDiscordMentionsToGitea, getPRByDiscordThreadId } from "../bridge"
import { convertUsername } from "../gitea"
export const listenForEvents = (client: Client) => {
client.on("interactionCreate", async (interaction) => {

View File

@ -0,0 +1,17 @@
# gitea/
Pure Gitea API client and types. No Discord or database dependencies.
## Barrel Exports
```ts
function fetchPR(fullname: string, prNumber: number): Promise<Gitea.PullRequest>
function fetchReviewComments(fullname: string, prNumber: number): Promise<Gitea.ReviewComment[]>
function convertUsername(opts: { discordUsername: string } | { giteaUsername: string }): string
function threadName(pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string
namespace Gitea { }
```

View File

@ -1,55 +1,8 @@
import { insertComment } from "../db"
import { type Gitea } from "./types"
import type { Gitea } from "./types"
const giteaUrl = "https://git.nose.space"
type CreateCommentResponse = {
id: number
body: string
user: {
login: string
}
created_at: string
updated_at: string
}
export const createPRComment = async (
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
body: string,
discordMessageId: string
): Promise<CreateCommentResponse> => {
const [owner, repo] = prData.repo.split("/")
if (!owner) {
throw new Error(`Could not determine owner from repo string: ${prData.repo}`)
}
if (!repo) {
throw new Error(`Could not determine repo from repo string: ${prData.repo}`)
}
const url = `${giteaUrl}/api/v1/repos/${owner}/${repo}/issues/${prData.pr_number}/comments`
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ body }),
})
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
}
const comment = (await response.json()) as CreateCommentResponse
// Store the mapping immediately so webhook handler knows this came from Discord
insertComment(comment.id, discordMessageId, prData.discord_thread_id)
return comment
}
export const fetchPR = async (fullname: string, prNumber: number): Promise<Gitea.PullRequest> => {
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: {
@ -63,10 +16,10 @@ export const fetchPR = async (fullname: string, prNumber: number): Promise<Gitea
return response.json() as Promise<Gitea.PullRequest>
}
export const fetchReviewComments = async (
export async function fetchReviewComments(
fullname: string,
prNumber: number
): Promise<Gitea.ReviewComment[]> => {
): Promise<Gitea.ReviewComment[]> {
// First, fetch all reviews
const reviewsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews`
const reviewsResponse = await fetch(reviewsUrl, {

View File

@ -1,149 +0,0 @@
import { client } from "../discord/index"
import { getPRByGiteaId } from "../db"
import { getConfig } from "../config"
import { ChannelType, TextChannel } from "discord.js"
import { assertNever } from "@workshop/shared/utils"
import { fetchPR } from "./api"
import type { Gitea } from "./types"
import { PRHandler } from "./webhook-handler"
export const ensurePRCreatedOnDiscord = async (
pullRequest: Gitea.PullRequest | Gitea.Issue,
repository: Gitea.Repository
) => {
const prNumber = pullRequest.number
const exists = getPRByGiteaId(pullRequest.id)
if (exists) return true
const pr = await fetchPR(repository.full_name, prNumber)
await PRHandler.handleOpened(pr, repository)
return true
}
export const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => {
const row = getPRByGiteaId(pullRequest.id)
if (!row) {
return
}
const thread = await client.channels.fetch(row.discord_thread_id)
if (!thread) {
return
}
if (!thread.isThread()) {
throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`)
}
return thread
}
type PullRequestState = "closed" | "merged" | "opened"
const prState = (pullRequest: Gitea.PullRequest): { icon: string; label: string } => {
let state: PullRequestState = "opened"
if (pullRequest.merged) {
state = "merged"
} else if (pullRequest.state === "closed") {
state = "closed"
}
let stateInfo: { icon: string; label: string }
if (state === "merged") {
stateInfo = { icon: "🟣", label: "merged" }
} else if (state === "closed") {
stateInfo = { icon: "🔴", label: "closed" }
} else if (state === "opened") {
stateInfo = { icon: "🟢", label: "opened" }
} else {
assertNever(state, `Unhandled pull request state: ${state}`)
}
return stateInfo
}
export const threadName = (pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string => {
const stateInfo = prState(pullRequest)
const name = `${stateInfo.icon} [${repository.name}] ${pullRequest.title}`
return name.slice(0, 100)
}
export const getWebhook = async () => {
const channel = await getChannel()
const webhooks = await channel.fetchWebhooks()
let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id)
if (!webhook) {
webhook = await channel.createWebhook({
name: "Gitea Bridge",
reason: "Proxy Gitea comments to Discord",
})
}
return webhook
}
export const getDiscordAvatarUrl = async (discordUsername: string) => {
const discordUser = await getDiscordUser(discordUsername)
return discordUser?.avatarURL() || undefined
}
export const convertUsername = (opts: { discordUsername: string } | { giteaUsername: string }): string => {
const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings")
let convertedUsername: string | undefined
let username: string
if ("giteaUsername" in opts) {
username = opts.giteaUsername
convertedUsername = giteaToDiscordUserMappings[username]
} else {
username = opts.discordUsername
const entries = Object.entries(giteaToDiscordUserMappings)
const result = entries.find(([_, discordUsername]) => discordUsername === username)
if (result) convertedUsername = result[0]
}
return convertedUsername || `💥${username}💥`
}
export const getDiscordUser = async (discordUsername: string) => {
const cachedUser = client.users.cache.find((user) => user.username === discordUsername)
if (cachedUser) return cachedUser
const channel = await getChannel()
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
const member = members.first()
return member?.user ?? undefined
}
export const convertDiscordMentionsToGitea = async (content: string): Promise<string> => {
const mentionRegex = /<@(\d+)>/g
const mentions = [...content.matchAll(mentionRegex)]
let result = content
for (const match of mentions) {
const userId = match[1]!
const user = await client.users.fetch(userId).catch(() => null)
if (!user) continue
const giteaUsername = convertUsername({ discordUsername: user.displayName })
result = result.replace(match[0], `@${giteaUsername}`)
}
return result
}
let channel: TextChannel | null = null
const getChannel = async (): Promise<TextChannel> => {
if (channel) return channel
const channelId = getConfig("channelId")
const foundChannel = await client.channels.fetch(channelId)
if (foundChannel?.type !== ChannelType.GuildText) {
throw new Error(
`Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)`
)
}
channel = foundChannel as TextChannel
return channel
}

View File

@ -0,0 +1,3 @@
export { fetchPR, fetchReviewComments } from "./api"
export { convertUsername, threadName } from "./utils"
export type { Gitea } from "./types"

View File

@ -0,0 +1,76 @@
import { describe, test, expect } from "bun:test"
import { convertUsername, threadName } from "../utils"
import type { Gitea } from "../types"
describe("convertUsername", () => {
test("converts gitea username to discord username", () => {
const result = convertUsername({ giteaUsername: "probablycorey" })
expect(result).toBe("corey")
})
test("converts discord username to gitea username", () => {
const result = convertUsername({ discordUsername: "corey" })
expect(result).toBe("probablycorey")
})
test("returns fallback with 💥 for unknown gitea username", () => {
const result = convertUsername({ giteaUsername: "nobody" })
expect(result).toBe("💥nobody💥")
})
test("returns fallback with 💥 for unknown discord username", () => {
const result = convertUsername({ discordUsername: "nobody" })
expect(result).toBe("💥nobody💥")
})
})
function fakePR(overrides: Partial<Gitea.PullRequest> = {}): Gitea.PullRequest {
return {
id: 1,
number: 42,
title: "Add feature",
body: "",
user: { id: 1, login: "test", full_name: "Test", avatar_url: "" },
html_url: "",
state: "open",
merged: false,
created_at: "",
updated_at: "",
draft: false,
base: {},
head: {},
...overrides,
}
}
const fakeRepo: Gitea.Repository = {
id: 1,
name: "my-repo",
full_name: "user/my-repo",
owner: { id: 1, login: "user", full_name: "User", avatar_url: "" },
html_url: "",
private: false,
}
describe("threadName", () => {
test("formats open PR", () => {
const result = threadName(fakePR(), fakeRepo)
expect(result).toBe("🟢 [my-repo] Add feature")
})
test("formats merged PR", () => {
const result = threadName(fakePR({ merged: true, state: "closed" }), fakeRepo)
expect(result).toBe("🟣 [my-repo] Add feature")
})
test("formats closed (not merged) PR", () => {
const result = threadName(fakePR({ state: "closed" }), fakeRepo)
expect(result).toBe("🔴 [my-repo] Add feature")
})
test("truncates to 100 characters", () => {
const longTitle = "A".repeat(200)
const result = threadName(fakePR({ title: longTitle }), fakeRepo)
expect(result.length).toBe(100)
})
})

View File

@ -0,0 +1,270 @@
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"
import { tmpdir } from "os"
const giteaUrl = "https://git.nose.space"
export type User = { token: string; username: string }
// --- Webhook capture ---
type Expectation = {
eventType: string
match: (payload: any) => boolean
resolve: (payload: any) => void
timer: ReturnType<typeof setTimeout>
}
const captured: any[] = []
const expectations: Expectation[] = []
function matchIncoming(eventType: string, payload: any) {
const idx = expectations.findIndex((e) => e.eventType === eventType && e.match(payload))
if (idx < 0) return
const expectation = expectations.splice(idx, 1)[0]!
clearTimeout(expectation.timer)
expectation.resolve(payload)
}
const captureServer = serve({
port: 0,
routes: {
"/gitea/webhook": {
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")
},
},
},
})
// 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)
if (idx >= 0) expectations.splice(idx, 1)
reject(new Error(`Timed out waiting for ${eventType} webhook`))
}, timeoutMs)
const expectation: Expectation = { eventType, match, resolve, timer }
expectations.push(expectation)
})
}
function hasBranch(payload: any, branch: string) {
return payload.pull_request?.head?.ref === branch || payload.pull_request?.head?.label === branch
}
export async function expectPullRequestWebhook<T>(
match: { action: string; branch: string },
callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
const webhook = awaitWebhook("pull_request", (p) => p.action === match.action && hasBranch(p, match.branch))
return callback(webhook)
}
export async function expectIssueCommentWebhook<T>(
match: { action: string; username: string; prNumber: number },
callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
const webhook = awaitWebhook(
"issue_comment",
(p) => p.action === match.action && p.comment?.user?.login === match.username && p.issue?.number === match.prNumber,
)
return callback(webhook)
}
export async function expectPullRequestCommentWebhook<T>(
match: { action: string; branch: string },
callback: (webhook: Promise<any>) => Promise<T>,
): Promise<T> {
const webhook = awaitWebhook("pull_request_comment", (p) => p.action === match.action && hasBranch(p, match.branch))
return callback(webhook)
}
// --- Setup / teardown ---
export async function setupWebhooks(corey: User, spike: User, coreyRepo: string, spikeRepo: string) {
const tsResult = Bun.spawnSync(["tailscale", "status", "--self", "--json"])
ensure(tsResult.success, `Failed to get tailscale status: ${tsResult.stderr.toString()}`)
const tailscaleUrl = `https://${JSON.parse(tsResult.stdout.toString()).Self.DNSName.replace(/\.$/, "")}`
const funnel = Bun.spawn(["tailscale", "funnel", String(captureServer.port)], { stdout: "ignore", stderr: "ignore" })
const funnelUrl = `${tailscaleUrl}/gitea/webhook`
// Wait for funnel to be reachable
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
} catch {}
await Bun.sleep(500)
}
await ensureRepo(spikeRepo, spike)
await addCollaborator(spikeRepo, spike, corey.username)
await upsertWebhook(coreyRepo, funnelUrl, corey)
await upsertWebhook(spikeRepo, funnelUrl, spike)
// 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() {
captureServer.stop()
funnel.kill()
}
}
// --- Scaffolding: open a PR ready for testing ---
export type TestPR = {
repo: string
number: number
branch: string
filename: string
dir: string
author: User
}
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 }
}
// --- Gitea API ---
async function giteaFetch(path: string, user: User, options: RequestInit = {}) {
const response = await fetch(`${giteaUrl}/api/v1${path}`, {
...options,
headers: {
Authorization: `token ${user.token}`,
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
},
})
if (!response.ok) {
const body = await response.text().catch(() => "")
throw new Error(`Gitea ${response.status}: ${options.method ?? "GET"} ${path}\n${body}`)
}
if (response.headers.get("content-type")?.includes("json")) {
return response.json()
}
}
async function ensureRepo(repo: string, owner: User) {
const res = await fetch(`${giteaUrl}/api/v1/repos/${repo}`, {
headers: { Authorization: `token ${owner.token}` },
})
if (res.ok) return
const repoName = repo.split("/")[1]
await giteaFetch("/user/repos", owner, {
method: "POST",
body: JSON.stringify({ name: repoName, auto_init: true, default_branch: "main" }),
})
}
async function addCollaborator(repo: string, owner: User, collaborator: string) {
await giteaFetch(`/repos/${repo}/collaborators/${collaborator}`, owner, {
method: "PUT",
body: JSON.stringify({ permission: "write" }),
})
}
async function upsertWebhook(repo: string, webhookUrl: string, user: User) {
// Delete all existing webhooks to prevent duplicates from stale test runs
const hooks = (await giteaFetch(`/repos/${repo}/hooks`, user)) as Array<{ id: number; config: { url: string } }>
for (const hook of hooks) {
await giteaFetch(`/repos/${repo}/hooks/${hook.id}`, user, { method: "DELETE" })
}
await giteaFetch(`/repos/${repo}/hooks`, user, {
method: "POST",
body: JSON.stringify({
type: "gitea",
active: true,
config: { url: webhookUrl, content_type: "json" },
events: ["pull_request", "issue_comment", "pull_request_comment"],
}),
})
}
async function createPR(repo: string, title: string, head: string, user: User) {
return giteaFetch(`/repos/${repo}/pulls`, user, {
method: "POST",
body: JSON.stringify({ title, head, base: "main", body: "Test PR" }),
})
}
export async function commentOnPR(repo: string, prNumber: number, body: string, user: User) {
return giteaFetch(`/repos/${repo}/issues/${prNumber}/comments`, user, {
method: "POST",
body: JSON.stringify({ body }),
})
}
export async function mergePR(repo: string, prNumber: number, user: User) {
return giteaFetch(`/repos/${repo}/pulls/${prNumber}/merge`, user, {
method: "POST",
body: JSON.stringify({ Do: "merge", merge_message_field: "Test merge" }),
})
}
export async function createReview(repo: string, prNumber: number, user: User, filePath: string) {
return giteaFetch(`/repos/${repo}/pulls/${prNumber}/reviews`, user, {
method: "POST",
body: JSON.stringify({
body: "Review from integration test",
event: "COMMENT",
comments: [{ path: filePath, new_position: 1, body: `Line comment from ${user.username}` }],
}),
})
}
// --- Git ---
function git(args: string[], cwd: string) {
const result = Bun.spawnSync(["git", ...args], { cwd })
if (!result.success) {
throw new Error(`git ${args.join(" ")} failed:\n${result.stderr.toString()}`)
}
}
async function pushBranch(repo: string, branch: string, user: User) {
const dir = await mkdtemp(join(tmpdir(), "spike-test-"))
const url = `https://${user.username}:${user.token}@git.nose.space/${repo}.git`
const filename = `test-${Date.now()}.md`
git(["clone", url, dir], dir)
git(["config", "user.name", user.username], dir)
git(["config", "user.email", `${user.username}@test`], dir)
git(["checkout", "-b", branch], dir)
await Bun.write(join(dir, filename), "test file")
git(["add", "."], dir)
git(["commit", "-m", "test commit"], dir)
git(["push", "origin", branch], dir)
return { dir, filename }
}

View File

@ -0,0 +1,119 @@
import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"
setDefaultTimeout(30_000)
import { ensure } from "@workshop/shared/utils"
import {
type User,
type TestPR,
setupWebhooks,
expectPullRequestWebhook,
expectIssueCommentWebhook,
expectPullRequestCommentWebhook,
openTestPR,
commentOnPR,
createReview,
mergePR,
} from "./helpers"
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}`, () => {
let pr: TestPR
const branch = `test-${Date.now()}-corey`
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
pr = await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
const result = 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)
return result
})
})
test("commenting sends issue_comment webhook with correct user", async () => {
await expectIssueCommentWebhook({ action: "created", username: corey.username, prNumber: pr.number }, async (webhook) => {
await commentOnPR(coreyRepo, pr.number, "Looks good!", corey)
const payload = await webhook
expect(payload.issue.number).toBe(pr.number)
})
})
test("review with line comment sends pull_request_comment webhook", async () => {
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
await createReview(coreyRepo, pr.number, corey, pr.filename)
const payload = await webhook
expect(payload.sender.login).toBe(corey.username)
expect(payload.review).toBeDefined()
})
})
test("merging sends pull_request closed webhook", async () => {
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
await mergePR(coreyRepo, pr.number, corey)
const payload = await webhook
expect(payload.pull_request.merged).toBe(true)
})
})
})
// --- Tests for Spike's repo (Corey opens PR, Spike comments/reviews/merges) ---
describe(`${spikeRepo}`, () => {
let pr: TestPR
const branch = `test-${Date.now()}-spike`
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
pr = await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
const result = 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)
return result
})
})
test("commenting sends issue_comment webhook with correct user", async () => {
await expectIssueCommentWebhook({ action: "created", username: spike.username, prNumber: pr.number }, async (webhook) => {
await commentOnPR(spikeRepo, pr.number, "On it!", spike)
const payload = await webhook
expect(payload.issue.number).toBe(pr.number)
})
})
test("review with line comment sends pull_request_comment webhook", async () => {
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
await createReview(spikeRepo, pr.number, spike, pr.filename)
const payload = await webhook
expect(payload.sender.login).toBe(spike.username)
expect(payload.review).toBeDefined()
})
})
test("merging sends pull_request closed webhook", async () => {
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
await mergePR(spikeRepo, pr.number, spike)
const payload = await webhook
expect(payload.pull_request.merged).toBe(true)
})
})
})

View File

@ -0,0 +1,48 @@
import { getConfig } from "../config"
import { assertNever } from "@workshop/shared/utils"
import type { Gitea } from "./types"
export function convertUsername(opts: { discordUsername: string } | { giteaUsername: string }): string {
const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings")
let convertedUsername: string | undefined
let username: string
if ("giteaUsername" in opts) {
username = opts.giteaUsername
convertedUsername = giteaToDiscordUserMappings[username]
} else {
username = opts.discordUsername
const entries = Object.entries(giteaToDiscordUserMappings)
const result = entries.find(([_, discordUsername]) => discordUsername === username)
if (result) convertedUsername = result[0]
}
return convertedUsername || `💥${username}💥`
}
type PullRequestState = "closed" | "merged" | "opened"
function prState(pullRequest: Gitea.PullRequest): { icon: string; label: string } {
let state: PullRequestState = "opened"
if (pullRequest.merged) {
state = "merged"
} else if (pullRequest.state === "closed") {
state = "closed"
}
let stateInfo: { icon: string; label: string }
if (state === "merged") {
stateInfo = { icon: "🟣", label: "merged" }
} else if (state === "closed") {
stateInfo = { icon: "🔴", label: "closed" }
} else if (state === "opened") {
stateInfo = { icon: "🟢", label: "opened" }
} else {
assertNever(state, `Unhandled pull request state: ${state}`)
}
return stateInfo
}
export function threadName(pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string {
const stateInfo = prState(pullRequest)
const name = `${stateInfo.icon} [${repository.name}] ${pullRequest.title}`
return name.slice(0, 100)
}

View File

@ -1,5 +1,5 @@
import { serve } from "bun"
import { handleGiteaWebhook } from "./gitea/webhook-handler"
import { handleGiteaWebhook } from "./bridge"
import "./discord/index" // Make suer the discord client is initialized
import { getConfig } from "./config"