better
Some checks failed
CI / test (pull_request) Has been cancelled

This commit is contained in:
Corey Johnson 2025-11-13 14:53:34 -08:00
parent f156e91bcd
commit 9e7eb70b4f
6 changed files with 200 additions and 30 deletions

View File

@ -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 (?, ?, ?)`,

View File

@ -62,3 +62,48 @@ export const fetchPR = async (fullname: string, prNumber: number): Promise<Gitea
return response.json() as Promise<Gitea.PullRequest>
}
export const fetchReviewComments = async (
fullname: string,
prNumber: number
): Promise<Gitea.ReviewComment[]> => {
// 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
}

View File

@ -126,7 +126,6 @@ export const convertDiscordMentionsToGitea = async (content: string): Promise<st
if (!user) continue
const giteaUsername = convertUsername({ discordUsername: user.displayName })
console.log(`🌭`, { userId, user, giteaUsername })
result = result.replace(match[0], `@${giteaUsername}`)
}

View File

@ -362,5 +362,37 @@ export namespace Gitea {
sender: WebhookUser
}
export type Webhook = PullRequestWebhook | IssueCommentWebhook
export interface PullRequestReviewWebhook {
action: "reviewed"
number: number
pull_request: WebhookPullRequest
repository: WebhookRepository
sender: WebhookUser
commit_id: string
review: {
type: string
content: string
}
}
export interface ReviewComment {
id: number
body: string
user: User
html_url: string
created_at: string
updated_at: string
path: string
commit_id: string
original_commit_id: string
diff_hunk: string
position?: number
original_position?: number
line?: number
old_line_num?: number
pull_request_review_id: number
pull_request_url: string
}
export type Webhook = PullRequestWebhook | IssueCommentWebhook | PullRequestReviewWebhook
}

View File

@ -10,21 +10,36 @@ import {
} from "./helpers"
import type { Gitea } from "./types"
import { getConfig } from "../config"
import { fetchReviewComments } from "./api"
export const handleGiteaWebhook = async (payload: Gitea.Webhook) => {
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<string> {
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")
}
}

View File

@ -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 = {