Much better

This commit is contained in:
Corey Johnson 2025-11-12 16:38:33 -08:00
parent fd939af0b7
commit 34de1f8e40
7 changed files with 217 additions and 147 deletions

View File

@ -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!**

View File

@ -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,

View File

@ -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)
}

View File

@ -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)

View File

@ -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<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) {
throw new Error(`Gitea API error: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<Gitea.PullRequest>
}

View File

@ -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<string | null>
}
// 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<string> => {
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

View File

@ -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 })
}
},