diff --git a/packages/spike/README.md b/packages/spike/README.md index b7e6759..f46e18d 100644 --- a/packages/spike/README.md +++ b/packages/spike/README.md @@ -10,5 +10,5 @@ 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 `bun run grok` to start an ngrok tunnel to your local server for webhooks. +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!** diff --git a/packages/spike/package.json b/packages/spike/package.json index c18c5e5..df559dd 100644 --- a/packages/spike/package.json +++ b/packages/spike/package.json @@ -8,8 +8,7 @@ "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", - "grok": "ngrok http 3000" + "subdomain:dev": "bun run --hot src/server.tsx" }, "prettier": { "printWidth": 110, diff --git a/packages/spike/src/db.ts b/packages/spike/src/db.ts index c262186..403bbe5 100644 --- a/packages/spike/src/db.ts +++ b/packages/spike/src/db.ts @@ -51,25 +51,22 @@ export const insertPR = ( } 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 - ) - - if (!row) { - throw new Error(`No database entry found for Gitea PR ID ${giteaPrId}`) - } + 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 const getPRByDiscordThreadId = (discordThreadId: string) => { - const row = db.query< - { gitea_pr_id: number; repo: string; pr_number: number }, - string - >(`SELECT gitea_pr_id, repo, pr_number FROM prs WHERE discord_thread_id = ?`).get(discordThreadId) + const row = db + .query<{ gitea_pr_id: number; repo: string; pr_number: number }, string>( + `SELECT gitea_pr_id, repo, pr_number FROM prs WHERE discord_thread_id = ?` + ) + .get(discordThreadId) // Don't throw - we use this to check if it's a PR thread return row @@ -90,9 +87,11 @@ export const insertComment = ( } export const getCommentByGiteaId = (giteaCommentId: number) => { - const row = db.query<{ discord_message_id: string }, number>( - `SELECT discord_message_id FROM comments WHERE gitea_comment_id = ?` - ).get(giteaCommentId) + const row = db + .query<{ discord_message_id: string }, number>( + `SELECT discord_message_id FROM comments WHERE gitea_comment_id = ?` + ) + .get(giteaCommentId) if (!row) { throw new Error(`No database entry found for Gitea comment ID ${giteaCommentId}`) @@ -111,7 +110,9 @@ export const addParticipant = (giteaPrId: number, discordUserId: string) => { } export const getParticipants = (giteaPrId: number) => { - return db.query<{ discord_user_id: string }, number>( - `SELECT discord_user_id FROM participants WHERE gitea_pr_id = ?` - ).all(giteaPrId) + return db + .query<{ discord_user_id: string }, number>( + `SELECT discord_user_id FROM participants WHERE gitea_pr_id = ?` + ) + .all(giteaPrId) } diff --git a/packages/spike/src/discord/events.ts b/packages/spike/src/discord/events.ts index cefee4f..5dbf119 100644 --- a/packages/spike/src/discord/events.ts +++ b/packages/spike/src/discord/events.ts @@ -23,8 +23,8 @@ export const listenForEvents = (client: Client) => { // if it is a dm always respond // if it is a guild message, only respond if the bot is at mentioned if (!msg.guild || msg.mentions.has(client.user!)) { - // simple echo for now - await msg.channel.send(`You said: ${msg.content}`) + // Do nothing right now + // await msg.channel.send(`You said: ${msg.content}`) } } catch (error) { console.error("Error handling messageCreate event:", error) diff --git a/packages/spike/src/gitea/api.ts b/packages/spike/src/gitea/api.ts index a731ce0..3c0a1e9 100644 --- a/packages/spike/src/gitea/api.ts +++ b/packages/spike/src/gitea/api.ts @@ -2,7 +2,46 @@ import { insertComment } from "../db" const giteaUrl = "https://git.nose.space" -interface CreateCommentResponse { +export namespace Gitea { + export type User = { + id: number + login: string + username: string + } + + export type PullRequest = { + id: number + number: number + title: string + body: string + user: User + html_url: string + state: string + merged: boolean + } + + export type Issue = { + id: number + number: number + pull_request?: { + html_url: string + } + } + + export type Comment = { + id: number + body: string + user: User + html_url: string + } + + export type Repository = { + name: string + full_name: string + } +} + +type CreateCommentResponse = { id: number body: string user: { @@ -47,3 +86,17 @@ export const createPRComment = async ( return comment } + +export const fetchPR = async (fullname: string, prNumber: number): Promise => { + 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) { + throw new Error(`Gitea API error: ${response.status} ${response.statusText}`) + } + + return response.json() as Promise +} diff --git a/packages/spike/src/gitea/webhook-handler.ts b/packages/spike/src/gitea/webhook-handler.ts index bebb9e9..3366ad6 100644 --- a/packages/spike/src/gitea/webhook-handler.ts +++ b/packages/spike/src/gitea/webhook-handler.ts @@ -3,47 +3,60 @@ import { insertPR, getPRByGiteaId, insertComment, getCommentByGiteaId } from ".. import { getConfig } from "../config" import { ChannelType, ThreadChannel, type Channel } from "discord.js" import { assertNever } from "@workshop/shared/utils" +import { fetchPR, type Gitea } from "./api" export const handleGiteaWebhook = async (payload: GiteaWebhook) => { const isDev = process.env.NODE_ENV !== "production" - if (!isDev && payload.repository.name === "ignore-me") { - return - } + if (!isDev && payload.repository.name === "ignore-me") return if ("pull_request" in payload) { - // TODO: states to handle still "closed" "reopened" + const { pull_request: pullRequest, repository } = payload + if (payload.action === "opened") { - await handlePullRequestOpened(payload) + await handlePullRequestOpened(pullRequest, repository) } else if (payload.action === "edited") { - await handlePullRequestEdited(payload) + await ensurePRCreatedOnDiscord(pullRequest, repository) + await handlePullRequestEdited(pullRequest, repository) + } else if (payload.action === "closed" || payload.action === "reopened") { + await ensurePRCreatedOnDiscord(pullRequest, repository) + await handlePullRequestStateChange(pullRequest, repository) } + console.log(`✅ pull request webhook action: ${payload.action} #${payload.number}`) } else if ("comment" in payload && "issue" in payload) { + const { issue, comment, repository } = payload + + if (!issue.pull_request) return + if (payload.action === "created") { - await handleIssueCommentCreated(payload) + await ensurePRCreatedOnDiscord(issue, repository) + await handleIssueCommentCreated(issue, comment) } else if (payload.action === "edited") { - await handleIssueCommentEdited(payload) + await ensurePRCreatedOnDiscord(issue, repository) + await handleIssueCommentEdited(issue, comment) + } else if (payload.action === "deleted") { + await ensurePRCreatedOnDiscord(issue, repository) + await handleIssueDeleted(issue, comment) } + console.log(`✅ comment webhook action: ${payload.action} on #${payload.issue.number}`) } else { - assertNever(payload, "Unhandled Gitea webhook payload") + // console.log(`🙈 Unhandled Gitea webhook payload`, { payload: JSON.stringify(payload, null, 2) }) } } -const handlePullRequestOpened = async (payload: PullRequestWebhook) => { - const { pull_request: pullRequest, repository } = payload - const channelId = getConfig("channelId") - - const channel = await client.channels.fetch(channelId) - if (channel?.type !== ChannelType.GuildText) { - throw new Error( - `Discord channel ${channelId} is type ${channel?.type}, expected GuildText (0) for PR #${pullRequest.number} (${repository.name})` - ) - } +const channelId = getConfig("channelId") +const channel = await client.channels.fetch(channelId) +if (channel?.type !== ChannelType.GuildText) { + throw new Error( + `Discord channel ${channelId} (from config.ts) is type ${channel?.type}, expected GuildText (0)` + ) +} +const handlePullRequestOpened = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { // Post the PR details as first message in thread const webhook = await getOrCreateWebhook(channel) - const prMessage = formatPrMessage(pullRequest, repository.full_name) + const prMessage = formatPrBody(pullRequest, repository.full_name) const message = await webhook.send({ content: prMessage, @@ -51,44 +64,29 @@ const handlePullRequestOpened = async (payload: PullRequestWebhook) => { avatarURL: await mapGiteaUserToAvatarURL(pullRequest.user.login), }) - const threadName = `[${repository.name}] ${pullRequest.title}` const thread = await message.startThread({ - name: threadName.slice(0, 100), + name: threadName(pullRequest, repository), reason: `PR #${pullRequest.number} opened`, }) insertPR(pullRequest.id, repository.full_name, pullRequest.number, thread.id, message.id) } -const handlePullRequestEdited = async (payload: PullRequestWebhook) => { - const { pull_request: pullRequest } = payload - - const row = getPRByGiteaId(pullRequest.id) - const thread = await client.channels.fetch(row.discord_thread_id) - if (!thread?.isThread()) { - throw new Error( - `Discord channel ${row.discord_thread_id} is not a thread (type: ${thread?.type}) for PR #${pullRequest.number}` - ) +const handlePullRequestEdited = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { + const thread = await getThread(pullRequest) + const prBody = await thread.fetchStarterMessage() + if (!prBody) { + throw new Error(`Could not fetch PR body for #${pullRequest.number}`) } - // Fetch the PR details message - const prMessage = await thread.messages.fetch(row.discord_message_id) - if (!prMessage) { - throw new Error( - `Could not fetch PR message ${row.discord_message_id} in thread ${row.discord_thread_id} (PR #${pullRequest.number})` - ) - } - - const updatedMessage = formatPrMessage(pullRequest, payload.repository.full_name) - await prMessage.edit(updatedMessage) + const updatedMessage = formatPrBody(pullRequest, repository.full_name) + console.log(`🌭`, { title: pullRequest.title }) + thread.setName(threadName(pullRequest, repository)) + const webhook = await getOrCreateWebhook(thread) + await webhook.editMessage(prBody, { content: updatedMessage }) } -const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => { - const { issue, comment } = payload - - // Only handle PR comments - if (!issue.pull_request) return - +const handleIssueCommentCreated = async (issue: Gitea.Issue, comment: Gitea.Comment) => { // Check if this comment already exists (came from Discord originally) try { getCommentByGiteaId(comment.id) @@ -97,15 +95,7 @@ const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => { // Comment doesn't exist - this is a new Gitea comment, post to Discord } - const row = getPRByGiteaId(issue.id) - const thread = await client.channels.fetch(row.discord_thread_id) - - if (!thread?.isThread()) { - throw new Error( - `Discord channel ${row.discord_thread_id} is not a thread for comment on PR #${issue.number}` - ) - } - + const thread = await getThread(issue) const webhook = await getOrCreateWebhook(thread) const message = await webhook.send({ content: await formatComment(comment), @@ -117,19 +107,9 @@ const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => { insertComment(comment.id, message.id, issue.id) } -const handleIssueCommentEdited = async (payload: IssueCommentWebhook) => { - const { issue, comment } = payload - - // Only handle PR comments - if (!issue.pull_request) return - +const handleIssueCommentEdited = async (issue: Gitea.Issue, comment: Gitea.Comment) => { const commentRow = getCommentByGiteaId(comment.id) - const prRow = getPRByGiteaId(issue.id) - - const thread = await client.channels.fetch(prRow.discord_thread_id) - if (!thread?.isThread()) { - throw new Error(`Discord thread ${prRow.discord_thread_id} not found for edited comment`) - } + const thread = await getThread(issue) const discordMessage = await thread.messages.fetch(commentRow.discord_message_id) if (!discordMessage) { @@ -138,7 +118,85 @@ const handleIssueCommentEdited = async (payload: IssueCommentWebhook) => { const webhook = await getOrCreateWebhook(thread) const content = await formatComment(comment) - await webhook.editMessage(commentRow.discord_message_id, { content }) + await webhook.editMessage(commentRow.discord_message_id, { content, threadId: thread.id }) +} + +const handleIssueDeleted = async (issue: Gitea.Issue, comment: Gitea.Comment) => { + const commentRow = getCommentByGiteaId(comment.id) + const thread = await getThread(issue) + + const discordMessage = await thread.messages.fetch(commentRow.discord_message_id) + if (!discordMessage) { + throw new Error(`Discord message ${commentRow.discord_message_id} not found`) + } + + const webhook = await getOrCreateWebhook(thread) + await webhook.editMessage(commentRow.discord_message_id, { content: `[DELETED]`, threadId: thread.id }) +} + +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 handlePullRequestOpened(pr, repository) + + return true +} + +const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => { + const row = getPRByGiteaId(pullRequest.id) + if (!row) { + throw new Error(`No PR found in database for Gitea PR ID ${pullRequest.id}`) + } + + const thread = await client.channels.fetch(row.discord_thread_id) + if (!thread?.isThread()) { + throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`) + } + + return thread +} + +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" + } + + console.log(`🌭 `, { state, merged: pullRequest.merged }) + + 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 +} + +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) +} + +type PullRequestState = "closed" | "merged" | "opened" +const handlePullRequestStateChange = async (pullRequest: Gitea.PullRequest, repository: Gitea.Repository) => { + const thread = await getThread(pullRequest) + + await thread.setName(threadName(pullRequest, repository)) } const getOrCreateWebhook = async (threadOrChannel: ThreadChannel | Channel) => { @@ -160,7 +218,7 @@ const getOrCreateWebhook = async (threadOrChannel: ThreadChannel | Channel) => { return webhook } -const formatPrMessage = (pullRequest: GiteaPullRequest, repositoryFullName: string): string => { +const formatPrBody = (pullRequest: Gitea.PullRequest, repositoryFullName: string): string => { const body = pullRequest.body || "_empty_" return ` > ### [${pullRequest.title}](<${pullRequest.html_url}>) @@ -173,7 +231,7 @@ ${body ` } -const formatComment = async (comment: { body: string; user: GiteaUser }) => { +const formatComment = async (comment: { body: string; user: Gitea.User }) => { return convertMentionsToDiscord(comment.body) } @@ -195,10 +253,6 @@ const getDiscordUser = async (discordUsername: string) => { const cachedUser = client.users.cache.find((user) => user.username === discordUsername) if (cachedUser) return cachedUser - const channelId = getConfig("channelId") - const channel = await client.channels.fetch(channelId) - if (!channel || !("guild" in channel)) return undefined - const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 }) const member = members.first() return member?.user ?? undefined @@ -214,11 +268,6 @@ const getDiscordUserId = async (discordUsername: string): Promise } // Fetch from Discord - const channelId = getConfig("channelId") - const channel = await client.channels.fetch(channelId) - - if (!channel || !("guild" in channel)) return null - const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 }) const member = members.first() @@ -253,52 +302,19 @@ const convertMentionsToDiscord = async (text: string): Promise => { return result } -interface GiteaUser { - id: number - login: string - username: string -} - -interface GiteaPullRequest { - id: number - number: number - title: string - body: string - user: GiteaUser - html_url: string - state: string - merged: boolean -} - -interface GiteaRepository { - name: string - full_name: string -} - interface PullRequestWebhook { - action: "opened" | "closed" | "edited" | "synchronized" | "reviewed" + action: "opened" | "closed" | "edited" | "synchronized" | "reviewed" | "reopened" number: number - pull_request: GiteaPullRequest - repository: GiteaRepository - sender: GiteaUser + pull_request: Gitea.PullRequest + repository: Gitea.Repository + sender: Gitea.User } interface IssueCommentWebhook { action: "created" | "edited" | "deleted" - issue: { - id: number - number: number - pull_request?: { - html_url: string - } - } - comment: { - id: number - body: string - user: GiteaUser - html_url: string - } - repository: GiteaRepository + issue: Gitea.Issue + comment: Gitea.Comment + repository: Gitea.Repository } type GiteaWebhook = PullRequestWebhook | IssueCommentWebhook diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server.tsx index 475d4fd..8f62824 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server.tsx @@ -34,7 +34,8 @@ const server = serve({ payload, } errors.push(errorLog) - console.error("Webhook error:", errorLog) + console.error("💥 Webhook error 💥") + console.error(error) return new Response("Error", { status: 500 }) } },