-
Notifications
You must be signed in to change notification settings - Fork 0
feat [mumu, redis]: threadTs 저장소를 in-memory Map에서 Redis로 마이그레이션 #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
60c5486
6868af2
6b82b3a
7dbbf34
f18b2bf
fcfbee8
03c5404
e584772
25a3039
c78fafb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./storage"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }, | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import type { SetCommandOptions } from "@upstash/redis"; | ||
|
|
||
| export type RedisStorage = { | ||
| register: (config: RedisConfig) => void; | ||
| get: <T = string>(key: string) => Promise<T | null>; | ||
| set: <T = string>(key: string, value: T, options?: SetCommandOptions) => Promise<T | "OK" | null>; | ||
| delete: (key: string) => Promise<number>; | ||
| }; | ||
|
|
||
| export type RedisConfig = { | ||
| url: string; | ||
| token: string; | ||
| retry?: number; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "ES2022", | ||
| "moduleResolution": "bundler", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "outDir": "dist", | ||
| "rootDir": "src" | ||
| }, | ||
| "include": ["src/**/*"] | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,3 @@ | ||
| packages: | ||
| - "servers/*" | ||
| - "packages/*" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SlackThread>(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<SlackThread>( | ||
| cacheKey, | ||
| { | ||
| ok: response.ok, | ||
| channel: response.channel, | ||
| threadTs: response.ts, | ||
| message: response.message, | ||
| }, | ||
| { ex: 60 * 60 * 24 * 21 }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 사실상 21일보다 긴 기간동안 열리는 PR은 없을것이라고 생각하면 되겠죠??
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 정확합니당 |
||
| ); | ||
| } catch { | ||
| console.error(`${cacheKey}: 슬랙 스레드 생성 실패`); | ||
| } | ||
|
|
||
| return JSON.stringify({ success: true, message: "Pull request processed successfully" }); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여기도 console.error가 아니라 log인 점이 궁금합니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
요것도 동일합니당.
로그에 대한 기준은 한 번더 고민해보겠습니다.