diff --git a/packages/redis/package.json b/packages/redis/package.json new file mode 100644 index 0000000..10020f2 --- /dev/null +++ b/packages/redis/package.json @@ -0,0 +1,13 @@ +{ + "name": "@makers-devops/redis", + "version": "1.0.0", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@upstash/redis": "^1.36.3" + }, + "packageManager": "pnpm@10.22.0" +} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts new file mode 100644 index 0000000..e2f63aa --- /dev/null +++ b/packages/redis/src/index.ts @@ -0,0 +1 @@ +export * from "./storage"; diff --git a/packages/redis/src/storage.ts b/packages/redis/src/storage.ts new file mode 100644 index 0000000..bea08cd --- /dev/null +++ b/packages/redis/src/storage.ts @@ -0,0 +1,44 @@ +import { Redis } from "@upstash/redis"; +import type { RedisStorage } from "./types"; + +export const createRedisStorage = (): RedisStorage => { + let redisInstance: Redis | null = null; + + return { + register: (config) => { + if (redisInstance) { + console.warn("Redis instance already registered"); + return; + } + + redisInstance = new Redis({ + url: config.url, + token: config.token, + retry: { + retries: config.retry ?? 5, + }, + }); + }, + get: (key) => { + if (!redisInstance) { + throw new Error("Redis instance is not set"); + } + + return redisInstance.get(key); + }, + set: (key, value, options) => { + if (!redisInstance) { + throw new Error("Redis instance is not set"); + } + + return redisInstance.set(key, value, options); + }, + delete: (key) => { + if (!redisInstance) { + throw new Error("Redis instance is not set"); + } + + return redisInstance.del(key); + }, + }; +}; diff --git a/packages/redis/src/types.ts b/packages/redis/src/types.ts new file mode 100644 index 0000000..5f41467 --- /dev/null +++ b/packages/redis/src/types.ts @@ -0,0 +1,14 @@ +import type { SetCommandOptions } from "@upstash/redis"; + +export type RedisStorage = { + register: (config: RedisConfig) => void; + get: (key: string) => Promise; + set: (key: string, value: T, options?: SetCommandOptions) => Promise; + delete: (key: string) => Promise; +}; + +export type RedisConfig = { + url: string; + token: string; + retry?: number; +}; diff --git a/packages/redis/tsconfig.json b/packages/redis/tsconfig.json new file mode 100644 index 0000000..88d6010 --- /dev/null +++ b/packages/redis/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e110f..be646dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,17 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/redis: + dependencies: + '@upstash/redis': + specifier: ^1.36.3 + version: 1.36.3 + servers/mumu: dependencies: + '@makers-devops/redis': + specifier: workspace:* + version: link:../../packages/redis '@octokit/rest': specifier: ^21.0.0 version: 21.1.1 @@ -377,6 +386,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@upstash/redis@1.36.3': + resolution: {integrity: sha512-wxo1ei4OHDHm4UGMgrNVz9QUEela9N/Iwi4p1JlHNSowQiPi+eljlGnfbZVkV0V4PIrjGtGFJt5GjWM5k28enA==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -867,6 +879,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -1156,6 +1171,10 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.3.2 + '@upstash/redis@1.36.3': + dependencies: + uncrypto: 0.1.3 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -1690,6 +1709,8 @@ snapshots: typescript@5.9.3: {} + uncrypto@0.1.3: {} + undici-types@7.18.2: {} universal-user-agent@7.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 306caea..60c2234 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "servers/*" + - "packages/*" diff --git a/servers/mumu/.env.example b/servers/mumu/.env.example index 85b0406..4793fbb 100644 --- a/servers/mumu/.env.example +++ b/servers/mumu/.env.example @@ -2,3 +2,5 @@ GITHUB_WEBHOOK_SECRET=your_webhook_secret GITHUB_TOKEN=your_github_token SLACK_BOT_TOKEN=xoxb-your-slack-bot-token PORT=3000 +UPSTASH_REDIS_REST_URL=https://your-endpoint.upstash.io +UPSTASH_REDIS_REST_TOKEN=your_upstash_token diff --git a/servers/mumu/package.json b/servers/mumu/package.json index 50d2881..8b31ff4 100644 --- a/servers/mumu/package.json +++ b/servers/mumu/package.json @@ -15,7 +15,8 @@ "dotenv": "^16.4.0", "express": "^4.21.0", "tsx": "^4.19.0", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@makers-devops/redis": "workspace:*" }, "devDependencies": { "@types/express": "^5.0.0" diff --git a/servers/mumu/src/config.ts b/servers/mumu/src/config.ts index 869f60a..6707401 100644 --- a/servers/mumu/src/config.ts +++ b/servers/mumu/src/config.ts @@ -18,11 +18,8 @@ export function loadConfig(): Config { export const config = loadConfig(); -export function validateRepository(repo: string): string { - const matchedRepo = config.repos.find((_repo: string) => _repo === repo); - if (!matchedRepo) { - throw new Error(`${repo}를 찾지 못했어요.`); - } +export function isValidRepository(repo: string | null | undefined) { + if (repo == null) return false; - return matchedRepo; + return config.repos.includes(repo); } diff --git a/servers/mumu/src/github/comment.ts b/servers/mumu/src/github/comment.ts index b56cbfd..12ed5d7 100644 --- a/servers/mumu/src/github/comment.ts +++ b/servers/mumu/src/github/comment.ts @@ -1,5 +1,6 @@ -import { threadStorage } from "../webhook"; +import { redisStorage } from "../redis"; import type { SlackNotifier } from "../slack"; +import type { SlackThread } from "../types"; import type { PullRequestReviewComment } from "./schema"; const MAX_CHARS = 200; @@ -10,26 +11,35 @@ const truncateBody = (body: string): string => { return `${twoLines.slice(0, MAX_CHARS)}...`; }; -export const handlePullRequestReviewComment = (payload: PullRequestReviewComment, slackNotifier: SlackNotifier) => { +export const handlePullRequestReviewComment = async ( + payload: PullRequestReviewComment, + slackNotifier: SlackNotifier, +) => { if (payload.action !== "created") { - return JSON.stringify({ success: true, message: "Review comment action skipped." }); + return JSON.stringify({ success: false, message: "Review comment action skipped." }); } const repoFullName = payload.repository.full_name; const prNumber = payload.pull_request.number; - const thread = threadStorage.get(repoFullName, prNumber); + const cacheKey = `${repoFullName}#${prNumber}`; - const preview = truncateBody(payload.comment.body); - const text = [`> *${payload.comment.user.login}*`, `> <${payload.comment.html_url}|${preview}>`].join("\n"); + const thread = await redisStorage.get(cacheKey); if (!thread?.threadTs) { return JSON.stringify({ success: false, - message: `Review comment thread not found for ${repoFullName}#${prNumber}`, + message: `${cacheKey}/${thread?.channel}: threadTs를 찾을 수 없어요.`, }); } - slackNotifier.createThreadReply(thread.threadTs, text); + const preview = truncateBody(payload.comment.body); + const text = [`> *${payload.comment.user.login}*`, `> <${payload.comment.html_url}|${preview}>`].join("\n"); + + try { + await slackNotifier.createThreadReply(thread.threadTs, text); + } catch { + console.error(`${cacheKey}/${thread.channel}: review comment 슬랙 스레드 답변 전송 실패`); + } return JSON.stringify({ success: true, message: "Review comment processed successfully" }); }; diff --git a/servers/mumu/src/github/pull_request.ts b/servers/mumu/src/github/pull_request.ts index 2c291d0..58ccb3e 100644 --- a/servers/mumu/src/github/pull_request.ts +++ b/servers/mumu/src/github/pull_request.ts @@ -1,13 +1,40 @@ -import { config, validateRepository } from "../config"; +import { config } from "../config"; import { assignReviewers, selectReviewers } from "./review"; import type { PullRequest } from "./schema"; -import { threadStorage } from "../webhook"; import type { SlackNotifier } from "../slack"; +import { redisStorage } from "../redis"; +import type { SlackThread } from "../types"; type HandledAction = (typeof HANDLED_ACTIONS)[number]; const HANDLED_ACTIONS = ["opened", "reopened", "closed"] as const; -export const handlePullRequest = (pullRequest: PullRequest, slackNotifier: SlackNotifier) => { +const handlePullRequestClosed = async (pullRequest: PullRequest, slackNotifier: SlackNotifier) => { + const cacheKey = `${pullRequest.repository.full_name}#${pullRequest.pull_request.number}`; + const thread = await redisStorage.get(cacheKey); + + const isMerged = pullRequest.pull_request.merged === true; + const replyText = `> ${isMerged ? "🎉 *PR이 머지되었어요.*" : "🚫 *PR이 닫혔어요.*"}`; + + if (!thread?.threadTs) { + console.warn(`${cacheKey}/${thread?.channel}: threadTs를 찾을 수 없어요.`); + } else { + try { + await slackNotifier.createThreadReply(thread.threadTs, replyText); + } catch { + console.error(`${cacheKey}/${thread.channel}: 슬랙 스레드 답변 전송 실패`); + } + } + + if (thread) { + await redisStorage.delete(cacheKey).catch((err) => { + console.log(`${cacheKey}: Redis 캐시 삭제 실패`, err); + }); + } + + return JSON.stringify({ success: true, message: "Pull request closed." }); +}; + +export const handlePullRequest = async (pullRequest: PullRequest, slackNotifier: SlackNotifier) => { if (!HANDLED_ACTIONS.includes(pullRequest.action as HandledAction)) { return JSON.stringify({ success: true, message: "Pull request action skipped." }); } @@ -16,22 +43,11 @@ export const handlePullRequest = (pullRequest: PullRequest, slackNotifier: Slack const repoName = repoFullName.split("/")[1]; const prNumber = pullRequest.pull_request.number; - const validRepo = validateRepository(repoName); + const cacheKey = `${repoFullName}#${prNumber}`; /** PR이 closed/merged 된 경우 */ if (pullRequest.action === "closed") { - const thread = threadStorage.get(repoFullName, prNumber); - const isMerged = pullRequest.pull_request.merged === true; - const replyText = `> ${isMerged ? "🎉 *PR이 머지되었어요.*" : "🚫 *PR이 닫혔어요.*"}`; - - if (!thread?.threadTs) { - console.warn(`[pull_request] threadTs not found for ${repoFullName}#${prNumber} — server may have restarted`); - } else { - slackNotifier.createThreadReply(thread.threadTs, replyText); - } - - threadStorage.delete(repoFullName, prNumber); - return JSON.stringify({ success: true, message: "Pull request closed." }); + return await handlePullRequestClosed(pullRequest, slackNotifier); } const reviewers = selectReviewers(config.admins, pullRequest.pull_request.user.login, 3); @@ -45,21 +61,33 @@ export const handlePullRequest = (pullRequest: PullRequest, slackNotifier: Slack /** 백그라운드에서 리뷰어 지정 (not await) */ assignReviewers( - validRepo, + repoName, prNumber, reviewers.map((r) => r.github), - ); - - /** 스레드 생성 */ - slackNotifier.createThread(text).then((response) => { - /** 스레드 정보 저장 */ - threadStorage.set(repoFullName, prNumber, { - ok: response.ok, - channel: response.channel, - threadTs: response.ts, - message: response.message, - }); + ).catch((err) => { + console.log(`${cacheKey}: 리뷰어 지정 실패`, err); }); + try { + const response = await slackNotifier.createThread(text); + + if (!response.ts) { + console.error(`${cacheKey}: Slack 스레드 생성 응답에 ts가 없어요 (ok: ${response.ok})`); + return JSON.stringify({ success: false, message: "Slack thread ts missing" }); + } + await redisStorage.set( + cacheKey, + { + ok: response.ok, + channel: response.channel, + threadTs: response.ts, + message: response.message, + }, + { ex: 60 * 60 * 24 * 21 }, + ); + } catch { + console.error(`${cacheKey}: 슬랙 스레드 생성 실패`); + } + return JSON.stringify({ success: true, message: "Pull request processed successfully" }); }; diff --git a/servers/mumu/src/index.ts b/servers/mumu/src/index.ts index 968bf88..184972b 100644 --- a/servers/mumu/src/index.ts +++ b/servers/mumu/src/index.ts @@ -1,23 +1,35 @@ import "dotenv/config"; import express from "express"; import { createWebhookRouter } from "./webhook"; +import { redisStorage } from "./redis"; +import { assertNonNullish } from "./util"; -async function main() { - const app = express(); +process.on("unhandledRejection", (reason) => { + console.error("Unhandled rejection:", reason); +}); - /** 요청 JSON 바디 파싱 */ - app.use(express.json()); +const app = express(); - app.use("/api", createWebhookRouter()); +assertNonNullish(process.env.UPSTASH_REDIS_REST_URL, "UPSTASH_REDIS_REST_URL is not set"); +assertNonNullish(process.env.UPSTASH_REDIS_REST_TOKEN, "UPSTASH_REDIS_REST_TOKEN is not set"); - app.get("/health", (_req, res) => { - res.status(200).json({ status: "ok" }); - }); +redisStorage.register({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, + retry: 3, +}); - const port = process.env.PORT || 3000; - app.listen(port, () => { - console.log(`mumu server running on port:${port}`); - }); -} +/** 요청 JSON 바디 파싱 */ +app.use(express.json()); -main(); +app.use("/api", createWebhookRouter()); + +app.get("/health", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); + +const port = Number(process.env.PORT) || 3000; + +app.listen(port, () => { + console.log(`mumu server running on port:${port}`); +}); diff --git a/servers/mumu/src/redis.ts b/servers/mumu/src/redis.ts new file mode 100644 index 0000000..3a09df4 --- /dev/null +++ b/servers/mumu/src/redis.ts @@ -0,0 +1,3 @@ +import { createRedisStorage } from "@makers-devops/redis"; + +export const redisStorage = createRedisStorage(); diff --git a/servers/mumu/src/storage.ts b/servers/mumu/src/storage.ts deleted file mode 100644 index 5229a5b..0000000 --- a/servers/mumu/src/storage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { SlackThread } from "./types"; - -/** pull_request - thread map store */ -export const createThreadStorage = () => { - const map = new Map(); - - const key = (repoFullName: string, prNumber: number) => `${repoFullName}#${prNumber}`; - - return { - set: (repoFullName: string, prNumber: number, value: SlackThread) => { - map.set(key(repoFullName, prNumber), value); - }, - get: (repoFullName: string, prNumber: number) => { - return map.get(key(repoFullName, prNumber)); - }, - delete: (repoFullName: string, prNumber: number) => { - map.delete(key(repoFullName, prNumber)); - }, - }; -}; diff --git a/servers/mumu/src/webhook.ts b/servers/mumu/src/webhook.ts index a55feb2..7bb9eae 100644 --- a/servers/mumu/src/webhook.ts +++ b/servers/mumu/src/webhook.ts @@ -3,11 +3,9 @@ import { createSlackNotifier } from "./slack"; import { assertNonNullish } from "./util"; import { pullRequestSchema, pullRequestReviewCommentSchema } from "./github/schema"; import { FRONTEND_BOT_CHANNEL } from "./constant"; -import { createThreadStorage } from "./storage"; import { handlePullRequest } from "./github/pull_request"; import { handlePullRequestReviewComment } from "./github/comment"; - -export const threadStorage = createThreadStorage(); +import { isValidRepository } from "./config"; export function createWebhookRouter(): Router { assertNonNullish(process.env.SLACK_BOT_TOKEN, "SLACK_BOT_TOKEN 환경변수가 누락되었어요."); @@ -20,26 +18,34 @@ export function createWebhookRouter(): Router { router.post("/webhook", async (req: Request, res: Response) => { const event = req.headers["x-github-event"]; + const fullName: string | undefined = req.body?.repository?.full_name; + const repoName = fullName?.split("/")[1]; + + if (!isValidRepository(repoName)) { + return res.status(400).json({ error: `Invalid repository: ${fullName}` }); + } + /** * Webhook API는 10초 동안 응답하지 않으면 delivery하지 않음 * 각 핸들러들을 백그라운드에서 실행하고 바로 응답 */ res.status(200).send("Webhook received"); + try { switch (event) { case "pull_request": { - const result = handlePullRequest(pullRequestSchema.parse(req.body), slackNotifier); - console.log(`Pull Request: ${JSON.parse(result)}`); + handlePullRequest(pullRequestSchema.parse(req.body), slackNotifier).then((res) => console.log(res)); break; } case "pull_request_review_comment": { - const result = handlePullRequestReviewComment(pullRequestReviewCommentSchema.parse(req.body), slackNotifier); - console.log(`Pull Request Review Comment: ${JSON.parse(result)}`); + handlePullRequestReviewComment(pullRequestReviewCommentSchema.parse(req.body), slackNotifier).then((res) => + console.log(res), + ); break; } } } catch (err) { - console.error(`${event} Error:`, err); + console.error(`[${event}] webhook 처리 실패:`, err); } });