From 9e7eb70b4fef11627f79d6b895bff7e4e156ba2e Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Thu, 13 Nov 2025 14:53:34 -0800 Subject: [PATCH] better --- packages/spike/src/db.ts | 21 +--- packages/spike/src/gitea/api.ts | 45 +++++++ packages/spike/src/gitea/helpers.ts | 1 - packages/spike/src/gitea/types.ts | 34 +++++- packages/spike/src/gitea/webhook-handler.ts | 123 ++++++++++++++++++-- packages/spike/src/server.tsx | 6 +- 6 files changed, 200 insertions(+), 30 deletions(-) diff --git a/packages/spike/src/db.ts b/packages/spike/src/db.ts index 500bd13..bf322da 100644 --- a/packages/spike/src/db.ts +++ b/packages/spike/src/db.ts @@ -23,14 +23,6 @@ db.run(` ); `) -// Migration: Add discord_thread_id to existing comments tables -try { - db.run(`ALTER TABLE comments ADD COLUMN discord_thread_id TEXT`) - console.log(`📊 Migration: Added discord_thread_id column to comments table`) -} catch (e) { - // Column already exists, ignore error -} - console.log(`📊 Database initialized at ${dbPath}`) // PR operations @@ -61,10 +53,9 @@ export const getPRByGiteaId = (giteaPrId: number) => { 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 = ?`) + .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 @@ -72,11 +63,7 @@ export const getPRByDiscordThreadId = (discordThreadId: string) => { } // Comment operations -export const 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 (?, ?, ?)`, diff --git a/packages/spike/src/gitea/api.ts b/packages/spike/src/gitea/api.ts index ee99863..1bab7e5 100644 --- a/packages/spike/src/gitea/api.ts +++ b/packages/spike/src/gitea/api.ts @@ -62,3 +62,48 @@ export const fetchPR = async (fullname: string, prNumber: number): Promise } + +export const fetchReviewComments = async ( + fullname: string, + prNumber: number +): Promise => { + // First, fetch all reviews + const reviewsUrl = `${giteaUrl}/api/v1/repos/${fullname}/pulls/${prNumber}/reviews` + 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 + user: Gitea.User + body: string + created_at: string + html_url: string + 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 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 +} diff --git a/packages/spike/src/gitea/helpers.ts b/packages/spike/src/gitea/helpers.ts index 779d22d..7fa49e2 100644 --- a/packages/spike/src/gitea/helpers.ts +++ b/packages/spike/src/gitea/helpers.ts @@ -126,7 +126,6 @@ export const convertDiscordMentionsToGitea = async (content: string): Promise { - const isDev = process.env.NODE_ENV !== "production" - if (!isDev && payload.repository.name === "ignore-me") return +type EventType = "issue_comment" | "pull_request" | "pull_request_comment" +export const handleGiteaWebhook = async (payload: unknown, eventType: EventType) => { + // console.log(`🌭`, JSON.stringify(payload, null, 2)) - if (PRHandler.canHandle(payload)) { + if (ignorePayload(payload)) { + return + } else if (PRHandler.canHandle(payload, eventType)) { await PRHandler.handle(payload) - } else if (CommentHandler.canHandle(payload)) { + } else if (ReviewHandler.canHandle(payload, eventType)) { + await ReviewHandler.handle(payload) + } else if (CommentHandler.canHandle(payload, eventType)) { await CommentHandler.handle(payload) } } +const ignorePayload = (payload: any) => { + const isDev = process.env.NODE_ENV !== "production" + const repositoryName = payload?.repository?.name + if (isDev) { + return repositoryName !== "ignore-me" + } else { + return repositoryName === "ignore-me" + } +} + export class PRHandler { - static canHandle(payload: Gitea.Webhook): payload is Gitea.PullRequestWebhook { - return "pull_request" in payload + static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestWebhook { + return eventType === "pull_request" } static async handle(payload: Gitea.PullRequestWebhook) { @@ -104,8 +119,8 @@ export class PRHandler { } class CommentHandler { - static canHandle(payload: Gitea.Webhook): payload is Gitea.IssueCommentWebhook { - return "comment" in payload && "issue" in payload + static canHandle(payload: unknown, eventType: EventType): payload is Gitea.IssueCommentWebhook { + return eventType === "issue_comment" } static async handle(payload: Gitea.IssueCommentWebhook) { @@ -201,3 +216,93 @@ class CommentHandler { return result } } + +class ReviewHandler { + static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestReviewWebhook { + return eventType === "pull_request_comment" + } + + static async handle(payload: Gitea.PullRequestReviewWebhook) { + const { pull_request: pullRequest, repository, review, action } = payload + + // Only handle new reviews, ignore edits + if (action !== "reviewed") { + console.log(`✅ review webhook action: ${action} on #${pullRequest.number} (ignored)`) + return + } + + await ensureThreadExists(pullRequest as any, repository as any) + + const thread = await getThread(pullRequest) + if (!thread) { + throw new Error(`No thread found for PR #${pullRequest.number} when handling review`) + } + + const webhook = await getWebhook() + const discordUsername = convertUsername({ giteaUsername: payload.sender.login }) + + // Post the review body if it exists + if (review.content) { + const reviewMessage = (await CommentHandler.formatComment(review.content)) + .split("\n") + .map((s) => `> ${s}`) + .join("\n") + + await webhook.send({ + content: `**Review:**\n${reviewMessage}\n${payload.review.type}`, + username: discordUsername, + avatarURL: await getDiscordAvatarUrl(discordUsername), + threadId: thread.id, + }) + } + + // Fetch and post all review comments + const reviewComments = await fetchReviewComments(repository.full_name, pullRequest.number) + + for (const comment of reviewComments) { + const existingComment = getCommentByGiteaId(comment.id) + if (existingComment) { + continue // Skip if already posted + } + + const formattedComment = await this.formatReviewComment(comment) + const message = await webhook.send({ + content: formattedComment, + username: discordUsername, + avatarURL: await getDiscordAvatarUrl(discordUsername), + threadId: thread.id, + }) + + insertComment(comment.id, message.id, thread.id) + } + + console.log(`✅ review webhook action: reviewed on #${pullRequest.number}`) + } + + static async formatReviewComment(comment: Gitea.ReviewComment): Promise { + const formattedBody = await CommentHandler.formatComment(comment.body) + const codeContext = this.extractCodeFromDiff(comment.diff_hunk) + + return ` +[**${comment.path}**](<${comment.html_url}>) +\`\`\`diff +${codeContext} +\`\`\` +${formattedBody} +`.trim() + } + + static extractCodeFromDiff(diffHunk: string): string { + // diff_hunk format is like: + // @@ -1,3 +1,3 @@ + // line1 + // -old line + // +new line + // line2 + + const lines = diffHunk.split("\n") + const codeLines = lines.filter((line) => !line.startsWith("@@")) + + return codeLines.join("\n") + } +} diff --git a/packages/spike/src/server.tsx b/packages/spike/src/server.tsx index 8f62824..0e141ff 100644 --- a/packages/spike/src/server.tsx +++ b/packages/spike/src/server.tsx @@ -1,6 +1,7 @@ import { serve } from "bun" import { handleGiteaWebhook } from "./gitea/webhook-handler" import "./discord/index" // Make suer the discord client is initialized +import { getConfig } from "./config" interface ErrorLog { timestamp: string @@ -21,10 +22,11 @@ const server = serve({ "/gitea/webhook": { POST: async (req) => { const payload = await req.json() - console.log(`🌵 Received Gitea webhook`) + const eventType = req.headers.get("X-Gitea-Event") || "unknown" + console.log(`🌵 Received Gitea webhook ${eventType}`) try { - await handleGiteaWebhook(payload as any) + await handleGiteaWebhook(payload, eventType as any) return new Response("OK", { status: 200 }) } catch (error) { const errorLog: ErrorLog = {