diff --git a/bun.lock b/bun.lock index 53c75eb..57d8b1d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "workshop", diff --git a/packages/spike/.env.example b/packages/spike/.env.example new file mode 100644 index 0000000..b97033d --- /dev/null +++ b/packages/spike/.env.example @@ -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 diff --git a/packages/spike/CLAUDE.md b/packages/spike/CLAUDE.md new file mode 100644 index 0000000..9ceab71 --- /dev/null +++ b/packages/spike/CLAUDE.md @@ -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) diff --git a/packages/spike/README.md b/packages/spike/README.md index f46e18d..31d392e 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -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//settings/hooks` pointing to `https:///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. diff --git a/packages/spike/package.json b/packages/spike/package.json index df559dd..94f8267 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -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, diff --git a/packages/spike/src/bridge/README.md b/packages/spike/src/bridge/README.md new file mode 100644 index 0000000..01e9ca6 --- /dev/null +++ b/packages/spike/src/bridge/README.md @@ -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 + +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 + +function getPRByDiscordThreadId(discordThreadId: string): + { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string } | null +``` diff --git a/packages/spike/src/db.ts b/packages/spike/src/bridge/db.ts similarity index 78% rename from packages/spike/src/db.ts rename to packages/spike/src/bridge/db.ts index bf322da..9272299 100644 --- a/packages/spike/src/db.ts +++ b/packages/spike/src/bridge/db.ts @@ -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 } diff --git a/packages/spike/src/bridge/discord-helpers.ts b/packages/spike/src/bridge/discord-helpers.ts new file mode 100644 index 0000000..31109d1 --- /dev/null +++ b/packages/spike/src/bridge/discord-helpers.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/packages/spike/src/bridge/index.ts b/packages/spike/src/bridge/index.ts new file mode 100644 index 0000000..3b5a394 --- /dev/null +++ b/packages/spike/src/bridge/index.ts @@ -0,0 +1,3 @@ +export { handleGiteaWebhook } from "./webhook-handler" +export { createPRComment, convertDiscordMentionsToGitea } from "./discord-helpers" +export { getPRByDiscordThreadId } from "./db" diff --git a/packages/spike/src/gitea/webhook-handler.ts b/packages/spike/src/bridge/webhook-handler.ts similarity index 91% rename from packages/spike/src/gitea/webhook-handler.ts rename to packages/spike/src/bridge/webhook-handler.ts index f22b0bc..82a771d 100644 --- a/packages/spike/src/gitea/webhook-handler.ts +++ b/packages/spike/src/bridge/webhook-handler.ts @@ -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") } } diff --git a/packages/spike/src/discord/README.md b/packages/spike/src/discord/README.md new file mode 100644 index 0000000..74496bf --- /dev/null +++ b/packages/spike/src/discord/README.md @@ -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 +``` diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts index 0d955be..30d5bf6 100644 --- a/packages/spike/src/discord/events.ts +++ b/packages/spike/src/discord/events.ts @@ -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) => { diff --git a/packages/spike/src/gitea/README.md b/packages/spike/src/gitea/README.md new file mode 100644 index 0000000..47a9761 --- /dev/null +++ b/packages/spike/src/gitea/README.md @@ -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 + +function fetchReviewComments(fullname: string, prNumber: number): Promise + +function convertUsername(opts: { discordUsername: string } | { giteaUsername: string }): string + +function threadName(pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string + +namespace Gitea { } +``` diff --git a/packages/spike/src/gitea/api.ts b/packages/spike/src/gitea/api.ts index 1bab7e5..cc8301d 100644 --- a/packages/spike/src/gitea/api.ts +++ b/packages/spike/src/gitea/api.ts @@ -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 => { - 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 => { +export async function fetchPR(fullname: string, prNumber: number): Promise { 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 } -export const fetchReviewComments = async ( +export async function fetchReviewComments( fullname: string, prNumber: number -): Promise => { +): Promise { // First, fetch all reviews const reviewsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews` const reviewsResponse = await fetch(reviewsUrl, { diff --git a/packages/spike/src/gitea/helpers.ts b/packages/spike/src/gitea/helpers.ts deleted file mode 100644 index 7fa49e2..0000000 --- a/packages/spike/src/gitea/helpers.ts +++ /dev/null @@ -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 => { - 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 => { - 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 -} diff --git a/packages/spike/src/gitea/index.ts b/packages/spike/src/gitea/index.ts new file mode 100644 index 0000000..1611406 --- /dev/null +++ b/packages/spike/src/gitea/index.ts @@ -0,0 +1,3 @@ +export { fetchPR, fetchReviewComments } from "./api" +export { convertUsername, threadName } from "./utils" +export type { Gitea } from "./types" diff --git a/packages/spike/src/gitea/test/api.test.ts b/packages/spike/src/gitea/test/api.test.ts new file mode 100644 index 0000000..6834b4d --- /dev/null +++ b/packages/spike/src/gitea/test/api.test.ts @@ -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 { + 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) + }) +}) diff --git a/packages/spike/src/gitea/test/helpers.ts b/packages/spike/src/gitea/test/helpers.ts new file mode 100644 index 0000000..44805fa --- /dev/null +++ b/packages/spike/src/gitea/test/helpers.ts @@ -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 +} + +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 { + 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( + match: { action: string; branch: string }, + callback: (webhook: Promise) => Promise, +): Promise { + const webhook = awaitWebhook("pull_request", (p) => p.action === match.action && hasBranch(p, match.branch)) + return callback(webhook) +} + +export async function expectIssueCommentWebhook( + match: { action: string; username: string; prNumber: number }, + callback: (webhook: Promise) => Promise, +): Promise { + 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( + match: { action: string; branch: string }, + callback: (webhook: Promise) => Promise, +): Promise { + 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 { + 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), + }, + }) + 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 } +} diff --git a/packages/spike/src/gitea/test/webhooks.test.ts b/packages/spike/src/gitea/test/webhooks.test.ts new file mode 100644 index 0000000..419c4ec --- /dev/null +++ b/packages/spike/src/gitea/test/webhooks.test.ts @@ -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) + }) + }) +}) diff --git a/packages/spike/src/gitea/utils.ts b/packages/spike/src/gitea/utils.ts new file mode 100644 index 0000000..23b3932 --- /dev/null +++ b/packages/spike/src/gitea/utils.ts @@ -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) +} diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server.tsx index 0e141ff..8e1d8a5 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server.tsx @@ -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"