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