Compare commits
No commits in common. "main" and "max-message-length" have entirely different histories.
main
...
max-messag
|
|
@ -32,4 +32,4 @@ import { something } from "@workshop/WHATEVER_YOU_WANT"
|
|||
|
||||
## How do I run the tests?
|
||||
|
||||
😂😂😂😂😂
|
||||
😂😂😂
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "workshop",
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# Spike
|
||||
|
||||
Discord-Gitea bridge bot. Syncs PRs, comments, and code reviews between a Gitea server (git.nose.space) and Discord.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── server/ — HTTP server, routes, and web pages (logs viewer, auth)
|
||||
├── config.ts — Dev/prod environment config (DB paths, channel IDs, username mappings)
|
||||
├── log.ts — Typed event logging (pub/sub, console + JSONL file listeners)
|
||||
├── 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.
|
||||
|
||||
- **[server/](src/server/README.md)** — HTTP server, webhook route, log viewer page, auth page.
|
||||
- **[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)
|
||||
|
|
@ -1,98 +1,14 @@
|
|||
# Spike
|
||||
|
||||
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.
|
||||
# Installation
|
||||
|
||||
## 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 (see below).
|
||||
|
||||
### Gitea webhook
|
||||
|
||||
Spike needs a webhook so Gitea sends PR and comment events to it. You have two options:
|
||||
|
||||
**System webhook (covers all repos)** — Requires Gitea admin access. Go to **Site Administration > System Webhooks > Add Webhook > Gitea**. Set the target URL to `https://spike.theworkshop.cc/gitea/webhook`. Under "Trigger On", select **Custom Events** and enable Pull Request, Issue Comment, and Pull Request Comment. This fires for every repo on the instance.
|
||||
|
||||
**Org/repo webhook (covers one org or repo)** — Go to the org or repo settings, then **Webhooks > Add Webhook > Gitea**. Same target URL and event configuration as above. Only fires for that org or repo.
|
||||
|
||||
### 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.
|
||||
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!**
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@
|
|||
"bot:cli": "bun run --watch src/cli",
|
||||
"bot:discord": "bun run --watch src/discord",
|
||||
"authServer": "bun run --watch src/discord/auth.ts",
|
||||
"subdomain:start": "bun run src/server/index.tsx",
|
||||
"subdomain:dev": "bun run --hot src/server/index.tsx",
|
||||
"test:unit": "bun test src/",
|
||||
"test:integration": "bun test --preload ./tests/setup.ts tests/",
|
||||
"test": "bun test src/ && bun test --preload ./tests/setup.ts tests/"
|
||||
"subdomain:start": "bun run src/server.tsx",
|
||||
"subdomain:dev": "bun run --hot src/server.tsx"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 110,
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# bridge/
|
||||
|
||||
Wiring between Gitea and Discord. Handles webhook events, maps IDs between platforms, and stores the mappings in SQLite.
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
```ts
|
||||
function handleGiteaWebhook(
|
||||
payload: unknown,
|
||||
eventType: "issue_comment" | "pull_request" | "pull_request_comment",
|
||||
): Promise<void>
|
||||
|
||||
function createPRComment(
|
||||
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
|
||||
body: string,
|
||||
discordMessageId: string,
|
||||
): Promise<{ id: number; body: string; user: { login: string }; created_at: string; updated_at: string }>
|
||||
|
||||
function convertDiscordMentionsToGitea(content: string): Promise<string>
|
||||
|
||||
function getPRByDiscordThreadId(discordThreadId: string):
|
||||
{ gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string } | null
|
||||
```
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { client } from "../discord"
|
||||
import { getPRByGiteaId, insertComment } from "./db"
|
||||
import { getConfig } from "../config"
|
||||
import { ChannelType, TextChannel } from "discord.js"
|
||||
import { convertUsername } from "../gitea"
|
||||
import { log } from "../log"
|
||||
|
||||
export async function getThread(pullRequest: { id: number; number: number }) {
|
||||
const row = getPRByGiteaId(pullRequest.id)
|
||||
if (!row) return
|
||||
|
||||
const thread = await client.channels.fetch(row.discord_thread_id)
|
||||
if (!thread) return
|
||||
|
||||
if (!thread.isThread()) {
|
||||
throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`)
|
||||
}
|
||||
|
||||
return thread
|
||||
}
|
||||
|
||||
export async function getWebhook() {
|
||||
const channel = await getChannel()
|
||||
const webhooks = await channel.fetchWebhooks()
|
||||
let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id)
|
||||
|
||||
if (!webhook) {
|
||||
webhook = await channel.createWebhook({
|
||||
name: "Gitea Bridge",
|
||||
reason: "Proxy Gitea comments to Discord",
|
||||
})
|
||||
}
|
||||
|
||||
return webhook
|
||||
}
|
||||
|
||||
export async function getDiscordAvatarUrl(discordUsername: string) {
|
||||
const discordUser = await getDiscordUser(discordUsername)
|
||||
return discordUser?.avatarURL() || undefined
|
||||
}
|
||||
|
||||
export async function getDiscordUser(discordUsername: string) {
|
||||
const cachedUser = client.users.cache.find((user) => user.username === discordUsername)
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const channel = await getChannel()
|
||||
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
|
||||
const member = members.first()
|
||||
return member?.user ?? undefined
|
||||
}
|
||||
|
||||
export async function convertDiscordMentionsToGitea(content: string): Promise<string> {
|
||||
const mentionRegex = /<@(\d+)>/g
|
||||
const mentions = [...content.matchAll(mentionRegex)]
|
||||
|
||||
let result = content
|
||||
for (const match of mentions) {
|
||||
const userId = match[1]!
|
||||
const user = await client.users.fetch(userId).catch((error) => {
|
||||
log({ type: "error", error, context: `fetch Discord user ${userId}` })
|
||||
return null
|
||||
})
|
||||
if (!user) continue
|
||||
|
||||
const giteaUsername = convertUsername({ discordUsername: user.displayName })
|
||||
result = result.replace(match[0], `@${giteaUsername}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// --- Gitea API call + DB insert (bridge between Discord messages and Gitea comments) ---
|
||||
|
||||
const giteaUrl = "https://git.nose.space"
|
||||
|
||||
type CreateCommentResponse = {
|
||||
id: number
|
||||
body: string
|
||||
user: { login: string }
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function createPRComment(
|
||||
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
|
||||
body: string,
|
||||
discordMessageId: string
|
||||
): Promise<CreateCommentResponse> {
|
||||
const [owner, repo] = prData.repo.split("/")
|
||||
if (!owner) {
|
||||
throw new Error(`Could not determine owner from repo string: ${prData.repo}`)
|
||||
}
|
||||
if (!repo) {
|
||||
throw new Error(`Could not determine repo from repo string: ${prData.repo}`)
|
||||
}
|
||||
|
||||
const url = `${giteaUrl}/api/v1/repos/${owner}/${repo}/issues/${prData.pr_number}/comments`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ body }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const comment = (await response.json()) as CreateCommentResponse
|
||||
|
||||
// Store the mapping immediately so webhook handler knows this came from Discord
|
||||
insertComment(comment.id, discordMessageId, prData.discord_thread_id)
|
||||
|
||||
return comment
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
let channel: TextChannel | undefined
|
||||
async function getChannel(): Promise<TextChannel> {
|
||||
if (channel) return channel
|
||||
|
||||
const channelId = getConfig("channelId")
|
||||
const foundChannel = await client.channels.fetch(channelId)
|
||||
if (foundChannel?.type !== ChannelType.GuildText) {
|
||||
throw new Error(
|
||||
`Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)`
|
||||
)
|
||||
}
|
||||
|
||||
channel = foundChannel as TextChannel
|
||||
return channel
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { handleGiteaWebhook } from "./webhook-handler"
|
||||
export { createPRComment, convertDiscordMentionsToGitea } from "./discord-helpers"
|
||||
export { getPRByDiscordThreadId } from "./db"
|
||||
|
|
@ -1,36 +1,33 @@
|
|||
type Config = {
|
||||
dbPath: string
|
||||
channelId: string
|
||||
discordClientId: string
|
||||
dataDir: string
|
||||
giteaToDiscordUserMappings: Record<string, string>
|
||||
export const getConfig = <K extends keyof typeof config>(key: K) => {
|
||||
const env = process.env.NODE_ENV === "production" ? "prod" : "dev"
|
||||
const value = config[key][env]
|
||||
return value as (typeof config)[K]["dev"]
|
||||
}
|
||||
|
||||
const devConfig: Config = {
|
||||
dbPath: ".//local-spike.db",
|
||||
channelId: "1480720354325561505",
|
||||
discordClientId: "1384271480119885977",
|
||||
dataDir: "/Users/corey/code/tmp/data",
|
||||
const config = {
|
||||
dbPath: {
|
||||
dev: ".//local-spike.db",
|
||||
prod: `${process.env.DATA_DIR}/spike.db`,
|
||||
},
|
||||
channelId: {
|
||||
dev: "1384275245174620370",
|
||||
prod: "1436392275696554055",
|
||||
},
|
||||
discordClientId: {
|
||||
dev: "1384271480119885977",
|
||||
prod: "1382067546651365416",
|
||||
},
|
||||
dataDir: {
|
||||
dev: "/Users/corey/code/tmp/data",
|
||||
prod: "/var/data",
|
||||
},
|
||||
giteaToDiscordUserMappings: {
|
||||
probablycorey: "corey",
|
||||
Spike: "Spike",
|
||||
dev: {
|
||||
probablycorey: "corey",
|
||||
} as Record<string, string>,
|
||||
prod: {
|
||||
probablycorey: "corey",
|
||||
defunkt: "defunkt",
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}
|
||||
|
||||
const prodConfig: Config = {
|
||||
dbPath: `${process.env.DATA_DIR}/spike.db`,
|
||||
channelId: "1436392275696554055",
|
||||
discordClientId: "1382067546651365416",
|
||||
dataDir: "/var/data",
|
||||
giteaToDiscordUserMappings: {
|
||||
probablycorey: "corey",
|
||||
defunkt: "defunkt",
|
||||
Spike: "Spike",
|
||||
},
|
||||
}
|
||||
|
||||
const config = process.env.NODE_ENV === "production" ? prodConfig : devConfig
|
||||
|
||||
export function getConfig<K extends keyof Config>(key: K) {
|
||||
return config[key]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { Database } from "bun:sqlite"
|
||||
import { getConfig } from "../config"
|
||||
import { log } from "../log"
|
||||
import { getConfig } from "./config"
|
||||
|
||||
const dbPath = getConfig("dbPath")
|
||||
export const db = new Database(dbPath)
|
||||
log({ type: "startup", detail: `Database opened at ${dbPath}` })
|
||||
|
||||
// Create tables if they don't exist
|
||||
db.run(`
|
||||
|
|
@ -25,14 +23,16 @@ db.run(`
|
|||
);
|
||||
`)
|
||||
|
||||
console.log(`📊 Database initialized at ${dbPath}`)
|
||||
|
||||
// PR operations
|
||||
export function insertPR(
|
||||
export const 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,25 +40,30 @@ export function insertPR(
|
|||
)
|
||||
}
|
||||
|
||||
export function getPRByGiteaId(giteaPrId: number) {
|
||||
return db
|
||||
export const getPRByGiteaId = (giteaPrId: number) => {
|
||||
const row = 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 function getPRByDiscordThreadId(discordThreadId: string) {
|
||||
return db
|
||||
export const getPRByDiscordThreadId = (discordThreadId: string) => {
|
||||
const row = 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 function insertComment(giteaCommentId: number, discordMessageId: string, discordThreadId: string) {
|
||||
export const insertComment = (giteaCommentId: number, discordMessageId: string, discordThreadId: string) => {
|
||||
db.run(
|
||||
`INSERT INTO comments (gitea_comment_id, discord_message_id, discord_thread_id)
|
||||
VALUES (?, ?, ?)`,
|
||||
|
|
@ -66,10 +71,12 @@ export function insertComment(giteaCommentId: number, discordMessageId: string,
|
|||
)
|
||||
}
|
||||
|
||||
export function getCommentByGiteaId(giteaCommentId: number) {
|
||||
return db
|
||||
export const getCommentByGiteaId = (giteaCommentId: number) => {
|
||||
const row = 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
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# 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 Discord bot client instance. Not logged in until startDiscord() is called.
|
||||
const client: Client
|
||||
|
||||
// Logs in the bot, registers event listeners and slash commands, sets up crash logging.
|
||||
async function startDiscord(): Promise<void>
|
||||
```
|
||||
|
|
@ -6,7 +6,6 @@ import {
|
|||
type Interaction,
|
||||
type SlashCommandOptionsOnlyBuilder,
|
||||
} from "discord.js"
|
||||
import { log } from "../log"
|
||||
|
||||
export const runCommand = async (interaction: Interaction<CacheType>) => {
|
||||
if (!interaction.isChatInputCommand()) return
|
||||
|
|
@ -17,9 +16,10 @@ export const runCommand = async (interaction: Interaction<CacheType>) => {
|
|||
try {
|
||||
await command.execute(interaction)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
log({ type: "discord-command-error", command: interaction.commandName, error: errorMessage })
|
||||
const content = `❌ Error executing command ${interaction.commandName}: ${errorMessage}`
|
||||
const content = `❌ Error executing command ${interaction.commandName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
console.error(content)
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply({ content })
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
import { mkdirSync } from "node:fs"
|
||||
import { getConfig } from "../config"
|
||||
import { log } from "../log"
|
||||
|
||||
const crashLogDir = getConfig("dataDir")
|
||||
mkdirSync(crashLogDir, { recursive: true })
|
||||
|
||||
const crashLogPath = `${crashLogDir}/crash.log`
|
||||
|
||||
export const logCrash = async (error: unknown) => {
|
||||
try {
|
||||
const stack = error instanceof Error ? error.stack : ""
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
const crashLog = `Spike crashed at ${new Date().toISOString()}:\n${message}\n${stack ?? ""}\n`
|
||||
console.error(crashLog)
|
||||
|
||||
// overwrite the crash log file
|
||||
const file = Bun.file(crashLogPath)
|
||||
const file = Bun.file(`${process.env.DATA_DIR}/crash.log`)
|
||||
file.write(crashLog)
|
||||
} catch (writeError) {
|
||||
log({ type: "error", error: writeError, context: "writing crash log" })
|
||||
} catch (error) {
|
||||
console.error("Failed to write crash log:", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,20 +19,20 @@ export const alertAboutCrashLog = async (client: any) => {
|
|||
const channelId = process.env.CHANNEL_ID ?? "1382121375619223594"
|
||||
const crashLog = await clearCrashLog()
|
||||
if (crashLog) {
|
||||
log({ type: "crash-log-found" })
|
||||
console.warn("⚠️ Previous crash log found:")
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (channel?.isSendable()) {
|
||||
channel.send(`⚠️ Previous crash log found:\n\`\`\`${crashLog}\`\`\``)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log({ type: "error", error, context: "alerting about crash log" })
|
||||
console.error("Failed to alert about crash log:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearCrashLog = async () => {
|
||||
try {
|
||||
const file = Bun.file(crashLogPath)
|
||||
const file = Bun.file(`${process.env.DATA_DIR}/crash.log`)
|
||||
if (!(await file.exists())) return
|
||||
const contents = await file.text()
|
||||
await file.write("")
|
||||
|
|
@ -49,6 +41,6 @@ const clearCrashLog = async () => {
|
|||
return contents
|
||||
}
|
||||
} catch (error) {
|
||||
log({ type: "error", error, context: "reading crash log" })
|
||||
console.error("Failed to read crash log:", error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { ActivityType, type Client } from "discord.js"
|
||||
import { runCommand } from "./commands"
|
||||
import { createPRComment, convertDiscordMentionsToGitea, getPRByDiscordThreadId } from "../bridge"
|
||||
import { convertUsername } from "../gitea"
|
||||
import { log } from "../log"
|
||||
import { getPRByDiscordThreadId } from "../db"
|
||||
import { createPRComment } from "../gitea/api"
|
||||
import { convertUsername, convertDiscordMentionsToGitea } from "../gitea/helpers"
|
||||
|
||||
export const listenForEvents = (client: Client) => {
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
|
|
@ -20,7 +20,6 @@ export const listenForEvents = (client: Client) => {
|
|||
const messageContent = await convertDiscordMentionsToGitea(msg.content)
|
||||
const message = `**${username}**: ${messageContent}`
|
||||
await createPRComment(prData, message, msg.id)
|
||||
log({ type: "discord-relay", pr: prData.pr_number, repo: prData.repo, user: msg.author.displayName })
|
||||
}
|
||||
|
||||
// if it is a dm always respond
|
||||
|
|
@ -30,13 +29,12 @@ export const listenForEvents = (client: Client) => {
|
|||
// await msg.channel.send(`You said: ${msg.content}`)
|
||||
}
|
||||
} catch (error) {
|
||||
log({ type: "error", error, context: "messageCreate" })
|
||||
console.error("Error handling messageCreate event:", error)
|
||||
msg.channel.send("An error occurred 💥.")
|
||||
}
|
||||
})
|
||||
|
||||
client.on("ready", () => {
|
||||
log({ type: "discord-ready" })
|
||||
// set the bots description
|
||||
const branch = process.env.RENDER_GIT_BRANCH || "unknown"
|
||||
const commit = process.env.RENDER_GIT_COMMIT?.slice(0, 7) || "deadbeef"
|
||||
|
|
@ -48,10 +46,10 @@ export const listenForEvents = (client: Client) => {
|
|||
})
|
||||
|
||||
client.on("error", (error) => {
|
||||
log({ type: "error", error, context: "discord client" })
|
||||
console.error("Discord client error:", error)
|
||||
})
|
||||
|
||||
client.on("warn", (info) => {
|
||||
log({ type: "discord-warning", detail: info })
|
||||
console.warn("Discord client warning:", info)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Client, GatewayIntentBits, Partials } from "discord.js"
|
|||
import { listenForEvents } from "./events"
|
||||
import { alertAboutCrashLog, logCrash } from "./crash"
|
||||
import { registerCommands } from "./commands"
|
||||
import { log } from "../log"
|
||||
|
||||
export const client = new Client({
|
||||
intents: [
|
||||
|
|
@ -16,22 +15,20 @@ export const client = new Client({
|
|||
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
|
||||
})
|
||||
|
||||
export async function startDiscord() {
|
||||
await client.login(process.env.DISCORD_TOKEN)
|
||||
await client.login(process.env.DISCORD_TOKEN)
|
||||
|
||||
listenForEvents(client)
|
||||
await registerCommands(client)
|
||||
listenForEvents(client)
|
||||
await registerCommands(client)
|
||||
|
||||
process.on("unhandledRejection", async (error) => {
|
||||
log({ type: "error", error, context: "unhandled rejection" })
|
||||
await logCrash(error)
|
||||
})
|
||||
process.on("unhandledRejection", async (error) => {
|
||||
console.error("💥 Unhandled promise rejection:", error)
|
||||
await logCrash(error)
|
||||
})
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
log({ type: "error", error, context: "uncaught exception" })
|
||||
await logCrash(error)
|
||||
})
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error("💥 Uncaught exception:", error)
|
||||
await logCrash(error)
|
||||
})
|
||||
|
||||
await alertAboutCrashLog(client)
|
||||
// startAuthServer() this is handy if you make a new bot
|
||||
}
|
||||
await alertAboutCrashLog(client)
|
||||
// startAuthServer() this is handy if you make a new bot
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# gitea/
|
||||
|
||||
Pure Gitea API client and types. No Discord or database dependencies.
|
||||
|
||||
## Barrel Exports
|
||||
|
||||
```ts
|
||||
function fetchPR(fullname: string, prNumber: number): Promise<Gitea.PullRequest>
|
||||
|
||||
function fetchReviewComments(fullname: string, prNumber: number): Promise<Gitea.ReviewComment[]>
|
||||
|
||||
function convertUsername(opts: { discordUsername: string } | { giteaUsername: string }): string
|
||||
|
||||
function threadName(pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string
|
||||
|
||||
namespace Gitea { }
|
||||
```
|
||||
|
|
@ -1,42 +1,82 @@
|
|||
import { log } from "../log"
|
||||
import type { Gitea } from "./types"
|
||||
import { insertComment } from "../db"
|
||||
import { type Gitea } from "./types"
|
||||
|
||||
const giteaUrl = "https://git.nose.space"
|
||||
|
||||
function tokenPrefix(): string {
|
||||
const token = process.env.GITEA_API_TOKEN
|
||||
if (!token) return "MISSING"
|
||||
return `${token.slice(0, 4)}...${token.slice(-4)} (${token.length} chars)`
|
||||
type CreateCommentResponse = {
|
||||
id: number
|
||||
body: string
|
||||
user: {
|
||||
login: string
|
||||
}
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
async function giteaFetch(url: string): Promise<Response> {
|
||||
export const createPRComment = async (
|
||||
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
|
||||
body: string,
|
||||
discordMessageId: string
|
||||
): Promise<CreateCommentResponse> => {
|
||||
const [owner, repo] = prData.repo.split("/")
|
||||
if (!owner) {
|
||||
throw new Error(`Could not determine owner from repo string: ${prData.repo}`)
|
||||
}
|
||||
if (!repo) {
|
||||
throw new Error(`Could not determine repo from repo string: ${prData.repo}`)
|
||||
}
|
||||
|
||||
const url = `${giteaUrl}/api/v1/repos/${owner}/${repo}/issues/${prData.pr_number}/comments`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ body }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const comment = (await response.json()) as CreateCommentResponse
|
||||
|
||||
// Store the mapping immediately so webhook handler knows this came from Discord
|
||||
insertComment(comment.id, discordMessageId, prData.discord_thread_id)
|
||||
|
||||
return comment
|
||||
}
|
||||
|
||||
export const fetchPR = async (fullname: string, prNumber: number): Promise<Gitea.PullRequest> => {
|
||||
const url = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}`
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
log({ type: "gitea-api-error", status: response.status, url, body: body.slice(0, 500), tokenPrefix: tokenPrefix() })
|
||||
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function fetchPR(fullname: string, prNumber: number): Promise<Gitea.PullRequest> {
|
||||
const url = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}`
|
||||
const response = await giteaFetch(url)
|
||||
return response.json() as Promise<Gitea.PullRequest>
|
||||
}
|
||||
|
||||
export async function fetchReviewComments(
|
||||
export const fetchReviewComments = async (
|
||||
fullname: string,
|
||||
prNumber: number
|
||||
): Promise<Gitea.ReviewComment[]> {
|
||||
): Promise<Gitea.ReviewComment[]> => {
|
||||
// First, fetch all reviews
|
||||
const reviewsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews`
|
||||
const reviewsResponse = await giteaFetch(reviewsUrl)
|
||||
const reviewsResponse = await fetch(reviewsUrl, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
if (!reviewsResponse.ok) {
|
||||
throw new Error(`Gitea API error: ${reviewsResponse.status} ${reviewsResponse.statusText}`)
|
||||
}
|
||||
|
||||
const reviews = (await reviewsResponse.json()) as Array<{
|
||||
id: number
|
||||
|
|
@ -47,14 +87,22 @@ export async function fetchReviewComments(
|
|||
comments_count: number
|
||||
}>
|
||||
|
||||
// For each review, fetch its comments
|
||||
const allComments: Gitea.ReviewComment[] = []
|
||||
for (const review of reviews) {
|
||||
if (review.comments_count === 0) continue
|
||||
|
||||
const commentsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews/${review.id}/comments`
|
||||
const commentsResponse = await giteaFetch(commentsUrl)
|
||||
const comments = (await commentsResponse.json()) as Gitea.ReviewComment[]
|
||||
allComments.push(...comments)
|
||||
const commentsResponse = await fetch(commentsUrl, {
|
||||
headers: {
|
||||
Authorization: `token ${process.env.GITEA_API_TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (commentsResponse.ok) {
|
||||
const comments = (await commentsResponse.json()) as Gitea.ReviewComment[]
|
||||
allComments.push(...comments)
|
||||
}
|
||||
}
|
||||
|
||||
return allComments
|
||||
|
|
|
|||
149
packages/spike/src/gitea/helpers.ts
Normal file
149
packages/spike/src/gitea/helpers.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { client } from "../discord/index"
|
||||
import { getPRByGiteaId } from "../db"
|
||||
import { getConfig } from "../config"
|
||||
import { ChannelType, TextChannel } from "discord.js"
|
||||
import { assertNever } from "@workshop/shared/utils"
|
||||
import { fetchPR } from "./api"
|
||||
import type { Gitea } from "./types"
|
||||
import { PRHandler } from "./webhook-handler"
|
||||
|
||||
export const ensurePRCreatedOnDiscord = async (
|
||||
pullRequest: Gitea.PullRequest | Gitea.Issue,
|
||||
repository: Gitea.Repository
|
||||
) => {
|
||||
const prNumber = pullRequest.number
|
||||
|
||||
const exists = getPRByGiteaId(pullRequest.id)
|
||||
if (exists) return true
|
||||
|
||||
const pr = await fetchPR(repository.full_name, prNumber)
|
||||
await PRHandler.handleOpened(pr, repository)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => {
|
||||
const row = getPRByGiteaId(pullRequest.id)
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
|
||||
const thread = await client.channels.fetch(row.discord_thread_id)
|
||||
if (!thread) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!thread.isThread()) {
|
||||
throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`)
|
||||
}
|
||||
|
||||
return thread
|
||||
}
|
||||
|
||||
type PullRequestState = "closed" | "merged" | "opened"
|
||||
const prState = (pullRequest: Gitea.PullRequest): { icon: string; label: string } => {
|
||||
let state: PullRequestState = "opened"
|
||||
if (pullRequest.merged) {
|
||||
state = "merged"
|
||||
} else if (pullRequest.state === "closed") {
|
||||
state = "closed"
|
||||
}
|
||||
|
||||
let stateInfo: { icon: string; label: string }
|
||||
if (state === "merged") {
|
||||
stateInfo = { icon: "🟣", label: "merged" }
|
||||
} else if (state === "closed") {
|
||||
stateInfo = { icon: "🔴", label: "closed" }
|
||||
} else if (state === "opened") {
|
||||
stateInfo = { icon: "🟢", label: "opened" }
|
||||
} else {
|
||||
assertNever(state, `Unhandled pull request state: ${state}`)
|
||||
}
|
||||
return stateInfo
|
||||
}
|
||||
|
||||
export const threadName = (pullRequest: Gitea.PullRequest, repository: Gitea.Repository): string => {
|
||||
const stateInfo = prState(pullRequest)
|
||||
const name = `${stateInfo.icon} [${repository.name}] ${pullRequest.title}`
|
||||
return name.slice(0, 100)
|
||||
}
|
||||
|
||||
export const getWebhook = async () => {
|
||||
const channel = await getChannel()
|
||||
const webhooks = await channel.fetchWebhooks()
|
||||
let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id)
|
||||
|
||||
if (!webhook) {
|
||||
webhook = await channel.createWebhook({
|
||||
name: "Gitea Bridge",
|
||||
reason: "Proxy Gitea comments to Discord",
|
||||
})
|
||||
}
|
||||
|
||||
return webhook
|
||||
}
|
||||
|
||||
export const getDiscordAvatarUrl = async (discordUsername: string) => {
|
||||
const discordUser = await getDiscordUser(discordUsername)
|
||||
return discordUser?.avatarURL() || undefined
|
||||
}
|
||||
|
||||
export const convertUsername = (opts: { discordUsername: string } | { giteaUsername: string }): string => {
|
||||
const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings")
|
||||
let convertedUsername: string | undefined
|
||||
let username: string
|
||||
if ("giteaUsername" in opts) {
|
||||
username = opts.giteaUsername
|
||||
convertedUsername = giteaToDiscordUserMappings[username]
|
||||
} else {
|
||||
username = opts.discordUsername
|
||||
const entries = Object.entries(giteaToDiscordUserMappings)
|
||||
const result = entries.find(([_, discordUsername]) => discordUsername === username)
|
||||
if (result) convertedUsername = result[0]
|
||||
}
|
||||
|
||||
return convertedUsername || `💥${username}💥`
|
||||
}
|
||||
|
||||
export const getDiscordUser = async (discordUsername: string) => {
|
||||
const cachedUser = client.users.cache.find((user) => user.username === discordUsername)
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const channel = await getChannel()
|
||||
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
|
||||
const member = members.first()
|
||||
return member?.user ?? undefined
|
||||
}
|
||||
|
||||
export const convertDiscordMentionsToGitea = async (content: string): Promise<string> => {
|
||||
const mentionRegex = /<@(\d+)>/g
|
||||
const mentions = [...content.matchAll(mentionRegex)]
|
||||
|
||||
let result = content
|
||||
for (const match of mentions) {
|
||||
const userId = match[1]!
|
||||
const user = await client.users.fetch(userId).catch(() => null)
|
||||
if (!user) continue
|
||||
|
||||
const giteaUsername = convertUsername({ discordUsername: user.displayName })
|
||||
result = result.replace(match[0], `@${giteaUsername}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let channel: TextChannel | null = null
|
||||
const getChannel = async (): Promise<TextChannel> => {
|
||||
if (channel) return channel
|
||||
|
||||
const channelId = getConfig("channelId")
|
||||
const foundChannel = await client.channels.fetch(channelId)
|
||||
if (foundChannel?.type !== ChannelType.GuildText) {
|
||||
throw new Error(
|
||||
`Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)`
|
||||
)
|
||||
}
|
||||
|
||||
channel = foundChannel as TextChannel
|
||||
return channel
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { fetchPR, fetchReviewComments } from "./api"
|
||||
export { convertUsername, threadName } from "./utils"
|
||||
export type { Gitea } from "./types"
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import { convertUsername, threadName } from "./utils"
|
||||
import type { Gitea } from "./types"
|
||||
|
||||
describe("convertUsername", () => {
|
||||
test("converts gitea username to discord username", () => {
|
||||
const result = convertUsername({ giteaUsername: "probablycorey" })
|
||||
expect(result).toBe("corey")
|
||||
})
|
||||
|
||||
test("converts discord username to gitea username", () => {
|
||||
const result = convertUsername({ discordUsername: "corey" })
|
||||
expect(result).toBe("probablycorey")
|
||||
})
|
||||
|
||||
test("returns fallback with 💥 for unknown gitea username", () => {
|
||||
const result = convertUsername({ giteaUsername: "nobody" })
|
||||
expect(result).toBe("💥nobody💥")
|
||||
})
|
||||
|
||||
test("returns fallback with 💥 for unknown discord username", () => {
|
||||
const result = convertUsername({ discordUsername: "nobody" })
|
||||
expect(result).toBe("💥nobody💥")
|
||||
})
|
||||
})
|
||||
|
||||
function fakePR(overrides: Partial<Gitea.PullRequest> = {}): Gitea.PullRequest {
|
||||
return {
|
||||
id: 1,
|
||||
number: 42,
|
||||
title: "Add feature",
|
||||
body: "",
|
||||
user: { id: 1, login: "test", full_name: "Test", avatar_url: "" },
|
||||
html_url: "",
|
||||
state: "open",
|
||||
merged: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
draft: false,
|
||||
base: {},
|
||||
head: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const fakeRepo: Gitea.Repository = {
|
||||
id: 1,
|
||||
name: "my-repo",
|
||||
full_name: "user/my-repo",
|
||||
owner: { id: 1, login: "user", full_name: "User", avatar_url: "" },
|
||||
html_url: "",
|
||||
private: false,
|
||||
}
|
||||
|
||||
describe("threadName", () => {
|
||||
test("formats open PR", () => {
|
||||
const result = threadName(fakePR(), fakeRepo)
|
||||
expect(result).toBe("🟢 [my-repo] Add feature")
|
||||
})
|
||||
|
||||
test("formats merged PR", () => {
|
||||
const result = threadName(fakePR({ merged: true, state: "closed" }), fakeRepo)
|
||||
expect(result).toBe("🟣 [my-repo] Add feature")
|
||||
})
|
||||
|
||||
test("formats closed (not merged) PR", () => {
|
||||
const result = threadName(fakePR({ state: "closed" }), fakeRepo)
|
||||
expect(result).toBe("🔴 [my-repo] Add feature")
|
||||
})
|
||||
|
||||
test("truncates to 100 characters", () => {
|
||||
const longTitle = "A".repeat(200)
|
||||
const result = threadName(fakePR({ title: longTitle }), fakeRepo)
|
||||
expect(result.length).toBe(100)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,15 +1,22 @@
|
|||
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 { insertPR, insertComment, getCommentByGiteaId } from "../db"
|
||||
import {
|
||||
ensurePRCreatedOnDiscord as ensureThreadExists,
|
||||
getDiscordUser,
|
||||
getWebhook,
|
||||
getThread,
|
||||
getDiscordAvatarUrl,
|
||||
convertUsername,
|
||||
threadName,
|
||||
} from "./helpers"
|
||||
import type { Gitea } from "./types"
|
||||
import { getConfig } from "../config"
|
||||
import { log } from "../log"
|
||||
import { fetchReviewComments } from "./api"
|
||||
|
||||
type EventType = "issue_comment" | "pull_request" | "pull_request_comment"
|
||||
export async function handleGiteaWebhook(payload: unknown, eventType: EventType) {
|
||||
export const handleGiteaWebhook = async (payload: unknown, eventType: EventType) => {
|
||||
// console.log(`🌭`, JSON.stringify(payload, null, 2))
|
||||
|
||||
if (ignorePayload(payload)) {
|
||||
const repo = (payload as any)?.repository?.full_name || "unknown"
|
||||
log({ type: "webhook-ignored", eventType, repo })
|
||||
return
|
||||
} else if (PRHandler.canHandle(payload, eventType)) {
|
||||
await PRHandler.handle(payload)
|
||||
|
|
@ -20,31 +27,17 @@ export async function handleGiteaWebhook(payload: unknown, eventType: EventType)
|
|||
}
|
||||
}
|
||||
|
||||
const TEST_REPOS = ["ignore-me", "ignore-me-too"]
|
||||
|
||||
function ignorePayload(payload: any) {
|
||||
const ignorePayload = (payload: any) => {
|
||||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const repositoryName = payload?.repository?.name
|
||||
if (isDev) {
|
||||
return !TEST_REPOS.includes(repositoryName)
|
||||
return repositoryName !== "ignore-me"
|
||||
} else {
|
||||
return TEST_REPOS.includes(repositoryName)
|
||||
return repositoryName === "ignore-me"
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureThreadExists(
|
||||
pullRequest: Gitea.PullRequest | Gitea.Issue,
|
||||
repository: Gitea.Repository
|
||||
) {
|
||||
const exists = getPRByGiteaId(pullRequest.id)
|
||||
if (exists) return
|
||||
|
||||
log({ type: "thread-auto-created", pr: pullRequest.number, repo: repository.full_name })
|
||||
const pr = await fetchPR(repository.full_name, pullRequest.number)
|
||||
await PRHandler.handleOpened(pr, repository)
|
||||
}
|
||||
|
||||
class PRHandler {
|
||||
export class PRHandler {
|
||||
static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestWebhook {
|
||||
return eventType === "pull_request"
|
||||
}
|
||||
|
|
@ -60,7 +53,7 @@ class PRHandler {
|
|||
await this.handleStateChange(pullRequest, repository)
|
||||
}
|
||||
|
||||
log({ type: "pr", action, pr: payload.number, repo: repository.full_name, user: pullRequest.user.login, title: pullRequest.title })
|
||||
console.log(`✅ pull request webhook action: ${action} #${payload.number}`)
|
||||
}
|
||||
|
||||
static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
|
||||
|
|
@ -112,16 +105,12 @@ class PRHandler {
|
|||
}
|
||||
|
||||
static formatPrBody(pullRequest: Gitea.PullRequest, repositoryFullName: string): string {
|
||||
// Strip the "🤖 Generated with Claude Code" footer that gets appended to PR bodies
|
||||
const claudeFooter = /\n*🤖.*Claude Code[^\n]*$/
|
||||
const body = (pullRequest.body || "_empty_")
|
||||
.replace(claudeFooter, "")
|
||||
.trim() || "_empty_"
|
||||
const body = pullRequest.body || "_empty_"
|
||||
let message = `
|
||||
> ### [${pullRequest.title}](<${pullRequest.html_url}>)
|
||||
> **${repositoryFullName}**
|
||||
>
|
||||
${body
|
||||
>
|
||||
${body
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}
|
||||
|
|
@ -148,23 +137,22 @@ class CommentHandler {
|
|||
|
||||
if (action === "created") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleCreated(issue, comment, repository.full_name)
|
||||
await this.handleCreated(issue, comment)
|
||||
} else if (action === "edited") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleEdited(issue, comment, repository.full_name)
|
||||
await this.handleEdited(issue, comment)
|
||||
} else if (action === "deleted") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleDeleted(comment)
|
||||
}
|
||||
|
||||
log({ type: "comment", action, pr: issue.number, repo: repository.full_name, user: comment.user.login, body: comment.body.slice(0, 200) })
|
||||
console.log(`✅ comment webhook action: ${action} on #${issue.number}`)
|
||||
}
|
||||
|
||||
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) {
|
||||
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment) {
|
||||
const commentRow = getCommentByGiteaId(comment.id)
|
||||
if (commentRow) {
|
||||
log({ type: "comment-skipped", pr: issue.number, repo, commentId: comment.id })
|
||||
return
|
||||
return // Comment already exists, skip
|
||||
}
|
||||
|
||||
const discordUsername = convertUsername({ giteaUsername: comment.user.login })
|
||||
|
|
@ -184,10 +172,10 @@ class CommentHandler {
|
|||
insertComment(comment.id, message.id, thread.id)
|
||||
}
|
||||
|
||||
static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment, repo: string) {
|
||||
static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment) {
|
||||
const commentRow = getCommentByGiteaId(comment.id)
|
||||
if (!commentRow) {
|
||||
this.handleCreated(issue, comment, repo)
|
||||
this.handleCreated(issue, comment)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +234,7 @@ class ReviewHandler {
|
|||
|
||||
// Only handle new reviews, ignore edits
|
||||
if (action !== "reviewed") {
|
||||
log({ type: "review", action, pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login })
|
||||
console.log(`✅ review webhook action: ${action} on #${pullRequest.number} (ignored)`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +283,7 @@ class ReviewHandler {
|
|||
insertComment(comment.id, message.id, thread.id)
|
||||
}
|
||||
|
||||
log({ type: "review", action: "reviewed", pr: pullRequest.number, repo: repository.full_name, user: payload.sender.login })
|
||||
console.log(`✅ review webhook action: reviewed on #${pullRequest.number}`)
|
||||
}
|
||||
|
||||
static async formatReviewComment(comment: Gitea.ReviewComment): Promise<string> {
|
||||
|
|
@ -312,8 +300,16 @@ ${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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { basename } from "node:path"
|
||||
import { getConfig } from "./config"
|
||||
|
||||
export type LogEvent =
|
||||
| { type: "webhook"; eventType: string; repo: string; sender?: string; ref?: string }
|
||||
| { type: "webhook-ignored"; eventType: string; repo: string }
|
||||
| { type: "pr"; action: string; pr: number; repo: string; user: string; title?: string }
|
||||
| { type: "comment"; action: string; pr: number; repo: string; user: string; body?: string }
|
||||
| { type: "comment-skipped"; pr: number; repo: string; commentId: number }
|
||||
| { type: "review"; action: string; pr: number; repo: string; user: string }
|
||||
| { type: "thread-auto-created"; pr: number; repo: string }
|
||||
| { type: "discord-relay"; pr: number; repo: string; user: string }
|
||||
| { type: "discord-command-error"; command: string; error: string }
|
||||
| { type: "discord-warning"; detail: string }
|
||||
| { type: "crash-log-found" }
|
||||
| { type: "startup"; detail: string }
|
||||
| { type: "discord-ready" }
|
||||
| { type: "gitea-api-error"; status: number; url: string; body: string; tokenPrefix: string }
|
||||
| { type: "error"; error: unknown; context?: string }
|
||||
|
||||
export type StoredLogEvent = LogEvent & { ts: string }
|
||||
|
||||
type Listener = (event: StoredLogEvent) => void
|
||||
const listeners: Listener[] = []
|
||||
|
||||
const releaseSha = process.env.RENDER_GIT_COMMIT?.slice(0, 7) || "dev"
|
||||
|
||||
export function log(event: LogEvent) {
|
||||
const stored = { ...event, ts: new Date().toISOString() } as StoredLogEvent
|
||||
for (const listener of listeners) {
|
||||
listener(stored)
|
||||
}
|
||||
}
|
||||
|
||||
export function onLog(listener: Listener) {
|
||||
listeners.push(listener)
|
||||
return () => {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index !== -1) listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Log files ---
|
||||
|
||||
const logsDir = `${getConfig("dataDir")}/spike/logs`
|
||||
mkdirSync(logsDir, { recursive: true })
|
||||
|
||||
function createLogFile(): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
return `${logsDir}/${timestamp}_${releaseSha}.jsonl`
|
||||
}
|
||||
|
||||
function extractTimestamp(filename: string): string {
|
||||
const stem = filename.replace(".jsonl", "")
|
||||
// New format: timestamp_sha, Old format: sha_timestamp
|
||||
if (/^\d{4}-/.test(stem)) return stem.split("_").slice(0, -1).join("_")
|
||||
return stem.split("_").pop() || ""
|
||||
}
|
||||
|
||||
export function listLogFiles(): string[] {
|
||||
return readdirSync(logsDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort((a, b) => extractTimestamp(b).localeCompare(extractTimestamp(a)))
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
export function readLogFile(filename: string): StoredLogEvent[] {
|
||||
const raw = readLogFileRaw(filename)
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as StoredLogEvent)
|
||||
}
|
||||
|
||||
export function readLogFileRaw(filename: string): string | undefined {
|
||||
if (filename !== basename(filename)) throw new Error("Invalid filename")
|
||||
const path = `${logsDir}/${filename}`
|
||||
try {
|
||||
return readFileSync(path, "utf-8")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// --- Built-in listeners ---
|
||||
|
||||
// Console
|
||||
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`
|
||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`
|
||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`
|
||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`
|
||||
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`
|
||||
|
||||
onLog((event) => {
|
||||
const time = dim(event.ts.slice(11, 19))
|
||||
|
||||
if (event.type === "error") {
|
||||
const context = event.context ? ` ${event.context}` : ""
|
||||
console.error(`${time} ${red("error")}${context}`, event.error)
|
||||
return
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if ("action" in event) parts.push(event.action)
|
||||
if ("repo" in event) parts.push(dim(event.repo))
|
||||
if ("pr" in event) parts.push(cyan(`#${event.pr}`))
|
||||
if ("user" in event) parts.push(dim(event.user))
|
||||
if ("sender" in event && event.sender) parts.push(dim(event.sender))
|
||||
if ("ref" in event && event.ref) parts.push(dim(event.ref))
|
||||
if ("title" in event && event.title) parts.push(event.title)
|
||||
if ("detail" in event) parts.push(event.detail)
|
||||
if ("command" in event) parts.push(event.command)
|
||||
if ("eventType" in event) parts.push(event.eventType)
|
||||
|
||||
const isSkipped = event.type === "webhook-ignored" || event.type === "comment-skipped"
|
||||
const typeColor = isSkipped ? yellow : green
|
||||
console.log(`${time} ${typeColor(event.type)} ${parts.join(" ")}`)
|
||||
})
|
||||
|
||||
// JSONL file
|
||||
const currentLogFile = createLogFile()
|
||||
|
||||
function serializeEvent(event: StoredLogEvent): string {
|
||||
if (event.type === "error") {
|
||||
const error = event.error instanceof Error
|
||||
? { message: event.error.message, stack: event.error.stack }
|
||||
: event.error
|
||||
return JSON.stringify({ ...event, error })
|
||||
}
|
||||
return JSON.stringify(event)
|
||||
}
|
||||
|
||||
onLog((event) => {
|
||||
appendFileSync(currentLogFile, serializeEvent(event) + "\n")
|
||||
})
|
||||
|
|
@ -1,10 +1,16 @@
|
|||
import { serve } from "bun"
|
||||
import { handleGiteaWebhook } from "../bridge"
|
||||
import { startDiscord } from "../discord"
|
||||
import { listLogFiles, log, readLogFileRaw } from "../log"
|
||||
import { LogsPage } from "./logs"
|
||||
import { handleGiteaWebhook } from "./gitea/webhook-handler"
|
||||
import "./discord/index" // Make suer the discord client is initialized
|
||||
import { getConfig } from "./config"
|
||||
|
||||
await startDiscord()
|
||||
interface ErrorLog {
|
||||
timestamp: string
|
||||
message: string
|
||||
stack?: string
|
||||
payload: any
|
||||
}
|
||||
|
||||
const errors: ErrorLog[] = []
|
||||
|
||||
const server = serve({
|
||||
port: parseInt(process.env.PORT || "3000"),
|
||||
|
|
@ -17,38 +23,30 @@ const server = serve({
|
|||
POST: async (req) => {
|
||||
const payload = await req.json()
|
||||
const eventType = req.headers.get("X-Gitea-Event") || "unknown"
|
||||
const repo = (payload as any)?.repository?.full_name || "unknown"
|
||||
const sender = (payload as any)?.sender?.login as string | undefined
|
||||
const ref = (payload as any)?.ref as string | undefined
|
||||
log({ type: "webhook", eventType, repo, sender, ref })
|
||||
console.log(`🌵 Received Gitea webhook ${eventType}`)
|
||||
|
||||
try {
|
||||
await handleGiteaWebhook(payload, eventType as any)
|
||||
return new Response("OK", { status: 200 })
|
||||
} catch (error) {
|
||||
log({ type: "error", error, context: `webhook ${eventType} ${repo}` })
|
||||
const errorLog: ErrorLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
payload,
|
||||
}
|
||||
errors.push(errorLog)
|
||||
console.error("💥 Webhook error 💥")
|
||||
console.error(error)
|
||||
return new Response("Error", { status: 500 })
|
||||
}
|
||||
},
|
||||
},
|
||||
"/logs": {
|
||||
GET: (req) => {
|
||||
const accept = req.headers.get("Accept") || ""
|
||||
const wantsRaw = !accept.includes("text/html")
|
||||
if (!wantsRaw) return LogsPage(req)
|
||||
|
||||
const file = new URL(req.url).searchParams.get("file")
|
||||
if (!file) {
|
||||
const files = listLogFiles()
|
||||
return Response.json(files)
|
||||
}
|
||||
try {
|
||||
const raw = readLogFileRaw(file)
|
||||
if (!raw) return new Response("Not found", { status: 404 })
|
||||
return new Response(raw, { headers: { "Content-Type": "application/jsonl" } })
|
||||
} catch {
|
||||
return new Response("Internal server error", { status: 500 })
|
||||
}
|
||||
"/errors": {
|
||||
GET: () => {
|
||||
return new Response(JSON.stringify(errors, null, 2), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
},
|
||||
},
|
||||
"/discord/auth": async () => {
|
||||
|
|
@ -70,4 +68,4 @@ const server = serve({
|
|||
development: process.env.NODE_ENV !== "production" && { hmr: true, console: true },
|
||||
})
|
||||
|
||||
log({ type: "startup", detail: `Spike running at ${server.url}:${server.port}` })
|
||||
console.log(`Spike running at ${server.url}:${server.port}`)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Server
|
||||
|
||||
HTTP server and web pages for Spike.
|
||||
|
||||
## Entry point
|
||||
|
||||
`index.tsx` — Bun HTTP server with routes for webhooks and web pages.
|
||||
|
||||
## Routes
|
||||
|
||||
- `GET /` — Health check
|
||||
- `POST /gitea/webhook` — Receives Gitea webhook payloads, dispatches to bridge
|
||||
- `GET /logs` — Log viewer (HTML). Supports `?file=`, `?type=`, `?repo=` query params
|
||||
- `GET /discord/auth` — Discord bot OAuth authorize page
|
||||
|
||||
## Pages
|
||||
|
||||
- `logs.tsx` — Server-rendered log viewer with sidebar (files grouped by sha) and filterable event table
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
import { listLogFiles, readLogFile, type StoredLogEvent } from "../log"
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
webhook: "#4ade80",
|
||||
"webhook-ignored": "#facc15",
|
||||
pr: "#4ade80",
|
||||
comment: "#4ade80",
|
||||
"comment-skipped": "#facc15",
|
||||
review: "#4ade80",
|
||||
"thread-auto-created": "#60a5fa",
|
||||
"discord-relay": "#60a5fa",
|
||||
"discord-command-error": "#f87171",
|
||||
"discord-warning": "#facc15",
|
||||
"crash-log-found": "#f87171",
|
||||
startup: "#60a5fa",
|
||||
"discord-ready": "#60a5fa",
|
||||
error: "#f87171",
|
||||
}
|
||||
|
||||
const summaryFields = ["type", "ts"] as const
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error && "message" in error) return String((error as any).message)
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function errorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack
|
||||
if (typeof error === "object" && error && "stack" in error) return String((error as any).stack)
|
||||
}
|
||||
|
||||
function getEventMeta(event: StoredLogEvent): Record<string, string> {
|
||||
const meta: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (summaryFields.includes(key as any)) continue
|
||||
if (value === undefined) continue
|
||||
if (key === "error") {
|
||||
meta[key] = errorMessage(value)
|
||||
const stack = errorStack(value)
|
||||
if (stack) meta.stack = stack
|
||||
continue
|
||||
}
|
||||
meta[key] = typeof value === "string" ? value : String(value)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
function EventRow({ event, index }: { event: StoredLogEvent; index: number }) {
|
||||
const color = typeColors[event.type] || "#9ca3af"
|
||||
const time = event.ts.slice(11, 19)
|
||||
|
||||
const details: string[] = []
|
||||
if ("action" in event) details.push(event.action)
|
||||
if ("repo" in event) details.push(event.repo)
|
||||
if ("pr" in event) details.push(`#${event.pr}`)
|
||||
if ("user" in event) details.push(event.user)
|
||||
if ("detail" in event) details.push(event.detail)
|
||||
if ("command" in event) details.push(event.command)
|
||||
if ("eventType" in event) details.push(event.eventType)
|
||||
if (event.type === "error" && event.context) details.push(event.context)
|
||||
if (event.type === "error") {
|
||||
details.push(errorMessage(event.error))
|
||||
}
|
||||
|
||||
const meta = getEventMeta(event)
|
||||
const hasExtra = Object.keys(meta).length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
style={`border-bottom: 1px solid #2a2a2a${hasExtra ? "; cursor: pointer" : ""}`}
|
||||
data-row={index}
|
||||
onclick={hasExtra ? `(function(e){var d=document.getElementById('detail-${index}');if(d){d.style.display=d.style.display==='none'?'table-row':'none';e.currentTarget.querySelector('.expand-icon').textContent=d.style.display==='none'?'+':'-'}})(event)` : undefined}
|
||||
>
|
||||
<td style="padding: 6px 4px 6px 12px; color: #4a4a4a; font-size: 11px; width: 16px; user-select: none">
|
||||
{hasExtra && <span class="expand-icon">+</span>}
|
||||
</td>
|
||||
<td data-ts={event.ts} style="padding: 6px 12px; color: #6b7280; white-space: nowrap; font-size: 13px">{time}</td>
|
||||
<td style={`padding: 6px 12px; color: ${color}; white-space: nowrap; font-weight: 600; font-size: 13px`}>
|
||||
{event.type}
|
||||
</td>
|
||||
<td style="padding: 6px 12px; color: #d1d5db; font-size: 13px">{details.join(" ")}</td>
|
||||
</tr>
|
||||
{hasExtra && (
|
||||
<tr id={`detail-${index}`} style="display: none; background: #141414">
|
||||
<td></td>
|
||||
<td colspan="3" style="padding: 8px 12px 12px">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 12px">
|
||||
{Object.entries(meta).map(([key, value]) => (
|
||||
key === "stack" ? (
|
||||
<div style="width: 100%">
|
||||
<span style="color: #6b7280">{key}</span>
|
||||
<pre style="color: #d1d5db; margin: 4px 0 0; font-size: 11px; white-space: pre-wrap; opacity: 0.8">{value}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div style="display: flex; gap: 6px">
|
||||
<span style="color: #6b7280">{key}</span>
|
||||
<span style="color: #d1d5db; max-width: 500px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">{value}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function parseLogFilename(file: string): { sha: string; isoStr: string } {
|
||||
const stem = file.replace(".jsonl", "")
|
||||
// New format: timestamp_sha (e.g. 2026-03-10T18-06-23-561Z_cb10121)
|
||||
// Old format: sha_timestamp (e.g. cb10121_2026-03-10T18-06-23-561Z)
|
||||
const isNewFormat = /^\d{4}-/.test(stem)
|
||||
const parts = stem.split("_")
|
||||
|
||||
let sha: string
|
||||
let rawTimestamp: string
|
||||
if (isNewFormat) {
|
||||
sha = parts.pop() || "unknown"
|
||||
rawTimestamp = parts.join("_")
|
||||
} else {
|
||||
rawTimestamp = parts.pop() || ""
|
||||
sha = parts.join("_") || "unknown"
|
||||
}
|
||||
|
||||
const isoStr = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d+)Z/, "T$1:$2:$3.$4Z")
|
||||
return { sha, isoStr }
|
||||
}
|
||||
|
||||
function Sidebar({ files, selectedFile }: { files: string[]; selectedFile?: string }) {
|
||||
// Group files by sha, sorted by most recent file in each group
|
||||
const grouped: Record<string, string[]> = {}
|
||||
for (const file of files) {
|
||||
const { sha } = parseLogFilename(file)
|
||||
const list = grouped[sha] || (grouped[sha] = [])
|
||||
list.push(file)
|
||||
}
|
||||
const sortedGroups = Object.entries(grouped).sort(([, a], [, b]) => {
|
||||
return b[0]!.localeCompare(a[0]!)
|
||||
})
|
||||
|
||||
return (
|
||||
<nav style="width: 280px; min-width: 280px; background: #141414; border-right: 1px solid #2a2a2a; overflow-y: auto; padding: 16px 0">
|
||||
<div style="padding: 0 16px 12px; color: #9ca3af; font-size: 11px; text-transform: uppercase; letter-spacing: 1px">
|
||||
Log Files
|
||||
</div>
|
||||
{sortedGroups.map(([sha, shaFiles]) => (
|
||||
<div style="margin-bottom: 8px">
|
||||
<div style="padding: 4px 16px; color: #6b7280; font-size: 11px; font-family: monospace">{sha}</div>
|
||||
{shaFiles.map((file) => {
|
||||
const isSelected = file === selectedFile
|
||||
const { isoStr } = parseLogFilename(file)
|
||||
const bg = isSelected ? "#2a2a2a" : "transparent"
|
||||
return (
|
||||
<a
|
||||
href={`/logs?file=${file}`}
|
||||
data-ts={isoStr}
|
||||
style={`display: block; padding: 6px 16px 6px 24px; color: ${isSelected ? "#f3f4f6" : "#9ca3af"}; text-decoration: none; font-size: 12px; background: ${bg}; font-family: monospace`}
|
||||
>
|
||||
{isoStr}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsPage(req: Request) {
|
||||
const url = new URL(req.url)
|
||||
const selectedFile = url.searchParams.get("file") || undefined
|
||||
const typeFilter = url.searchParams.get("type") || undefined
|
||||
const repoFilter = url.searchParams.get("repo") || undefined
|
||||
|
||||
const files = listLogFiles()
|
||||
|
||||
let events: StoredLogEvent[] = []
|
||||
if (selectedFile) {
|
||||
events = readLogFile(selectedFile)
|
||||
if (typeFilter) events = events.filter((e) => e.type === typeFilter)
|
||||
if (repoFilter) events = events.filter((e) => "repo" in e && e.repo === repoFilter)
|
||||
}
|
||||
|
||||
// Collect unique types and repos for filter links
|
||||
const allEvents = selectedFile ? readLogFile(selectedFile) : []
|
||||
const types = [...new Set(allEvents.map((e) => e.type))]
|
||||
const repos = [...new Set(allEvents.filter((e): e is StoredLogEvent & { repo: string } => "repo" in e).map((e) => e.repo))]
|
||||
|
||||
const html = (
|
||||
<html>
|
||||
<head>
|
||||
<title>Spike Logs</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body style="margin: 0; background: #0a0a0a; color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; height: 100vh">
|
||||
<Sidebar files={files} selectedFile={selectedFile} />
|
||||
|
||||
<main style="flex: 1; overflow-y: auto; padding: 24px">
|
||||
{!selectedFile ? (
|
||||
<div style="color: #6b7280; padding: 40px; text-align: center; font-size: 14px">
|
||||
Select a log file from the sidebar
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
{(types.length > 1 || repos.length > 0) && (
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center">
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; ${!typeFilter && !repoFilter ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
all
|
||||
</a>
|
||||
{types.map((t) => (
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}&type=${t}${repoFilter ? `&repo=${repoFilter}` : ""}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; ${typeFilter === t ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
{t}
|
||||
</a>
|
||||
))}
|
||||
{repos.length > 0 && (
|
||||
<span style="color: #3a3a3a; margin: 0 4px">|</span>
|
||||
)}
|
||||
{repos.map((r) => (
|
||||
<a
|
||||
href={`/logs?file=${selectedFile}&repo=${r}${typeFilter ? `&type=${typeFilter}` : ""}`}
|
||||
style={`padding: 4px 10px; border-radius: 4px; font-size: 12px; text-decoration: none; font-family: monospace; ${repoFilter === r ? "background: #3b82f6; color: white" : "background: #1f1f1f; color: #9ca3af; border: 1px solid #2a2a2a"}`}
|
||||
>
|
||||
{r}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events table */}
|
||||
<table style="width: 100%; border-collapse: collapse">
|
||||
<tbody>
|
||||
{events.map((event, i) => (
|
||||
<EventRow event={event} index={i} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{events.length === 0 && (
|
||||
<div style="color: #6b7280; padding: 40px; text-align: center; font-size: 14px">
|
||||
No events{typeFilter ? ` of type "${typeFilter}"` : ""}{repoFilter ? ` for ${repoFilter}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<script dangerouslySetInnerHTML={{__html: `
|
||||
document.querySelectorAll('[data-ts]').forEach(function(el) {
|
||||
var d = new Date(el.dataset.ts);
|
||||
if (isNaN(d)) return;
|
||||
var pad = function(n) { return n < 10 ? '0' + n : n; };
|
||||
el.textContent = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||
});
|
||||
`}} />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
return new Response(html.toString(), { headers: { "Content-Type": "text/html" } })
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, test, expect, beforeAll, afterEach, setDefaultTimeout } from "bun:test"
|
||||
import { spike, spikeRepo } from "./setup"
|
||||
import { type TestPR, expectPullRequestWebhook, openTestPR, cleanupTestPR } from "./helpers"
|
||||
import { handleGiteaWebhook } from "../src/bridge"
|
||||
import { client, startDiscord } from "../src/discord"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
beforeAll(async () => {
|
||||
await startDiscord()
|
||||
})
|
||||
|
||||
const prsToCleanup: TestPR[] = []
|
||||
afterEach(async () => {
|
||||
while (prsToCleanup.length) {
|
||||
await cleanupTestPR(prsToCleanup.pop()!)
|
||||
}
|
||||
})
|
||||
|
||||
describe("bridge: Spike-owned repo", () => {
|
||||
test("Spike creating a PR on their own repo creates a Discord thread", async () => {
|
||||
const branch = `test-${crypto.randomUUID().slice(0, 8)}-bridge`
|
||||
|
||||
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
|
||||
const pr = await openTestPR(spikeRepo, spike, branch)
|
||||
prsToCleanup.push(pr)
|
||||
const payload = await webhook
|
||||
|
||||
// Process the webhook through the bridge (Gitea → Discord)
|
||||
await handleGiteaWebhook(payload, "pull_request")
|
||||
|
||||
// Verify a Discord thread was created
|
||||
const channelId = (await import("../src/config")).getConfig("channelId")
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
if (!channel || !("threads" in channel)) throw new Error("Could not fetch channel")
|
||||
|
||||
const threads = await channel.threads.fetchActive()
|
||||
const thread = threads.threads.find((t) => t.name.includes(branch))
|
||||
expect(thread).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
import { serve } from "bun"
|
||||
import { ensure } from "@workshop/shared/utils"
|
||||
import { mkdtemp, rm } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const giteaUrl = "https://git.nose.space"
|
||||
|
||||
export type User = { token: string; username: string }
|
||||
|
||||
// --- Webhook capture ---
|
||||
|
||||
type Expectation = {
|
||||
eventType: string
|
||||
match: (payload: any) => boolean
|
||||
resolve: (payload: any) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const 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"
|
||||
matchIncoming(eventType, payload)
|
||||
return new Response("OK")
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function awaitWebhook(eventType: string, match: (payload: any) => boolean, timeoutMs = 15000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const idx = expectations.indexOf(expectation)
|
||||
if (idx >= 0) expectations.splice(idx, 1)
|
||||
reject(new Error(`Timed out waiting for ${eventType} webhook`))
|
||||
}, timeoutMs)
|
||||
|
||||
const expectation: Expectation = { eventType, match, resolve, timer }
|
||||
expectations.push(expectation)
|
||||
})
|
||||
}
|
||||
|
||||
function hasBranch(payload: any, branch: string) {
|
||||
return payload.pull_request?.head?.ref === branch || payload.pull_request?.head?.label === branch
|
||||
}
|
||||
|
||||
export async function expectPullRequestWebhook<T>(
|
||||
match: { action: string; branch: string },
|
||||
callback: (webhook: Promise<any>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const webhook = awaitWebhook("pull_request", (p) => p.action === match.action && hasBranch(p, match.branch))
|
||||
return callback(webhook)
|
||||
}
|
||||
|
||||
export async function expectIssueCommentWebhook<T>(
|
||||
match: { action: string; username: string; prNumber: number },
|
||||
callback: (webhook: Promise<any>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const webhook = awaitWebhook(
|
||||
"issue_comment",
|
||||
(p) => p.action === match.action && p.comment?.user?.login === match.username && p.issue?.number === match.prNumber,
|
||||
)
|
||||
return callback(webhook)
|
||||
}
|
||||
|
||||
export async function expectPullRequestCommentWebhook<T>(
|
||||
match: { action: string; branch: string },
|
||||
callback: (webhook: Promise<any>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const webhook = awaitWebhook("pull_request_comment", (p) => p.action === match.action && hasBranch(p, match.branch))
|
||||
return callback(webhook)
|
||||
}
|
||||
|
||||
// --- Setup / teardown ---
|
||||
|
||||
export async function setupWebhooks(corey: User, spike: User, coreyRepo: string, spikeRepo: string) {
|
||||
const tsResult = Bun.spawnSync(["tailscale", "status", "--self", "--json"])
|
||||
ensure(tsResult.success, `Failed to get tailscale status: ${tsResult.stderr.toString()}`)
|
||||
const tailscaleUrl = `https://${JSON.parse(tsResult.stdout.toString()).Self.DNSName.replace(/\.$/, "")}`
|
||||
|
||||
// Clear any stale funnel/serve config from previous test runs
|
||||
Bun.spawnSync(["pkill", "-f", "tailscale funnel"])
|
||||
Bun.spawnSync(["tailscale", "serve", "reset"])
|
||||
await Bun.sleep(500)
|
||||
|
||||
const funnel = Bun.spawn(["tailscale", "funnel", String(captureServer.port)], { stdout: "ignore", stderr: "ignore" })
|
||||
const funnelUrl = `${tailscaleUrl}/gitea/webhook`
|
||||
|
||||
// Wait for funnel to be reachable
|
||||
let funnelReachable = false
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 10000) {
|
||||
try {
|
||||
const res = await fetch(funnelUrl, { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } })
|
||||
if (res.ok) {
|
||||
funnelReachable = true
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
await Bun.sleep(500)
|
||||
}
|
||||
ensure(funnelReachable, `Tailscale funnel not reachable at ${funnelUrl} after 10s`)
|
||||
|
||||
await ensureRepo(spikeRepo, spike)
|
||||
await addCollaborator(spikeRepo, spike, corey.username)
|
||||
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)
|
||||
expectations.length = 0
|
||||
|
||||
return function teardown() {
|
||||
captureServer.stop()
|
||||
funnel.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scaffolding: open a PR ready for testing ---
|
||||
|
||||
export type TestPR = {
|
||||
repo: string
|
||||
number: number
|
||||
branch: string
|
||||
filename: string
|
||||
dir: string
|
||||
author: User
|
||||
}
|
||||
|
||||
export async function openTestPR(repo: string, author: User, branch = `test-${Date.now()}`): Promise<TestPR> {
|
||||
const { dir, filename } = await pushBranch(repo, branch, author)
|
||||
const pr = await createPR(repo, `Test PR (${branch})`, branch, author)
|
||||
return { repo, number: pr.number, branch, filename, dir, author }
|
||||
}
|
||||
|
||||
export async function cleanupTestPR(pr: TestPR) {
|
||||
try { git(["push", "origin", "--delete", pr.branch], pr.dir) } catch {}
|
||||
await rm(pr.dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// --- Gitea API ---
|
||||
|
||||
async function giteaFetch(path: string, user: User, options: RequestInit = {}) {
|
||||
const response = await fetch(`${giteaUrl}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `token ${user.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers as Record<string, string>),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "")
|
||||
throw new Error(`Gitea ${response.status}: ${options.method ?? "GET"} ${path}\n${body}`)
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("json")) {
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRepo(repo: string, owner: User) {
|
||||
const res = await fetch(`${giteaUrl}/api/v1/repos/${repo}`, {
|
||||
headers: { Authorization: `token ${owner.token}` },
|
||||
})
|
||||
if (res.ok) return
|
||||
const repoName = repo.split("/")[1]
|
||||
await giteaFetch("/user/repos", owner, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: repoName, auto_init: true, default_branch: "main" }),
|
||||
})
|
||||
}
|
||||
|
||||
async function addCollaborator(repo: string, owner: User, collaborator: string) {
|
||||
await giteaFetch(`/repos/${repo}/collaborators/${collaborator}`, owner, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ permission: "write" }),
|
||||
})
|
||||
}
|
||||
|
||||
async function upsertWebhook(repo: string, webhookUrl: string, user: User) {
|
||||
// Delete all existing webhooks to prevent duplicates from stale test runs
|
||||
const hooks = (await giteaFetch(`/repos/${repo}/hooks`, user)) as Array<{ id: number; config: { url: string } }>
|
||||
for (const hook of hooks) {
|
||||
await giteaFetch(`/repos/${repo}/hooks/${hook.id}`, user, { method: "DELETE" })
|
||||
}
|
||||
|
||||
await giteaFetch(`/repos/${repo}/hooks`, user, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "gitea",
|
||||
active: true,
|
||||
config: { url: webhookUrl, content_type: "json" },
|
||||
events: ["pull_request", "issue_comment", "pull_request_comment"],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function createPR(repo: string, title: string, head: string, user: User) {
|
||||
return giteaFetch(`/repos/${repo}/pulls`, user, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, head, base: "main", body: "Test PR" }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function commentOnPR(repo: string, prNumber: number, body: string, user: User) {
|
||||
return giteaFetch(`/repos/${repo}/issues/${prNumber}/comments`, user, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function mergePR(repo: string, prNumber: number, user: User) {
|
||||
// Gitea returns 404/405 while computing mergeability after PR creation
|
||||
const path = `/repos/${repo}/pulls/${prNumber}/merge`
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const response = await fetch(`${giteaUrl}/api/v1${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${user.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ Do: "merge" }),
|
||||
})
|
||||
|
||||
if (response.ok) return
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
const body = await response.text().catch(() => "")
|
||||
throw new Error(`Gitea ${response.status}: POST ${path}\n${body}`)
|
||||
}
|
||||
|
||||
throw new Error(`Gitea merge failed after 10 retries for ${path}`)
|
||||
}
|
||||
|
||||
export async function createReview(repo: string, prNumber: number, user: User, filePath: string) {
|
||||
return giteaFetch(`/repos/${repo}/pulls/${prNumber}/reviews`, user, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: "Review from integration test",
|
||||
event: "COMMENT",
|
||||
comments: [{ path: filePath, new_position: 1, body: `Line comment from ${user.username}` }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Git ---
|
||||
|
||||
function git(args: string[], cwd: string) {
|
||||
const result = Bun.spawnSync(["git", ...args], { cwd })
|
||||
if (!result.success) {
|
||||
throw new Error(`git ${args.join(" ")} failed:\n${result.stderr.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function pushBranch(repo: string, branch: string, user: User) {
|
||||
const dir = await mkdtemp(join(tmpdir(), "spike-test-"))
|
||||
const url = `https://${user.username}:${user.token}@git.nose.space/${repo}.git`
|
||||
const filename = `test-${Date.now()}.md`
|
||||
git(["clone", url, dir], dir)
|
||||
git(["config", "user.name", user.username], dir)
|
||||
git(["config", "user.email", `${user.username}@test`], dir)
|
||||
git(["checkout", "-b", branch], dir)
|
||||
await Bun.write(join(dir, filename), "test file")
|
||||
git(["add", "."], dir)
|
||||
git(["commit", "-m", "test commit"], dir)
|
||||
git(["push", "origin", branch], dir)
|
||||
return { dir, filename }
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { ensure } from "@workshop/shared/utils"
|
||||
import { setupWebhooks, type User } from "./helpers"
|
||||
|
||||
function getEnv(name: string): string {
|
||||
const value = process.env[name]
|
||||
ensure(value, `Missing env var: ${name}. See .env.example`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const corey: User = { token: getEnv("TEST_GITEA_API_TOKEN_COREY"), username: "probablycorey" }
|
||||
export const spike: User = { token: getEnv("TEST_GITEA_API_TOKEN_SPIKE"), username: "Spike" }
|
||||
export const coreyRepo = getEnv("TEST_REPO_COREY")
|
||||
export const spikeRepo = getEnv("TEST_REPO_SPIKE")
|
||||
|
||||
const teardown = await setupWebhooks(corey, spike, coreyRepo, spikeRepo)
|
||||
|
||||
process.on("beforeExit", () => teardown())
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { describe, test, expect, afterEach, setDefaultTimeout } from "bun:test"
|
||||
import { corey, spike, coreyRepo, spikeRepo } from "./setup"
|
||||
import {
|
||||
type TestPR,
|
||||
expectPullRequestWebhook,
|
||||
expectIssueCommentWebhook,
|
||||
expectPullRequestCommentWebhook,
|
||||
openTestPR,
|
||||
cleanupTestPR,
|
||||
commentOnPR,
|
||||
createReview,
|
||||
mergePR,
|
||||
} from "./helpers"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
function testBranch(label: string) {
|
||||
return `test-${crypto.randomUUID().slice(0, 8)}-${label}`
|
||||
}
|
||||
|
||||
const prsToCleanup: TestPR[] = []
|
||||
afterEach(async () => {
|
||||
while (prsToCleanup.length) {
|
||||
await cleanupTestPR(prsToCleanup.pop()!)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Tests for Corey's repo (Spike opens PR, Corey comments/reviews/merges) ---
|
||||
|
||||
describe(`${coreyRepo}`, () => {
|
||||
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
|
||||
const branch = testBranch("open")
|
||||
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
|
||||
const pr = await openTestPR(coreyRepo, spike, branch)
|
||||
prsToCleanup.push(pr)
|
||||
const payload = await webhook
|
||||
expect(payload.pull_request.user.login).toBe(spike.username)
|
||||
expect(payload.repository.full_name).toBe(coreyRepo)
|
||||
})
|
||||
})
|
||||
|
||||
test("commenting sends issue_comment webhook with correct user", async () => {
|
||||
const branch = testBranch("comment")
|
||||
const pr = await openTestPR(coreyRepo, spike, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectIssueCommentWebhook({ action: "created", username: corey.username, prNumber: pr.number }, async (webhook) => {
|
||||
await commentOnPR(coreyRepo, pr.number, "Looks good!", corey)
|
||||
const payload = await webhook
|
||||
expect(payload.issue.number).toBe(pr.number)
|
||||
})
|
||||
})
|
||||
|
||||
test("review with line comment sends pull_request_comment webhook", async () => {
|
||||
const branch = testBranch("review")
|
||||
const pr = await openTestPR(coreyRepo, spike, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
|
||||
await createReview(coreyRepo, pr.number, corey, pr.filename)
|
||||
const payload = await webhook
|
||||
expect(payload.sender.login).toBe(corey.username)
|
||||
expect(payload.review).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("merging sends pull_request closed webhook", async () => {
|
||||
const branch = testBranch("merge")
|
||||
const pr = await openTestPR(coreyRepo, spike, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
|
||||
await mergePR(coreyRepo, pr.number, corey)
|
||||
const payload = await webhook
|
||||
expect(payload.pull_request.merged).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- Tests for Spike's repo (Corey opens PR, Spike comments/reviews/merges) ---
|
||||
|
||||
describe(`${spikeRepo}`, () => {
|
||||
test("opening a PR sends pull_request webhook with correct author and repo", async () => {
|
||||
const branch = testBranch("open")
|
||||
await expectPullRequestWebhook({ action: "opened", branch }, async (webhook) => {
|
||||
const pr = await openTestPR(spikeRepo, corey, branch)
|
||||
prsToCleanup.push(pr)
|
||||
const payload = await webhook
|
||||
expect(payload.pull_request.user.login).toBe(corey.username)
|
||||
expect(payload.repository.full_name).toBe(spikeRepo)
|
||||
})
|
||||
})
|
||||
|
||||
test("commenting sends issue_comment webhook with correct user", async () => {
|
||||
const branch = testBranch("comment")
|
||||
const pr = await openTestPR(spikeRepo, corey, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectIssueCommentWebhook({ action: "created", username: spike.username, prNumber: pr.number }, async (webhook) => {
|
||||
await commentOnPR(spikeRepo, pr.number, "On it!", spike)
|
||||
const payload = await webhook
|
||||
expect(payload.issue.number).toBe(pr.number)
|
||||
})
|
||||
})
|
||||
|
||||
test("review with line comment sends pull_request_comment webhook", async () => {
|
||||
const branch = testBranch("review")
|
||||
const pr = await openTestPR(spikeRepo, corey, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectPullRequestCommentWebhook({ action: "reviewed", branch }, async (webhook) => {
|
||||
await createReview(spikeRepo, pr.number, spike, pr.filename)
|
||||
const payload = await webhook
|
||||
expect(payload.sender.login).toBe(spike.username)
|
||||
expect(payload.review).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test("merging sends pull_request closed webhook", async () => {
|
||||
const branch = testBranch("merge")
|
||||
const pr = await openTestPR(spikeRepo, corey, branch)
|
||||
prsToCleanup.push(pr)
|
||||
await expectPullRequestWebhook({ action: "closed", branch }, async (webhook) => {
|
||||
await mergePR(spikeRepo, pr.number, spike)
|
||||
const payload = await webhook
|
||||
expect(payload.pull_request.merged).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user