Compare commits

..

4 Commits

Author SHA1 Message Date
f7684cf141 Merge pull request 'Untangle Spike architecture with bridge pattern' (#9) from probablycorey/ai-project-structure into main
Some checks are pending
CI / test (push) Waiting to run
Reviewed-on: #9
2026-03-09 23:03:30 +00:00
4bcec23c22 Merge branch 'main' into probablycorey/ai-project-structure
Some checks failed
CI / test (pull_request) Has been cancelled
2026-03-09 23:03:25 +00:00
a71bf2d492 Make webhook tests independent with isolated PRs and retry on merge timing issues
Some checks failed
CI / test (pull_request) Has been cancelled
Each test now creates its own PR and cleans up after itself. Tests no longer depend on shared state or ordering, making them resilient to failures.

Added retry logic to mergePR for Gitea's 404/405 responses while computing mergeability. Used crypto.randomUUID() for unique branch names instead of Date.now() to eliminate collision risk.
2026-03-09 15:56:09 -07:00
238df92888 Untangle Spike architecture with bridge pattern
Split the monolithic gitea/helpers.ts (which had Discord imports and created circular dependencies) into three focused libs:

1. **gitea/** — Pure API client: fetchPR, fetchReviewComments, convertUsername, threadName. No side effects or external deps.

2. **discord/** — Discord client setup: bot login, event listeners, slash commands. Now isolated from Gitea internals.

3. **bridge/** — New integration layer: webhook handler, DB mappings (Gitea PR ↔ Discord thread), Discord helpers, and createPRComment.

Dependencies now flow one direction: bridge → gitea and bridge → discord. No circular imports.

Added:
- Barrel exports (index.ts) for each lib with public API
- README.md for each lib documenting the barrel exports
- Comprehensive spike README.md with setup guide and architecture explanation
- Integration tests for webhooks (callback-based, no race conditions)
- Unit tests for pure API functions
- CLAUDE.md with links to each lib's README

This architecture makes it possible for AI to understand a lib by reading just its README, keeping context focused and small.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-09 15:23:11 -07:00
20 changed files with 932 additions and 261 deletions

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,290 @@
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) {
// Gitea returns 404/405 while computing mergeability after PR creation
const path = `/repos/${repo}/pulls/${prNumber}/merge`
for (let attempt = 0; attempt < 10; attempt++) {
const response = await fetch(`${giteaUrl}/api/v1${path}`, {
method: "POST",
headers: {
Authorization: `token ${user.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ Do: "merge" }),
})
if (response.ok) return
if (response.status === 404 || response.status === 405) {
await Bun.sleep(1000)
continue
}
const body = await response.text().catch(() => "")
throw new Error(`Gitea ${response.status}: POST ${path}\n${body}`)
}
throw new Error(`Gitea merge failed after 10 retries for ${path}`)
}
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,128 @@
import { describe, test, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"
setDefaultTimeout(30_000)
import { ensure } from "@workshop/shared/utils"
import {
type User,
setupWebhooks,
expectPullRequestWebhook,
expectIssueCommentWebhook,
expectPullRequestCommentWebhook,
openTestPR,
commentOnPR,
createReview,
mergePR,
} from "./helpers"
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)
})
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 payload = await webhook
expect(payload.pull_request.user.login).toBe(spike.username)
expect(payload.repository.full_name).toBe(coreyRepo)
})
})
test("commenting sends issue_comment webhook with correct user", async () => {
const branch = testBranch("comment")
const pr = await openTestPR(coreyRepo, spike, branch)
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 () => {
const branch = testBranch("review")
const pr = await openTestPR(coreyRepo, spike, branch)
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 () => {
const branch = testBranch("merge")
const pr = await openTestPR(coreyRepo, spike, branch)
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}`, () => {
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 payload = await webhook
expect(payload.pull_request.user.login).toBe(corey.username)
expect(payload.repository.full_name).toBe(spikeRepo)
})
})
test("commenting sends issue_comment webhook with correct user", async () => {
const branch = testBranch("comment")
const pr = await openTestPR(spikeRepo, corey, branch)
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 () => {
const branch = testBranch("review")
const pr = await openTestPR(spikeRepo, corey, branch)
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 () => {
const branch = testBranch("merge")
const pr = await openTestPR(spikeRepo, corey, branch)
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"