From 238df92888fab1d830ccd4a00aec2835db49fcca Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 9 Mar 2026 15:23:11 -0700 Subject: [PATCH 1/2] Untangle Spike architecture with bridge pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bun.lock | 1 + packages/spike/.env.example | 17 ++ packages/spike/CLAUDE.md | 46 +++ packages/spike/README.md | 98 ++++++- packages/spike/package.json | 3 +- packages/spike/src/bridge/README.md | 23 ++ packages/spike/src/{ => bridge}/db.ts | 27 +- packages/spike/src/bridge/discord-helpers.ts | 132 +++++++++ packages/spike/src/bridge/index.ts | 3 + .../src/{gitea => bridge}/webhook-handler.ts | 51 ++-- packages/spike/src/discord/README.md | 20 ++ packages/spike/src/discord/events.ts | 5 +- packages/spike/src/gitea/README.md | 17 ++ packages/spike/src/gitea/api.ts | 55 +--- packages/spike/src/gitea/helpers.ts | 149 ---------- packages/spike/src/gitea/index.ts | 3 + packages/spike/src/gitea/test/api.test.ts | 76 +++++ packages/spike/src/gitea/test/helpers.ts | 270 ++++++++++++++++++ .../spike/src/gitea/test/webhooks.test.ts | 119 ++++++++ packages/spike/src/gitea/utils.ts | 48 ++++ packages/spike/src/server.tsx | 2 +- 21 files changed, 904 insertions(+), 261 deletions(-) create mode 100644 packages/spike/.env.example create mode 100644 packages/spike/CLAUDE.md create mode 100644 packages/spike/src/bridge/README.md rename packages/spike/src/{ => bridge}/db.ts (78%) create mode 100644 packages/spike/src/bridge/discord-helpers.ts create mode 100644 packages/spike/src/bridge/index.ts rename packages/spike/src/{gitea => bridge}/webhook-handler.ts (91%) create mode 100644 packages/spike/src/discord/README.md create mode 100644 packages/spike/src/gitea/README.md delete mode 100644 packages/spike/src/gitea/helpers.ts create mode 100644 packages/spike/src/gitea/index.ts create mode 100644 packages/spike/src/gitea/test/api.test.ts create mode 100644 packages/spike/src/gitea/test/helpers.ts create mode 100644 packages/spike/src/gitea/test/webhooks.test.ts create mode 100644 packages/spike/src/gitea/utils.ts 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" From a71bf2d4925501fa3577ce203f0e561f3924b28e Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 9 Mar 2026 15:56:09 -0700 Subject: [PATCH 2/2] Make webhook tests independent with isolated PRs and retry on merge timing issues 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. --- packages/spike/src/gitea/test/helpers.ts | 28 ++++++++++++--- .../spike/src/gitea/test/webhooks.test.ts | 35 ++++++++++++------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/spike/src/gitea/test/helpers.ts b/packages/spike/src/gitea/test/helpers.ts index 44805fa..098fbb0 100644 --- a/packages/spike/src/gitea/test/helpers.ts +++ b/packages/spike/src/gitea/test/helpers.ts @@ -228,10 +228,30 @@ export async function commentOnPR(repo: string, prNumber: number, body: string, } 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" }), - }) + // 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) { diff --git a/packages/spike/src/gitea/test/webhooks.test.ts b/packages/spike/src/gitea/test/webhooks.test.ts index 419c4ec..6d31975 100644 --- a/packages/spike/src/gitea/test/webhooks.test.ts +++ b/packages/spike/src/gitea/test/webhooks.test.ts @@ -4,7 +4,6 @@ setDefaultTimeout(30_000) import { ensure } from "@workshop/shared/utils" import { type User, - type TestPR, setupWebhooks, expectPullRequestWebhook, expectIssueCommentWebhook, @@ -15,6 +14,10 @@ import { 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`) @@ -37,20 +40,19 @@ 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 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) - return result }) }) 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 @@ -59,6 +61,8 @@ describe(`${coreyRepo}`, () => { }) 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 @@ -68,6 +72,8 @@ describe(`${coreyRepo}`, () => { }) 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 @@ -79,20 +85,19 @@ describe(`${coreyRepo}`, () => { // --- 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 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) - return result }) }) 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 @@ -101,6 +106,8 @@ describe(`${spikeRepo}`, () => { }) 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 @@ -110,6 +117,8 @@ describe(`${spikeRepo}`, () => { }) 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