Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/redis/package.json
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"
}
1 change: 1 addition & 0 deletions packages/redis/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./storage";
44 changes: 44 additions & 0 deletions packages/redis/src/storage.ts
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);
},
};
};
14 changes: 14 additions & 0 deletions packages/redis/src/types.ts
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;
};
13 changes: 13 additions & 0 deletions packages/redis/tsconfig.json
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/**/*"]
}
21 changes: 21 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
packages:
- "servers/*"
- "packages/*"
2 changes: 2 additions & 0 deletions servers/mumu/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion servers/mumu/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 3 additions & 6 deletions servers/mumu/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
26 changes: 18 additions & 8 deletions servers/mumu/src/github/comment.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<SlackThread>(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" });
};
84 changes: 56 additions & 28 deletions servers/mumu/src/github/pull_request.ts
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." });
}
Expand All @@ -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);
Expand All @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 console.error가 아니라 log인 점이 궁금합니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 동일합니당.

로그에 대한 기준은 한 번더 고민해보겠습니다.

});

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 },

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실상 21일보다 긴 기간동안 열리는 PR은 없을것이라고 생각하면 되겠죠??

Copy link
Member Author

Choose a reason for hiding this comment

The 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" });
};
40 changes: 26 additions & 14 deletions servers/mumu/src/index.ts
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}`);
});
Loading