Compare commits
14 Commits
discord-te
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb401f8252 | |||
| 78c0dcf2e5 | |||
| e46fb0dd7a | |||
| d169c19e8a | |||
| 9e7eb70b4f | |||
| f156e91bcd | |||
| e2feb9cc1a | |||
| 34de1f8e40 | |||
| fd939af0b7 | |||
| 1bd8ecba7a | |||
| 726961ca99 | |||
| c85c61ed95 | |||
| c2cf89f1d2 | |||
| f404f7c99b |
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
.nano-remix
|
||||
.nano-remix
|
||||
|
||||
local-spike.db
|
||||
|
|
@ -32,4 +32,4 @@ import { something } from "@workshop/WHATEVER_YOU_WANT"
|
|||
|
||||
## How do I run the tests?
|
||||
|
||||
😂😂😂
|
||||
😂😂😂😂😂
|
||||
|
|
|
|||
5
main.ts
5
main.ts
|
|
@ -41,10 +41,7 @@ try {
|
|||
const isDev = process.env.NODE_ENV !== "production"
|
||||
const noElide = isDev ? "--elide-lines=0" : ""
|
||||
|
||||
procs = await Promise.all([
|
||||
run(["bun", "run", noElide, "--filter=@workshop/http", "start"]),
|
||||
run(["bun", "run", noElide, "--filter=@workshop/spike", "bot:discord"]),
|
||||
])
|
||||
procs = await Promise.all([run(["bun", "run", noElide, "--filter=@workshop/http", "start"])])
|
||||
console.log("✅ All processes completed successfully")
|
||||
} catch (error) {
|
||||
console.error("❌ One or more processes failed:", error)
|
||||
|
|
|
|||
|
|
@ -7,10 +7,8 @@
|
|||
1. Install deps with `bun install`.
|
||||
1. Setup a dummy Discord bot and get your DISCORD*TOKEN and CLIENT_ID at https://discord.com/developers/applications *(Corey doesn't remember how to do this all this, so if pair with him if you run into problems. Then document it in here).
|
||||
1. `cp .env.example .env` and fill in the values for everything.
|
||||
1. Find a Discord channel id to use for testing and set it in `.env` as `CHANNEL_ID`.
|
||||
1. There is a very simple auth server you can use to invite the bot to your server. Run it with `bun authServer`.
|
||||
1. Run the bot with `bun bot:discord`
|
||||
|
||||
# Before deploying
|
||||
|
||||
- Add the env var for the gitea token to render
|
||||
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 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!**
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -21,12 +21,12 @@ const config = {
|
|||
dev: "/Users/corey/code/tmp/data",
|
||||
prod: "/var/data",
|
||||
},
|
||||
userMappings: {
|
||||
giteaToDiscordUserMappings: {
|
||||
dev: {
|
||||
probablycorey: "corey",
|
||||
} as Record<string, string>,
|
||||
prod: {
|
||||
probablycorey: "probablycorey",
|
||||
probablycorey: "corey",
|
||||
defunkt: "defunkt",
|
||||
} as Record<string, string>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,18 +18,8 @@ db.run(`
|
|||
CREATE TABLE IF NOT EXISTS comments (
|
||||
gitea_comment_id INTEGER PRIMARY KEY,
|
||||
discord_message_id TEXT NOT NULL,
|
||||
gitea_pr_id INTEGER NOT NULL,
|
||||
parent_comment_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (gitea_pr_id) REFERENCES prs(gitea_pr_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_pr_id INTEGER NOT NULL,
|
||||
discord_user_id TEXT NOT NULL,
|
||||
UNIQUE(gitea_pr_id, discord_user_id),
|
||||
FOREIGN KEY (gitea_pr_id) REFERENCES prs(gitea_pr_id)
|
||||
discord_thread_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
|
||||
|
|
@ -51,67 +41,42 @@ 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; 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
|
||||
return row
|
||||
}
|
||||
|
||||
// Comment operations
|
||||
export const insertComment = (
|
||||
giteaCommentId: number,
|
||||
discordMessageId: string,
|
||||
giteaPrId: number,
|
||||
parentCommentId?: number
|
||||
) => {
|
||||
export const insertComment = (giteaCommentId: number, discordMessageId: string, discordThreadId: string) => {
|
||||
db.run(
|
||||
`INSERT INTO comments (gitea_comment_id, discord_message_id, gitea_pr_id, parent_comment_id)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[giteaCommentId, discordMessageId, giteaPrId, parentCommentId || null]
|
||||
`INSERT INTO comments (gitea_comment_id, discord_message_id, discord_thread_id)
|
||||
VALUES (?, ?, ?)`,
|
||||
[giteaCommentId, discordMessageId, discordThreadId]
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`No database entry found for Gitea comment ID ${giteaCommentId}`)
|
||||
}
|
||||
const row = db
|
||||
.query<{ discord_message_id: string; discord_thread_id: string }, number>(
|
||||
`SELECT discord_message_id, discord_thread_id FROM comments WHERE gitea_comment_id = ?`
|
||||
)
|
||||
.get(giteaCommentId)
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// Participant operations
|
||||
export const addParticipant = (giteaPrId: number, discordUserId: string) => {
|
||||
db.run(
|
||||
`INSERT OR IGNORE INTO participants (gitea_pr_id, discord_user_id)
|
||||
VALUES (?, ?)`,
|
||||
[giteaPrId, discordUserId]
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ActivityType, type Client } from "discord.js"
|
|||
import { runCommand } from "./commands"
|
||||
import { getPRByDiscordThreadId } from "../db"
|
||||
import { createPRComment } from "../gitea/api"
|
||||
import { getConfig } from "../config"
|
||||
import { convertUsername, convertDiscordMentionsToGitea } from "../gitea/helpers"
|
||||
|
||||
export const listenForEvents = (client: Client) => {
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
|
|
@ -16,15 +16,17 @@ export const listenForEvents = (client: Client) => {
|
|||
// Check if this is a PR thread
|
||||
const prData = getPRByDiscordThreadId(msg.channel.id)
|
||||
if (prData) {
|
||||
const message = `**${mapDiscordUserToGitea(msg.author.username)}**: ${msg.content}`
|
||||
const username = convertUsername({ discordUsername: msg.author.displayName })
|
||||
const messageContent = await convertDiscordMentionsToGitea(msg.content)
|
||||
const message = `**${username}**: ${messageContent}`
|
||||
await createPRComment(prData, message, msg.id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -51,14 +53,3 @@ export const listenForEvents = (client: Client) => {
|
|||
console.warn("Discord client warning:", info)
|
||||
})
|
||||
}
|
||||
|
||||
const mapDiscordUserToGitea = (username: string): string => {
|
||||
const userMappings = getConfig("userMappings")
|
||||
for (const [giteaUser, discordUser] of Object.entries(userMappings)) {
|
||||
if (discordUser === username) {
|
||||
return giteaUser
|
||||
}
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { insertComment } from "../db"
|
||||
import { type Gitea } from "./types"
|
||||
|
||||
const giteaUrl = "https://git.nose.space"
|
||||
|
||||
interface CreateCommentResponse {
|
||||
type CreateCommentResponse = {
|
||||
id: number
|
||||
body: string
|
||||
user: {
|
||||
|
|
@ -13,7 +14,7 @@ interface CreateCommentResponse {
|
|||
}
|
||||
|
||||
export const createPRComment = async (
|
||||
prData: { gitea_pr_id: number; repo: string; pr_number: number },
|
||||
prData: { gitea_pr_id: number; repo: string; pr_number: number; discord_thread_id: string },
|
||||
body: string,
|
||||
discordMessageId: string
|
||||
): Promise<CreateCommentResponse> => {
|
||||
|
|
@ -43,7 +44,66 @@ export const createPRComment = async (
|
|||
const comment = (await response.json()) as CreateCommentResponse
|
||||
|
||||
// Store the mapping immediately so webhook handler knows this came from Discord
|
||||
insertComment(comment.id, discordMessageId, prData.gitea_pr_id)
|
||||
insertComment(comment.id, discordMessageId, prData.discord_thread_id)
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
149
packages/spike/src/gitea/helpers.ts
Normal file
149
packages/spike/src/gitea/helpers.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { client } from "../discord/index"
|
||||
import { getPRByGiteaId } from "../db"
|
||||
import { getConfig } from "../config"
|
||||
import { ChannelType, TextChannel } from "discord.js"
|
||||
import { assertNever } from "@workshop/shared/utils"
|
||||
import { fetchPR } from "./api"
|
||||
import type { Gitea } from "./types"
|
||||
import { PRHandler } from "./webhook-handler"
|
||||
|
||||
export 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 PRHandler.handleOpened(pr, repository)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const getThread = async (pullRequest: Gitea.PullRequest | Gitea.Issue) => {
|
||||
const row = getPRByGiteaId(pullRequest.id)
|
||||
if (!row) {
|
||||
return
|
||||
}
|
||||
|
||||
const thread = await client.channels.fetch(row.discord_thread_id)
|
||||
if (!thread) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!thread.isThread()) {
|
||||
throw new Error(`Discord channel ${row.discord_thread_id} is not a thread for PR #${pullRequest.number}`)
|
||||
}
|
||||
|
||||
return thread
|
||||
}
|
||||
|
||||
type PullRequestState = "closed" | "merged" | "opened"
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export const getWebhook = async () => {
|
||||
const channel = await getChannel()
|
||||
const webhooks = await channel.fetchWebhooks()
|
||||
let webhook = webhooks.find((wh) => wh.owner?.id === client.user?.id)
|
||||
|
||||
if (!webhook) {
|
||||
webhook = await channel.createWebhook({
|
||||
name: "Gitea Bridge",
|
||||
reason: "Proxy Gitea comments to Discord",
|
||||
})
|
||||
}
|
||||
|
||||
return webhook
|
||||
}
|
||||
|
||||
export const getDiscordAvatarUrl = async (discordUsername: string) => {
|
||||
const discordUser = await getDiscordUser(discordUsername)
|
||||
return discordUser?.avatarURL() || undefined
|
||||
}
|
||||
|
||||
export const convertUsername = (opts: { discordUsername: string } | { giteaUsername: string }): string => {
|
||||
const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings")
|
||||
let convertedUsername: string | undefined
|
||||
let username: string
|
||||
if ("giteaUsername" in opts) {
|
||||
username = opts.giteaUsername
|
||||
convertedUsername = giteaToDiscordUserMappings[username]
|
||||
} else {
|
||||
username = opts.discordUsername
|
||||
const entries = Object.entries(giteaToDiscordUserMappings)
|
||||
const result = entries.find(([_, discordUsername]) => discordUsername === username)
|
||||
if (result) convertedUsername = result[0]
|
||||
}
|
||||
|
||||
return convertedUsername || `💥${username}💥`
|
||||
}
|
||||
|
||||
export const getDiscordUser = async (discordUsername: string) => {
|
||||
const cachedUser = client.users.cache.find((user) => user.username === discordUsername)
|
||||
if (cachedUser) return cachedUser
|
||||
|
||||
const channel = await getChannel()
|
||||
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
|
||||
const member = members.first()
|
||||
return member?.user ?? undefined
|
||||
}
|
||||
|
||||
export const convertDiscordMentionsToGitea = async (content: string): Promise<string> => {
|
||||
const mentionRegex = /<@(\d+)>/g
|
||||
const mentions = [...content.matchAll(mentionRegex)]
|
||||
|
||||
let result = content
|
||||
for (const match of mentions) {
|
||||
const userId = match[1]!
|
||||
const user = await client.users.fetch(userId).catch(() => null)
|
||||
if (!user) continue
|
||||
|
||||
const giteaUsername = convertUsername({ discordUsername: user.displayName })
|
||||
result = result.replace(match[0], `@${giteaUsername}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let channel: TextChannel | null = null
|
||||
const getChannel = async (): Promise<TextChannel> => {
|
||||
if (channel) return channel
|
||||
|
||||
const channelId = getConfig("channelId")
|
||||
const foundChannel = await client.channels.fetch(channelId)
|
||||
if (foundChannel?.type !== ChannelType.GuildText) {
|
||||
throw new Error(
|
||||
`Discord channel ${channelId} (from config.ts) is type ${foundChannel?.type}, expected GuildText (0)`
|
||||
)
|
||||
}
|
||||
|
||||
channel = foundChannel as TextChannel
|
||||
return channel
|
||||
}
|
||||
398
packages/spike/src/gitea/types.ts
Normal file
398
packages/spike/src/gitea/types.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
// Types borrowed from gitea-js@1.23.0
|
||||
// https://github.com/anbraten/gitea-js
|
||||
|
||||
export namespace Gitea {
|
||||
export interface User {
|
||||
id: number
|
||||
login: string
|
||||
full_name: string
|
||||
avatar_url: string
|
||||
active?: boolean
|
||||
created?: string
|
||||
description?: string
|
||||
email?: string
|
||||
followers_count?: number
|
||||
following_count?: number
|
||||
html_url?: string
|
||||
is_admin?: boolean
|
||||
language?: string
|
||||
last_login?: string
|
||||
location?: string
|
||||
login_name?: string
|
||||
prohibit_login?: boolean
|
||||
restricted?: boolean
|
||||
source_id?: number
|
||||
starred_repos_count?: number
|
||||
visibility?: string
|
||||
website?: string
|
||||
}
|
||||
|
||||
export interface PullRequest {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
user: User
|
||||
html_url: string
|
||||
state: StateType
|
||||
merged: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
draft: boolean
|
||||
base: PRBranchInfo
|
||||
head: PRBranchInfo
|
||||
additions?: number
|
||||
allow_maintainer_edit?: boolean
|
||||
assignee?: User
|
||||
assignees?: User[]
|
||||
changed_files?: number
|
||||
closed_at?: string
|
||||
comments?: number
|
||||
deletions?: number
|
||||
diff_url?: string
|
||||
due_date?: string
|
||||
is_locked?: boolean
|
||||
labels?: Label[]
|
||||
merge_base?: string
|
||||
merge_commit_sha?: string
|
||||
mergeable?: boolean
|
||||
merged_at?: string
|
||||
merged_by?: User
|
||||
milestone?: Milestone
|
||||
patch_url?: string
|
||||
pin_order?: number
|
||||
requested_reviewers?: User[]
|
||||
requested_reviewers_teams?: Team[]
|
||||
review_comments?: number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
user: User
|
||||
html_url: string
|
||||
state: StateType
|
||||
created_at: string
|
||||
updated_at: string
|
||||
assets?: Attachment[]
|
||||
assignee?: User
|
||||
assignees?: User[]
|
||||
closed_at?: string
|
||||
comments?: number
|
||||
due_date?: string
|
||||
is_locked?: boolean
|
||||
labels?: Label[]
|
||||
milestone?: Milestone
|
||||
original_author?: string
|
||||
original_author_id?: number
|
||||
pin_order?: number
|
||||
pull_request?: PullRequestMeta
|
||||
ref?: string
|
||||
repository?: RepositoryMeta
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number
|
||||
body: string
|
||||
user: User
|
||||
html_url: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
assets?: Attachment[]
|
||||
issue_url?: string
|
||||
original_author?: string
|
||||
original_author_id?: number
|
||||
pull_request_url?: string
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: number
|
||||
name: string
|
||||
full_name: string
|
||||
owner: User
|
||||
html_url: string
|
||||
private: boolean
|
||||
allow_fast_forward_only_merge?: boolean
|
||||
allow_merge_commits?: boolean
|
||||
allow_rebase?: boolean
|
||||
allow_rebase_explicit?: boolean
|
||||
allow_rebase_update?: boolean
|
||||
allow_squash_merge?: boolean
|
||||
archived?: boolean
|
||||
archived_at?: string
|
||||
avatar_url?: string
|
||||
clone_url?: string
|
||||
created_at?: string
|
||||
default_allow_maintainer_edit?: boolean
|
||||
default_branch?: string
|
||||
default_delete_branch_after_merge?: boolean
|
||||
default_merge_style?: string
|
||||
description?: string
|
||||
empty?: boolean
|
||||
external_tracker?: ExternalTracker
|
||||
external_wiki?: ExternalWiki
|
||||
fork?: boolean
|
||||
forks_count?: number
|
||||
has_actions?: boolean
|
||||
has_issues?: boolean
|
||||
has_packages?: boolean
|
||||
has_projects?: boolean
|
||||
has_pull_requests?: boolean
|
||||
has_releases?: boolean
|
||||
has_wiki?: boolean
|
||||
ignore_whitespace_conflicts?: boolean
|
||||
internal?: boolean
|
||||
internal_tracker?: InternalTracker
|
||||
language?: string
|
||||
languages_url?: string
|
||||
licenses?: string[]
|
||||
link?: string
|
||||
mirror?: boolean
|
||||
mirror_interval?: string
|
||||
mirror_updated?: string
|
||||
object_format_name?: "sha1" | "sha256"
|
||||
open_issues_count?: number
|
||||
open_pr_counter?: number
|
||||
original_url?: string
|
||||
parent?: Repository
|
||||
permissions?: Permission
|
||||
projects_mode?: string
|
||||
release_counter?: number
|
||||
repo_transfer?: RepoTransfer
|
||||
size?: number
|
||||
ssh_url?: string
|
||||
stars_count?: number
|
||||
template?: boolean
|
||||
topics?: string[]
|
||||
updated_at?: string
|
||||
url?: string
|
||||
watchers_count?: number
|
||||
website?: string
|
||||
}
|
||||
|
||||
// Supporting types
|
||||
export type StateType = "open" | "closed" | "all"
|
||||
|
||||
export interface PRBranchInfo {
|
||||
label?: string
|
||||
ref?: string
|
||||
repo?: Repository
|
||||
repo_id?: number
|
||||
sha?: string
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
color?: string
|
||||
description?: string
|
||||
exclusive?: boolean
|
||||
id?: number
|
||||
is_archived?: boolean
|
||||
name?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface Milestone {
|
||||
closed_at?: string
|
||||
closed_issues?: number
|
||||
created_at?: string
|
||||
description?: string
|
||||
due_on?: string
|
||||
id?: number
|
||||
open_issues?: number
|
||||
state?: StateType
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
can_create_org_repo?: boolean
|
||||
description?: string
|
||||
id?: number
|
||||
includes_all_repositories?: boolean
|
||||
name?: string
|
||||
organization?: Organization
|
||||
permission?: "none" | "read" | "write" | "admin" | "owner"
|
||||
units?: string[]
|
||||
units_map?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
avatar_url?: string
|
||||
description?: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
id?: number
|
||||
location?: string
|
||||
name?: string
|
||||
repo_admin_change_team_access?: boolean
|
||||
username?: string
|
||||
visibility?: string
|
||||
website?: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
browser_download_url?: string
|
||||
created_at?: string
|
||||
download_count?: number
|
||||
id?: number
|
||||
name?: string
|
||||
size?: number
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
export interface PullRequestMeta {
|
||||
merged?: boolean
|
||||
merged_at?: string
|
||||
html_url?: string
|
||||
}
|
||||
|
||||
export interface RepositoryMeta {
|
||||
full_name?: string
|
||||
id?: number
|
||||
name?: string
|
||||
owner?: string
|
||||
}
|
||||
|
||||
export interface ExternalTracker {
|
||||
external_tracker_format?: string
|
||||
external_tracker_regexp_pattern?: string
|
||||
external_tracker_style?: string
|
||||
external_tracker_url?: string
|
||||
}
|
||||
|
||||
export interface ExternalWiki {
|
||||
external_wiki_url?: string
|
||||
}
|
||||
|
||||
export interface InternalTracker {
|
||||
allow_only_contributors_to_track_time?: boolean
|
||||
enable_issue_dependencies?: boolean
|
||||
enable_time_tracker?: boolean
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
admin?: boolean
|
||||
pull?: boolean
|
||||
push?: boolean
|
||||
}
|
||||
|
||||
export interface RepoTransfer {
|
||||
doer?: User
|
||||
recipient?: User
|
||||
teams?: Team[]
|
||||
}
|
||||
|
||||
// Webhook-specific stricter types (fields that are always present in webhooks)
|
||||
export interface WebhookUser {
|
||||
id: number
|
||||
login: string
|
||||
full_name: string
|
||||
avatar_url: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface WebhookRepository {
|
||||
id: number
|
||||
name: string
|
||||
full_name: string
|
||||
owner: User
|
||||
html_url: string
|
||||
description?: string
|
||||
private: boolean
|
||||
}
|
||||
|
||||
export interface WebhookPullRequest {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
user: WebhookUser
|
||||
html_url: string
|
||||
state: StateType
|
||||
merged: boolean
|
||||
merged_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
closed_at?: string
|
||||
draft: boolean
|
||||
base: PRBranchInfo
|
||||
head: PRBranchInfo
|
||||
}
|
||||
|
||||
export interface WebhookIssue {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
body: string
|
||||
user: WebhookUser
|
||||
html_url: string
|
||||
state: StateType
|
||||
pull_request?: PullRequestMeta
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface WebhookComment {
|
||||
id: number
|
||||
body: string
|
||||
user: WebhookUser
|
||||
html_url: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Webhook payload types (not from gitea-js, these are custom)
|
||||
export interface PullRequestWebhook {
|
||||
action: "opened" | "closed" | "edited" | "synchronized" | "reviewed" | "reopened"
|
||||
number: number
|
||||
pull_request: WebhookPullRequest
|
||||
repository: WebhookRepository
|
||||
sender: WebhookUser
|
||||
}
|
||||
|
||||
export interface IssueCommentWebhook {
|
||||
action: "created" | "edited" | "deleted"
|
||||
issue: WebhookIssue
|
||||
comment: WebhookComment
|
||||
repository: WebhookRepository
|
||||
sender: WebhookUser
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -1,256 +1,315 @@
|
|||
import { client } from "../discord/index"
|
||||
import { insertPR, getPRByGiteaId, insertComment, getCommentByGiteaId } from "../db"
|
||||
import { insertPR, insertComment, getCommentByGiteaId } from "../db"
|
||||
import {
|
||||
ensurePRCreatedOnDiscord as ensureThreadExists,
|
||||
getDiscordUser,
|
||||
getWebhook,
|
||||
getThread,
|
||||
getDiscordAvatarUrl,
|
||||
convertUsername,
|
||||
threadName,
|
||||
} from "./helpers"
|
||||
import type { Gitea } from "./types"
|
||||
import { getConfig } from "../config"
|
||||
import { ChannelType } from "discord.js"
|
||||
import { assertNever } from "@workshop/shared/utils"
|
||||
import { fetchReviewComments } from "./api"
|
||||
|
||||
export const handleGiteaWebhook = async (payload: GiteaWebhook) => {
|
||||
if ("pull_request" in payload) {
|
||||
if (payload.action === "opened") {
|
||||
await handlePullRequestOpened(payload)
|
||||
} else if (payload.action === "edited") {
|
||||
await handlePullRequestEdited(payload)
|
||||
}
|
||||
console.log(`✅ pull request webhook action: ${payload.action}`)
|
||||
} else if ("comment" in payload && "issue" in payload) {
|
||||
if (payload.action === "created") {
|
||||
await handleIssueCommentCreated(payload)
|
||||
} else if (payload.action === "edited") {
|
||||
await handleIssueCommentEdited(payload)
|
||||
}
|
||||
console.log(`✅ comment webhook action: ${payload.action}`)
|
||||
} else {
|
||||
assertNever(payload, "Unhandled Gitea webhook payload")
|
||||
}
|
||||
}
|
||||
type EventType = "issue_comment" | "pull_request" | "pull_request_comment"
|
||||
export const handleGiteaWebhook = async (payload: unknown, eventType: EventType) => {
|
||||
// console.log(`🌭`, 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})`
|
||||
)
|
||||
}
|
||||
|
||||
// Create thread name
|
||||
const authorMention = mapGiteaUserToDiscord(pullRequest.user.login)
|
||||
const threadName = `[${repository.name}] ${pullRequest.title} - ${authorMention}`
|
||||
|
||||
// Create the thread
|
||||
const thread = await channel.threads.create({
|
||||
name: threadName.slice(0, 100), // Discord has 100 char limit on thread names
|
||||
reason: `PR #${pullRequest.number} opened`,
|
||||
})
|
||||
|
||||
// Post the PR details as first message in thread
|
||||
const prMessage = formatPrMessage(pullRequest)
|
||||
|
||||
const message = await thread.send(prMessage)
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
await prMessage.edit(updatedMessage)
|
||||
}
|
||||
|
||||
const handleIssueCommentCreated = async (payload: IssueCommentWebhook) => {
|
||||
const { issue, comment } = payload
|
||||
|
||||
// Only handle PR comments
|
||||
if (!issue.pull_request) return
|
||||
|
||||
// Check if this comment already exists (came from Discord originally)
|
||||
try {
|
||||
getCommentByGiteaId(comment.id)
|
||||
// Comment exists - this came from Discord, skip posting back
|
||||
console.log(`⏭️ Skipping comment ${comment.id} - already exists (came from Discord)`)
|
||||
if (ignorePayload(payload)) {
|
||||
return
|
||||
} catch {
|
||||
// Comment doesn't exist - this is a new Gitea comment, post to Discord
|
||||
} else if (PRHandler.canHandle(payload, eventType)) {
|
||||
await PRHandler.handle(payload)
|
||||
} else if (ReviewHandler.canHandle(payload, eventType)) {
|
||||
await ReviewHandler.handle(payload)
|
||||
} else if (CommentHandler.canHandle(payload, eventType)) {
|
||||
await CommentHandler.handle(payload)
|
||||
}
|
||||
|
||||
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 message = await thread.send(await formatComment(comment))
|
||||
|
||||
insertComment(comment.id, message.id, issue.id)
|
||||
}
|
||||
|
||||
const handleIssueCommentEdited = async (payload: IssueCommentWebhook) => {
|
||||
const { issue, comment } = 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"
|
||||
}
|
||||
}
|
||||
|
||||
// Only handle PR comments
|
||||
if (!issue.pull_request) return
|
||||
|
||||
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`)
|
||||
export class PRHandler {
|
||||
static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestWebhook {
|
||||
return eventType === "pull_request"
|
||||
}
|
||||
|
||||
const discordMessage = await thread.messages.fetch(commentRow.discord_message_id)
|
||||
if (!discordMessage) {
|
||||
throw new Error(`Discord message ${commentRow.discord_message_id} not found`)
|
||||
static async handle(payload: Gitea.PullRequestWebhook) {
|
||||
const { pull_request: pullRequest, repository, action } = payload
|
||||
|
||||
if (action === "opened") {
|
||||
await this.handleOpened(pullRequest, repository)
|
||||
} else if (action === "edited") {
|
||||
await this.handleEdited(pullRequest, repository)
|
||||
} else if (action === "closed" || action === "reopened") {
|
||||
await this.handleStateChange(pullRequest, repository)
|
||||
}
|
||||
|
||||
console.log(`✅ pull request webhook action: ${action} #${payload.number}`)
|
||||
}
|
||||
|
||||
await discordMessage.edit(await formatComment(comment))
|
||||
}
|
||||
static async handleOpened(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
|
||||
// Post the PR details as first message in thread
|
||||
const webhook = await getWebhook()
|
||||
const prMessage = this.formatPrBody(pullRequest, repository.full_name)
|
||||
const discordUsername = convertUsername({ giteaUsername: pullRequest.user.login })
|
||||
|
||||
const formatPrMessage = (pullRequest: GiteaPullRequest): string => {
|
||||
const authorMention = mapGiteaUserToDiscord(pullRequest.user.login)
|
||||
const message = await webhook.send({
|
||||
content: prMessage,
|
||||
username: discordUsername,
|
||||
avatarURL: await getDiscordAvatarUrl(discordUsername),
|
||||
})
|
||||
|
||||
return `
|
||||
const thread = await message.startThread({
|
||||
name: threadName(pullRequest, repository),
|
||||
reason: `PR #${pullRequest.number} opened`,
|
||||
})
|
||||
|
||||
${pullRequest.html_url}
|
||||
**${pullRequest.title}** by __${authorMention}__
|
||||
|
||||
> ${pullRequest.body || "_empty_"}
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
const formatComment = async (comment: { body: string; user: GiteaUser }): Promise<string> => {
|
||||
const authorName = mapGiteaUserToDiscord(comment.user.login)
|
||||
console.log(`🌭`, { authorName, comment: comment.user.login })
|
||||
const messageBody = await convertMentionsToDiscord(comment.body)
|
||||
|
||||
return `**${authorName}**: ${messageBody}`
|
||||
}
|
||||
|
||||
const mapGiteaUserToDiscord = (username: string): string => {
|
||||
const userMappings = getConfig("userMappings")
|
||||
const discordUserId = userMappings[username]
|
||||
|
||||
return discordUserId || `💥${username}💥`
|
||||
}
|
||||
|
||||
// Cache Discord username -> user ID
|
||||
const discordUserIdCache = new Map<string, string>()
|
||||
|
||||
const getDiscordUserId = async (discordUsername: string): Promise<string | null> => {
|
||||
// Check cache first
|
||||
if (discordUserIdCache.has(discordUsername)) {
|
||||
return discordUserIdCache.get(discordUsername)!
|
||||
insertPR(pullRequest.id, repository.full_name, pullRequest.number, thread.id, message.id)
|
||||
}
|
||||
|
||||
// Fetch from Discord
|
||||
const channelId = getConfig("channelId")
|
||||
const channel = await client.channels.fetch(channelId)
|
||||
static async handleEdited(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
|
||||
const thread = await getThread(pullRequest)
|
||||
if (!thread) {
|
||||
this.handleOpened(pullRequest, repository)
|
||||
return
|
||||
}
|
||||
|
||||
if (!channel || !("guild" in channel)) return null
|
||||
const prBody = await thread.fetchStarterMessage()
|
||||
if (!prBody) {
|
||||
throw new Error(`Could not fetch PR body for #${pullRequest.number}`)
|
||||
}
|
||||
|
||||
const members = await channel.guild.members.fetch({ query: discordUsername, limit: 1 })
|
||||
const member = members.first()
|
||||
|
||||
if (member) {
|
||||
discordUserIdCache.set(discordUsername, member.user.id)
|
||||
return member.user.id
|
||||
const updatedMessage = this.formatPrBody(pullRequest, repository.full_name)
|
||||
thread.setName(threadName(pullRequest, repository))
|
||||
const webhook = await getWebhook()
|
||||
await webhook.editMessage(prBody, { content: updatedMessage })
|
||||
}
|
||||
|
||||
return null
|
||||
static async handleStateChange(pullRequest: Gitea.PullRequest, repository: Gitea.Repository) {
|
||||
const thread = await getThread(pullRequest)
|
||||
if (!thread) {
|
||||
this.handleOpened(pullRequest, repository)
|
||||
return
|
||||
}
|
||||
|
||||
await thread.setName(threadName(pullRequest, repository))
|
||||
}
|
||||
|
||||
static formatPrBody(pullRequest: Gitea.PullRequest, repositoryFullName: string): string {
|
||||
const body = pullRequest.body || "_empty_"
|
||||
let message = `
|
||||
> ### [${pullRequest.title}](<${pullRequest.html_url}>)
|
||||
> **${repositoryFullName}**
|
||||
>
|
||||
${body
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}
|
||||
`
|
||||
|
||||
const maxMessageSize = 2000
|
||||
const truncateMessage = `\n_message truncated. View full PR on Gitea_`
|
||||
|
||||
if (message.length < maxMessageSize) return message
|
||||
|
||||
return message.slice(0, maxMessageSize - 1 - truncateMessage.length) + truncateMessage
|
||||
}
|
||||
}
|
||||
|
||||
const convertMentionsToDiscord = async (text: string): Promise<string> => {
|
||||
const userMappings = getConfig("userMappings")
|
||||
class CommentHandler {
|
||||
static canHandle(payload: unknown, eventType: EventType): payload is Gitea.IssueCommentWebhook {
|
||||
return eventType === "issue_comment"
|
||||
}
|
||||
|
||||
// Find all @username mentions
|
||||
const mentionRegex = /@(\w+)/g
|
||||
const mentions = [...text.matchAll(mentionRegex)]
|
||||
static async handle(payload: Gitea.IssueCommentWebhook) {
|
||||
const { issue, comment, repository, action } = payload
|
||||
|
||||
let result = text
|
||||
for (const match of mentions) {
|
||||
const giteaUsername = match[1]!
|
||||
const discordUsername = userMappings[giteaUsername]
|
||||
if (!issue.pull_request) return
|
||||
|
||||
if (discordUsername) {
|
||||
const discordId = await getDiscordUserId(discordUsername)
|
||||
if (discordId) {
|
||||
result = result.replace(match[0], `<@${discordId}>`)
|
||||
if (action === "created") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleCreated(issue, comment)
|
||||
} else if (action === "edited") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleEdited(issue, comment)
|
||||
} else if (action === "deleted") {
|
||||
await ensureThreadExists(issue, repository)
|
||||
await this.handleDeleted(comment)
|
||||
}
|
||||
|
||||
console.log(`✅ comment webhook action: ${action} on #${issue.number}`)
|
||||
}
|
||||
|
||||
static async handleCreated(issue: Gitea.Issue, comment: Gitea.Comment) {
|
||||
const commentRow = getCommentByGiteaId(comment.id)
|
||||
if (commentRow) {
|
||||
return // Comment already exists, skip
|
||||
}
|
||||
|
||||
const discordUsername = convertUsername({ giteaUsername: comment.user.login })
|
||||
const thread = await getThread(issue)
|
||||
if (!thread) {
|
||||
throw new Error(`No thread found for PR #${issue.number} when handling new comment`)
|
||||
}
|
||||
|
||||
const webhook = await getWebhook()
|
||||
const message = await webhook.send({
|
||||
content: await this.formatComment(comment.body),
|
||||
username: discordUsername,
|
||||
avatarURL: await getDiscordAvatarUrl(discordUsername),
|
||||
threadId: thread.id,
|
||||
})
|
||||
|
||||
insertComment(comment.id, message.id, thread.id)
|
||||
}
|
||||
|
||||
static async handleEdited(issue: Gitea.Issue, comment: Gitea.Comment) {
|
||||
const commentRow = getCommentByGiteaId(comment.id)
|
||||
if (!commentRow) {
|
||||
this.handleCreated(issue, comment)
|
||||
return
|
||||
}
|
||||
|
||||
const webhook = await getWebhook()
|
||||
const content = await this.formatComment(comment.body)
|
||||
await webhook.editMessage(commentRow.discord_message_id, {
|
||||
content,
|
||||
threadId: commentRow.discord_thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
static async handleDeleted(comment: Gitea.Comment) {
|
||||
const commentRow = getCommentByGiteaId(comment.id)
|
||||
if (!commentRow) {
|
||||
return // Comment doesn't exist, skip
|
||||
}
|
||||
|
||||
const webhook = await getWebhook()
|
||||
await webhook.editMessage(commentRow.discord_message_id, {
|
||||
content: `[DELETED]`,
|
||||
threadId: commentRow.discord_thread_id,
|
||||
})
|
||||
}
|
||||
|
||||
static async formatComment(comment: string) {
|
||||
const giteaToDiscordUserMappings = getConfig("giteaToDiscordUserMappings")
|
||||
|
||||
// Find all @username mentions
|
||||
const mentionRegex = /@(\w+)/g
|
||||
const mentions = [...comment.matchAll(mentionRegex)]
|
||||
|
||||
let result = comment
|
||||
for (const match of mentions) {
|
||||
const giteaUsername = match[1]!
|
||||
const discordUsername = giteaToDiscordUserMappings[giteaUsername]
|
||||
|
||||
if (discordUsername) {
|
||||
const discordUser = await getDiscordUser(discordUsername)
|
||||
if (discordUser) {
|
||||
result = result.replace(match[0], `<@${discordUser.id}>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class ReviewHandler {
|
||||
static canHandle(payload: unknown, eventType: EventType): payload is Gitea.PullRequestReviewWebhook {
|
||||
return eventType === "pull_request_comment"
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
static async handle(payload: Gitea.PullRequestReviewWebhook) {
|
||||
const { pull_request: pullRequest, repository, review, action } = payload
|
||||
|
||||
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"
|
||||
number: number
|
||||
pull_request: GiteaPullRequest
|
||||
repository: GiteaRepository
|
||||
sender: GiteaUser
|
||||
}
|
||||
|
||||
interface IssueCommentWebhook {
|
||||
action: "created" | "edited" | "deleted"
|
||||
issue: {
|
||||
id: number
|
||||
number: number
|
||||
pull_request?: {
|
||||
html_url: string
|
||||
// Only handle new reviews, ignore edits
|
||||
if (action !== "reviewed") {
|
||||
console.log(`✅ review webhook action: ${action} on #${pullRequest.number} (ignored)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
comment: {
|
||||
id: number
|
||||
body: string
|
||||
user: GiteaUser
|
||||
html_url: string
|
||||
}
|
||||
repository: GiteaRepository
|
||||
}
|
||||
|
||||
type GiteaWebhook = PullRequestWebhook | IssueCommentWebhook
|
||||
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,5 +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
|
||||
|
|
@ -13,25 +15,29 @@ const errors: ErrorLog[] = []
|
|||
const server = serve({
|
||||
port: parseInt(process.env.PORT || "3000"),
|
||||
routes: {
|
||||
"/": async () => {
|
||||
return new Response("🌵")
|
||||
"/": {
|
||||
GET: () => new Response("🌵"),
|
||||
POST: () => new Response("Use /gitea/webhook for POST requests", { status: 400 }),
|
||||
},
|
||||
"/gitea/webhook": {
|
||||
POST: async (req) => {
|
||||
const payload = await req.json()
|
||||
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 = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
payload
|
||||
payload,
|
||||
}
|
||||
errors.push(errorLog)
|
||||
console.error("Webhook error:", errorLog)
|
||||
console.error("💥 Webhook error 💥")
|
||||
console.error(error)
|
||||
return new Response("Error", { status: 500 })
|
||||
}
|
||||
},
|
||||
|
|
@ -39,18 +45,19 @@ const server = serve({
|
|||
"/errors": {
|
||||
GET: () => {
|
||||
return new Response(JSON.stringify(errors, null, 2), {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
"/discord/auth": async () => {
|
||||
const permissions = 536870912 // from https://discord.com/developers/applications
|
||||
const authorizeUrl = `https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=${permissions}`
|
||||
|
||||
const html = (
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authenticate spike</h1>
|
||||
<a href="https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=${permissions}">
|
||||
Authorize
|
||||
</a>
|
||||
<a href={authorizeUrl}>Authorize</a>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user