Good enough gitea/discord integration #6
|
|
@ -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 (?, ?, ?)`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user